@praxisui/page-builder 10.0.0-rc.32 → 10.0.0-rc.34
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/ai/component-registry.json +10 -8
- package/fesm2022/praxisui-page-builder.mjs +1405 -133
- package/package.json +4 -4
- package/types/praxisui-page-builder.d.ts +84 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { Inject, ChangeDetectionStrategy, Component, EventEmitter, Output, Input, inject, ElementRef, signal, computed, effect, Optional, input, output, Injector, viewChild, afterNextRender, afterEveryRender, ViewChild, InjectionToken, ENVIRONMENT_INITIALIZER, Injectable, isSignal } from '@angular/core';
|
|
2
|
+
import { Inject, ChangeDetectionStrategy, Component, EventEmitter, Output, Input, inject, ElementRef, signal, computed, effect, Optional, input, output, Injector, viewChild, afterNextRender, DestroyRef, afterEveryRender, ViewChild, InjectionToken, ENVIRONMENT_INITIALIZER, Injectable, isSignal } from '@angular/core';
|
|
3
3
|
import * as i2 from '@angular/material/button';
|
|
4
4
|
import { MatButtonModule } from '@angular/material/button';
|
|
5
5
|
import * as i1 from '@angular/material/dialog';
|
|
@@ -7,28 +7,30 @@ import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
|
|
7
7
|
import * as i2$1 from '@angular/material/icon';
|
|
8
8
|
import { MatIconModule } from '@angular/material/icon';
|
|
9
9
|
import * as i2$2 from '@praxisui/core';
|
|
10
|
-
import { PraxisIconDirective, providePraxisI18n, BUILTIN_WIDGET_SHELL_PRESETS, SETTINGS_EDITOR_SECTIONS_HOSTED, PraxisI18nService, getWidgetShellActionIdError, WidgetShellComponent, SettingsSectionTabsComponent, PraxisIconButtonComponent, NestedWidgetConfigAccessor, GLOBAL_ACTION_CATALOG, ComponentMetadataRegistry, CompositionValidatorService, getGlobalActionCatalog, PRAXIS_GLOBAL_ACTION_CATALOG, SurfaceOpenActionEditorComponent, BUILTIN_PAGE_LAYOUT_PRESETS, resolveWidgetPageLayoutPreset, BUILTIN_PAGE_THEME_PRESETS, prepareCanvasArrangement, SETTINGS_PANEL_DATA as SETTINGS_PANEL_DATA$1, WidgetPageDataSourceRuntimeService, WidgetPageStateRuntimeService, validateCanvasArrangement, PraxisJsonLogicService, canonicalJsonSha256,
|
|
10
|
+
import { PraxisIconDirective, providePraxisI18n, BUILTIN_WIDGET_SHELL_PRESETS, SETTINGS_EDITOR_SECTIONS_HOSTED, PraxisI18nService, getWidgetShellActionIdError, WidgetShellComponent, SettingsSectionTabsComponent, PraxisIconButtonComponent, NestedWidgetConfigAccessor, GLOBAL_ACTION_CATALOG, ComponentMetadataRegistry, CompositionValidatorService, getGlobalActionCatalog, PRAXIS_GLOBAL_ACTION_CATALOG, SurfaceOpenActionEditorComponent, BUILTIN_PAGE_LAYOUT_PRESETS, resolveWidgetPageLayoutPreset, BUILTIN_PAGE_THEME_PRESETS, prepareCanvasArrangement, SETTINGS_PANEL_DATA as SETTINGS_PANEL_DATA$1, ResourceDiscoveryService, WidgetPageDataSourceRuntimeService, WidgetPageStateRuntimeService, applyDeclarativeValuePipe, validateCanvasArrangement, ResourceQuickConnectComponent, PraxisJsonLogicService, canonicalJsonSha256, ComponentRuntimeProfileService, buildApiUrl, deepMerge, generateId, ObservabilityDashboardService, PraxisRuntimeComponentObservationRegistryService, PraxisCustomizationModeService, domainKnowledgeTimelineToRichContentDocument, DomainRuleService, DomainKnowledgeService, SETTINGS_PANEL_BRIDGE, DynamicWidgetPageComponent, DYNAMIC_PAGE_SHELL_EDITOR } from '@praxisui/core';
|
|
11
11
|
import * as i3 from '@angular/material/tooltip';
|
|
12
12
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
13
|
-
import * as
|
|
13
|
+
import * as i6 from '@angular/material/form-field';
|
|
14
14
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
15
|
-
import * as
|
|
15
|
+
import * as i8 from '@angular/material/input';
|
|
16
16
|
import { MatInputModule } from '@angular/material/input';
|
|
17
17
|
import * as i1$1 from '@angular/forms';
|
|
18
18
|
import { FormsModule, FormGroup, FormArray, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
19
19
|
import * as i1$2 from '@angular/common';
|
|
20
20
|
import { CommonModule, NgTemplateOutlet } from '@angular/common';
|
|
21
|
-
import * as i4
|
|
21
|
+
import * as i4 from '@angular/material/checkbox';
|
|
22
22
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
23
|
-
import * as
|
|
23
|
+
import * as i3$1 from '@angular/material/select';
|
|
24
24
|
import { MatSelectModule } from '@angular/material/select';
|
|
25
25
|
import { BehaviorSubject, merge, timeout, map, catchError, throwError, firstValueFrom, from, share, concatMap, of, timer, takeUntil, Subject, tap, filter, take, isObservable } from 'rxjs';
|
|
26
26
|
import { SETTINGS_PANEL_DATA } from '@praxisui/settings-panel';
|
|
27
27
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
28
28
|
import { CdkTrapFocus } from '@angular/cdk/a11y';
|
|
29
|
-
import * as
|
|
29
|
+
import * as i2$3 from '@angular/material/autocomplete';
|
|
30
|
+
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
|
31
|
+
import * as i5 from '@angular/material/button-toggle';
|
|
30
32
|
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
|
31
|
-
import * as
|
|
33
|
+
import * as i9 from '@angular/material/tabs';
|
|
32
34
|
import { MatTabsModule } from '@angular/material/tabs';
|
|
33
35
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
|
34
36
|
import { AgenticAuthoringTurnClientService, withAuthoringScopePolicyContextHints, toPraxisAssistantConversationMessages, PraxisAssistantTurnOrchestratorService, PraxisAssistantSessionRegistryService, createPraxisAssistantViewportLayout, PraxisAiAssistantShellComponent } from '@praxisui/ai';
|
|
@@ -1483,10 +1485,31 @@ const PRAXIS_PAGE_BUILDER_EN_US = {
|
|
|
1483
1485
|
'praxis.pageBuilder.editor.state.unbindAction': "Remove binding",
|
|
1484
1486
|
'praxis.pageBuilder.editor.state.unboundSuccess': "Removed binding {{widget}}.{{input}} from draft.",
|
|
1485
1487
|
'praxis.pageBuilder.editor.state.autoBoundSuccess': "Auto-bound {{count}} widget placeholders to page state in draft.",
|
|
1486
|
-
'praxis.pageBuilder.editor.state.autoBoundNone': "No unbound ${...} widget placeholders matched available state tokens.",
|
|
1488
|
+
'praxis.pageBuilder.editor.state.autoBoundNone': "No unbound ${...} / {{...}} widget placeholders matched available state tokens.",
|
|
1489
|
+
'praxis.pageBuilder.editor.state.unboundPlaceholdersWarning': "Unbound placeholders detected:",
|
|
1490
|
+
'praxis.pageBuilder.editor.state.selectUnboundPlaceholderHint': "Click to populate widget and path in the binder form",
|
|
1491
|
+
'praxis.pageBuilder.editor.state.binderInputSuggestions': "Suggested paths:",
|
|
1492
|
+
'praxis.pageBuilder.editor.state.binderTokenSuggestions': "Available tokens:",
|
|
1493
|
+
'praxis.pageBuilder.editor.state.binderPipeSuggestions': "CSP-safe pipes:",
|
|
1494
|
+
'praxis.pageBuilder.editor.state.binderLivePreview': "Simulated preview:",
|
|
1495
|
+
'praxis.pageBuilder.editor.state.binderPipeInvalid': "Invalid declarative pipe. Allowed CSP-safe pipes: default, coalesce, round[:digits], number[:digits], percent[:digits], currency[:code], uppercase, lowercase.",
|
|
1496
|
+
'praxis.pageBuilder.editor.state.invalidWidgetPipesWarning': "Invalid declarative pipes detected in widgets:",
|
|
1497
|
+
'praxis.pageBuilder.editor.state.editBindingHint': "Click to load binding into form",
|
|
1498
|
+
'praxis.pageBuilder.editor.grouping.widgets': "Widgets in group",
|
|
1499
|
+
'praxis.pageBuilder.widgetTypes.richContent': "Rich Content",
|
|
1500
|
+
'praxis.pageBuilder.widgetTypes.chart': "Chart",
|
|
1501
|
+
'praxis.pageBuilder.widgetTypes.table': "Table",
|
|
1502
|
+
'praxis.pageBuilder.widgetTypes.list': "List",
|
|
1503
|
+
'praxis.pageBuilder.widgetTypes.form': "Form",
|
|
1504
|
+
'praxis.pageBuilder.widgetTypes.stat': "Metric",
|
|
1505
|
+
'praxis.pageBuilder.widgetTypes.tabs': "Tabs",
|
|
1506
|
+
'praxis.pageBuilder.widgetTypes.widget': "Widget",
|
|
1507
|
+
'praxis.pageBuilder.widgetTypes.formModePrefix': "Mode",
|
|
1487
1508
|
'praxis.pageBuilder.palette.initialWidth': "Initial width",
|
|
1488
1509
|
'praxis.pageBuilder.palette.keepOpen': "Keep open for multi-insert",
|
|
1489
1510
|
'praxis.pageBuilder.palette.done': "Done",
|
|
1511
|
+
'praxis.pageBuilder.studio.composer.groupFieldHint': "Choose the boolean or categorical field used in summary indicators.",
|
|
1512
|
+
'praxis.pageBuilder.studio.composer.statsUnsupportedHint': "This resource does not publish stats/group-by aggregation; summary indicators were disabled automatically.",
|
|
1490
1513
|
};
|
|
1491
1514
|
|
|
1492
1515
|
const PRAXIS_PAGE_BUILDER_PT_BR = {
|
|
@@ -2612,10 +2635,31 @@ const PRAXIS_PAGE_BUILDER_PT_BR = {
|
|
|
2612
2635
|
'praxis.pageBuilder.editor.state.unbindAction': "Remover vínculo",
|
|
2613
2636
|
'praxis.pageBuilder.editor.state.unboundSuccess': "Vínculo {{widget}}.{{input}} removido do rascunho.",
|
|
2614
2637
|
'praxis.pageBuilder.editor.state.autoBoundSuccess': "{{count}} placeholders de widgets vinculados ao estado da página no rascunho.",
|
|
2615
|
-
'praxis.pageBuilder.editor.state.autoBoundNone': "Nenhum placeholder ${...} sem vínculo correspondeu aos tokens de estado disponíveis.",
|
|
2638
|
+
'praxis.pageBuilder.editor.state.autoBoundNone': "Nenhum placeholder ${...} / {{...}} sem vínculo correspondeu aos tokens de estado disponíveis.",
|
|
2639
|
+
'praxis.pageBuilder.editor.state.unboundPlaceholdersWarning': "Placeholders sem vínculo detectados:",
|
|
2640
|
+
'praxis.pageBuilder.editor.state.selectUnboundPlaceholderHint': "Clique para preencher widget e caminho no formulário",
|
|
2641
|
+
'praxis.pageBuilder.editor.state.binderInputSuggestions': "Caminhos sugeridos:",
|
|
2642
|
+
'praxis.pageBuilder.editor.state.binderTokenSuggestions': "Tokens disponíveis:",
|
|
2643
|
+
'praxis.pageBuilder.editor.state.binderPipeSuggestions': "Pipes CSP-safe:",
|
|
2644
|
+
'praxis.pageBuilder.editor.state.binderLivePreview': "Prévia simulada:",
|
|
2645
|
+
'praxis.pageBuilder.editor.state.binderPipeInvalid': "Pipe declarativo inválido. Pipes CSP-safe suportados: default, coalesce, round[:casas], number[:casas], percent[:casas], currency[:moeda], uppercase, lowercase.",
|
|
2646
|
+
'praxis.pageBuilder.editor.state.invalidWidgetPipesWarning': "Pipes declarativos inválidos detectados nos widgets:",
|
|
2647
|
+
'praxis.pageBuilder.editor.state.editBindingHint': "Clique para carregar vínculo no formulário",
|
|
2648
|
+
'praxis.pageBuilder.editor.grouping.widgets': "Widgets do grupo",
|
|
2649
|
+
'praxis.pageBuilder.widgetTypes.richContent': "Conteúdo Rico",
|
|
2650
|
+
'praxis.pageBuilder.widgetTypes.chart': "Gráfico",
|
|
2651
|
+
'praxis.pageBuilder.widgetTypes.table': "Tabela",
|
|
2652
|
+
'praxis.pageBuilder.widgetTypes.list': "Lista",
|
|
2653
|
+
'praxis.pageBuilder.widgetTypes.form': "Formulário",
|
|
2654
|
+
'praxis.pageBuilder.widgetTypes.stat': "Indicador",
|
|
2655
|
+
'praxis.pageBuilder.widgetTypes.tabs': "Abas",
|
|
2656
|
+
'praxis.pageBuilder.widgetTypes.widget': "Widget",
|
|
2657
|
+
'praxis.pageBuilder.widgetTypes.formModePrefix': "Modo",
|
|
2616
2658
|
'praxis.pageBuilder.palette.initialWidth': "Largura inicial",
|
|
2617
2659
|
'praxis.pageBuilder.palette.keepOpen': "Manter aberto para inserção múltipla",
|
|
2618
2660
|
'praxis.pageBuilder.palette.done': "Concluir",
|
|
2661
|
+
'praxis.pageBuilder.studio.composer.groupFieldHint': "Escolha o campo booleano ou categórico usado nos indicadores de resumo.",
|
|
2662
|
+
'praxis.pageBuilder.studio.composer.statsUnsupportedHint': "Este recurso não publica agregação stats/group-by; os indicadores de resumo foram desativados automaticamente.",
|
|
2619
2663
|
};
|
|
2620
2664
|
|
|
2621
2665
|
function createPraxisPageBuilderI18nConfig(options = {}) {
|
|
@@ -2994,7 +3038,7 @@ class ComponentPaletteDialogComponent {
|
|
|
2994
3038
|
{{ insertedCount() > 0 ? tx('palette.done', 'Concluir') : tx('palette.cancel', 'Cancelar') }}
|
|
2995
3039
|
</button>
|
|
2996
3040
|
</div>
|
|
2997
|
-
`, isInline: true, styles: [":host{display:block}h2[mat-dialog-title]{margin:0;padding:12px 24px 10px;border-bottom:1px solid var(--md-sys-color-outline-variant)}.dlg-title{display:flex;align-items:center;gap:10px}.dlg-icon{color:var(--md-sys-color-primary)}.dlg-head{font-size:18px;font-weight:600;line-height:1.2}.dlg-sub{font-size:12px;opacity:.85;color:var(--md-sys-color-on-surface-variant)}.pdx-palette-content{min-width:480px;max-width:920px;padding-top:8px}.pdx-palette-search{width:100%;margin-bottom:8px}.pdx-palette-toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}.pdx-palette-span-group{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.pdx-palette-toolbar-label{font-size:12px;color:var(--md-sys-color-on-surface-variant);margin-right:4px}.pdx-span-chip,.pdx-keep-open-chip{min-height:32px;padding:0 10px;font-size:12px;border-radius:999px}.pdx-span-chip.selected,.pdx-keep-open-chip.selected{background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container);border-color:var(--md-sys-color-primary)}.pdx-inserted-badge{display:inline-flex;align-items:center;justify-content:center;margin-left:6px;padding:1px 6px;border-radius:999px;background:var(--md-sys-color-primary);color:var(--md-sys-color-on-primary);font-size:11px;font-weight:700}.pdx-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}.pdx-grid.dense{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.pdx-grid.roomy{grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}.pdx-card{appearance:none;position:relative;display:grid;grid-template-columns:44px 1fr;grid-template-rows:auto auto;gap:10px;padding:14px;border:1px solid transparent;border-radius:14px;cursor:pointer;outline:none;color:inherit;font:inherit;min-height:92px;text-align:left;background:var(--md-sys-color-surface);border-color:var(--md-sys-color-outline-variant);transition:box-shadow .2s ease,background .25s ease,transform .06s ease}.pdx-grid.dense .pdx-card{padding:10px;border-radius:12px;min-height:84px}.pdx-grid.roomy .pdx-card{padding:16px;border-radius:16px;min-height:100px}.pdx-card:focus,.pdx-card:hover{box-shadow:var(--mat-elevation-level4);background:var(--md-sys-color-surface-container-low);border-color:var(--md-sys-color-primary)}.pdx-card:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.pdx-card:active{transform:translateY(1px)}.pdx-card-icon{grid-row:span 2;display:flex;align-items:center;justify-content:center;color:var(--md-sys-color-primary)}.pdx-card-title{font-weight:600;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pdx-card-desc{color:var(--md-sys-color-on-surface-variant);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}.pdx-card-badges{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.pdx-card-badge{display:inline-flex;align-items:center;gap:4px;min-height:22px;padding:2px 8px;border-radius:999px;border:1px solid var(--md-sys-color-outline-variant);color:var(--md-sys-color-on-surface-variant);background:var(--md-sys-color-surface-container-highest);font-size:11px;line-height:1.2;font-weight:600;letter-spacing:.01em}.pdx-card-badge mat-icon{width:14px;height:14px;font-size:14px}.pdx-card-badge.ready{color:var(--md-sys-color-primary);border-color:color-mix(in srgb,var(--md-sys-color-primary) 45%,transparent);background:color-mix(in srgb,var(--md-sys-color-primary-container) 72%,transparent)}.pdx-card-badge.info{color:var(--md-sys-color-tertiary);border-color:color-mix(in srgb,var(--md-sys-color-tertiary) 38%,transparent);background:color-mix(in srgb,var(--md-sys-color-tertiary-container) 68%,transparent)}.pdx-card-badge.warning{color:var(--md-sys-color-error);border-color:color-mix(in srgb,var(--md-sys-color-error) 35%,transparent);background:color-mix(in srgb,var(--md-sys-color-error-container) 62%,transparent)}.pdx-empty{padding:24px;display:grid;place-items:center;color:var(--md-sys-color-on-surface-variant);gap:8px}@media(max-width:600px){.pdx-palette-content{min-width:320px}.pdx-grid{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { 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.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type: i1.MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "directive", type: i1.MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: i1.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i1.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type:
|
|
3041
|
+
`, isInline: true, styles: [":host{display:block}h2[mat-dialog-title]{margin:0;padding:12px 24px 10px;border-bottom:1px solid var(--md-sys-color-outline-variant)}.dlg-title{display:flex;align-items:center;gap:10px}.dlg-icon{color:var(--md-sys-color-primary)}.dlg-head{font-size:18px;font-weight:600;line-height:1.2}.dlg-sub{font-size:12px;opacity:.85;color:var(--md-sys-color-on-surface-variant)}.pdx-palette-content{min-width:480px;max-width:920px;padding-top:8px}.pdx-palette-search{width:100%;margin-bottom:8px}.pdx-palette-toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}.pdx-palette-span-group{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.pdx-palette-toolbar-label{font-size:12px;color:var(--md-sys-color-on-surface-variant);margin-right:4px}.pdx-span-chip,.pdx-keep-open-chip{min-height:32px;padding:0 10px;font-size:12px;border-radius:999px}.pdx-span-chip.selected,.pdx-keep-open-chip.selected{background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container);border-color:var(--md-sys-color-primary)}.pdx-inserted-badge{display:inline-flex;align-items:center;justify-content:center;margin-left:6px;padding:1px 6px;border-radius:999px;background:var(--md-sys-color-primary);color:var(--md-sys-color-on-primary);font-size:11px;font-weight:700}.pdx-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}.pdx-grid.dense{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.pdx-grid.roomy{grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}.pdx-card{appearance:none;position:relative;display:grid;grid-template-columns:44px 1fr;grid-template-rows:auto auto;gap:10px;padding:14px;border:1px solid transparent;border-radius:14px;cursor:pointer;outline:none;color:inherit;font:inherit;min-height:92px;text-align:left;background:var(--md-sys-color-surface);border-color:var(--md-sys-color-outline-variant);transition:box-shadow .2s ease,background .25s ease,transform .06s ease}.pdx-grid.dense .pdx-card{padding:10px;border-radius:12px;min-height:84px}.pdx-grid.roomy .pdx-card{padding:16px;border-radius:16px;min-height:100px}.pdx-card:focus,.pdx-card:hover{box-shadow:var(--mat-elevation-level4);background:var(--md-sys-color-surface-container-low);border-color:var(--md-sys-color-primary)}.pdx-card:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.pdx-card:active{transform:translateY(1px)}.pdx-card-icon{grid-row:span 2;display:flex;align-items:center;justify-content:center;color:var(--md-sys-color-primary)}.pdx-card-title{font-weight:600;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pdx-card-desc{color:var(--md-sys-color-on-surface-variant);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}.pdx-card-badges{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.pdx-card-badge{display:inline-flex;align-items:center;gap:4px;min-height:22px;padding:2px 8px;border-radius:999px;border:1px solid var(--md-sys-color-outline-variant);color:var(--md-sys-color-on-surface-variant);background:var(--md-sys-color-surface-container-highest);font-size:11px;line-height:1.2;font-weight:600;letter-spacing:.01em}.pdx-card-badge mat-icon{width:14px;height:14px;font-size:14px}.pdx-card-badge.ready{color:var(--md-sys-color-primary);border-color:color-mix(in srgb,var(--md-sys-color-primary) 45%,transparent);background:color-mix(in srgb,var(--md-sys-color-primary-container) 72%,transparent)}.pdx-card-badge.info{color:var(--md-sys-color-tertiary);border-color:color-mix(in srgb,var(--md-sys-color-tertiary) 38%,transparent);background:color-mix(in srgb,var(--md-sys-color-tertiary-container) 68%,transparent)}.pdx-card-badge.warning{color:var(--md-sys-color-error);border-color:color-mix(in srgb,var(--md-sys-color-error) 35%,transparent);background:color-mix(in srgb,var(--md-sys-color-error-container) 62%,transparent)}.pdx-empty{padding:24px;display:grid;place-items:center;color:var(--md-sys-color-on-surface-variant);gap:8px}@media(max-width:600px){.pdx-palette-content{min-width:320px}.pdx-grid{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { 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.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type: i1.MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "directive", type: i1.MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: i1.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i1.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i6.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i6.MatLabel, selector: "mat-label" }, { kind: "directive", type: i6.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i8.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: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { 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: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2998
3042
|
}
|
|
2999
3043
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ComponentPaletteDialogComponent, decorators: [{
|
|
3000
3044
|
type: Component,
|
|
@@ -4814,7 +4858,7 @@ class WidgetShellEditorComponent {
|
|
|
4814
4858
|
</div>
|
|
4815
4859
|
</details>
|
|
4816
4860
|
</div>
|
|
4817
|
-
`, isInline: true, styles: [":host{display:block;container:shell-editor / inline-size}.shell-effective-style{display:grid;gap:4px;margin:0;font-size:12px}.shell-effective-style span{color:var(--md-sys-color-on-surface-variant)}.shell-topic-panel,.shell-topic{display:grid;gap:var(--pfx-spacing-4, 16px)}.shell-topic-panel{padding-block:var(--pfx-spacing-4, 16px);min-width:0}.shell-topic-panel[hidden]{display:none}.shell-topic-panel>.shell-grid{margin-block-end:var(--pfx-spacing-4, 16px)}.shell-link-summary{display:grid;gap:var(--pfx-spacing-1, 4px);padding-block:var(--pfx-spacing-3, 12px);overflow-wrap:anywhere}.shell-button-task{display:grid;gap:var(--pfx-spacing-4, 16px);min-width:0}.shell-button-count{margin:0;font-size:14px}.shell-action-technical,.shell-action-scopes{min-width:0;font-size:12px}.shell-action-technical summary,.shell-action-scopes summary{cursor:pointer;padding-block:8px}.shell-action-technical summary:focus-visible,.shell-action-scopes summary:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.shell-action-contract{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px}.shell-action-contract dd{margin:0;overflow-wrap:anywhere}.shell-action-technical .shell-action-row{margin-top:12px}.shell-editor{display:grid;grid-template-columns:minmax(0,1fr);min-width:0;gap:20px;padding:12px 4px 20px}.shell-controls{display:grid;gap:var(--pfx-spacing-4, 16px);min-width:0;container:shell-controls / inline-size}.shell-preview-region{min-width:0}@container shell-controls (max-width: 480px){.shell-controls .shell-grid,.shell-controls .shell-action-row,.shell-controls .shell-quick-inputs,.shell-controls .shell-available-item{grid-template-columns:minmax(0,1fr)}.shell-controls .shell-available-item>button{justify-self:start}}.shell-intro{margin:0;max-width:68ch;color:var(--md-sys-color-on-surface-variant);font-size:14px;line-height:1.5}.shell-details{border-top:1px solid var(--md-sys-color-outline-variant);padding-top:var(--pfx-spacing-4, 16px)}.shell-details summary{cursor:pointer;color:var(--md-sys-color-on-surface);font-weight:600;line-height:1.5}.shell-details summary:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:4px}.shell-option-content{display:grid;gap:var(--pfx-spacing-4, 16px);margin-top:var(--pfx-spacing-4, 16px);min-width:0}.shell-details>.shell-grid{margin-top:var(--pfx-spacing-4, 16px)}.shell-details>.shell-section{margin:var(--pfx-spacing-4, 16px) 0 var(--pfx-spacing-2, 8px)}.shell-validation{color:var(--md-sys-color-error);margin:0;line-height:1.5}.shell-section{margin:4px 0 -8px;color:var(--md-sys-color-on-surface);font-size:14px;font-weight:650;line-height:1.4}.shell-grid{display:grid;gap:12px 16px;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start}.shell-field--wide{grid-column:1 / -1}.shell-preset-preview__body{display:grid;align-content:start;height:100%;color:var(--md-sys-color-on-surface-variant)}.shell-preset-preview__body .shell-mini-value,.shell-preset-preview__body .shell-mini-line{background:currentColor}.shell-preset-copy strong{display:flex;align-items:baseline;justify-content:space-between;gap:var(--pfx-spacing-2, 8px)}.shell-preset-check{color:var(--md-sys-color-primary);flex-shrink:0}.shell-preset-field,.shell-preset-copy{display:grid}.shell-preset-field{gap:8px}.shell-preset-label{color:var(--md-sys-color-on-surface);font-size:13px;font-weight:600}.shell-preset-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.shell-preset-option{min-width:0;display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px;padding:10px;border:1px solid var(--md-sys-color-outline-variant);border-radius:12px;background:var(--md-sys-color-surface);color:var(--md-sys-color-on-surface);text-align:left;cursor:pointer}.shell-preset-option:hover{background:var(--md-sys-color-surface-container-low)}.shell-preset-option:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.shell-preset-option--selected{border-color:var(--md-sys-color-primary);box-shadow:0 0 0 1px var(--md-sys-color-primary)}.shell-preset-preview{box-sizing:border-box;display:block;height:64px;overflow:hidden;border:1px solid var(--md-sys-color-outline-variant);background:var(--shell-mini-bg, var(--md-sys-color-surface));-webkit-backdrop-filter:var(--shell-mini-filter, none);backdrop-filter:var(--shell-mini-filter, none)}@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))){.shell-preset-preview--backdrop{background:var(--shell-mini-fallback, var(--md-sys-color-surface))}}@media(prefers-reduced-transparency:reduce){.shell-preset-preview--backdrop{background:var(--shell-mini-fallback, var(--md-sys-color-surface));-webkit-backdrop-filter:none;backdrop-filter:none}}.shell-preset-preview__header{display:flex;align-items:center;gap:4px;padding-inline:8px;height:22px;border-bottom:1px solid transparent}.shell-preset-preview--none{border-style:dashed;border-radius:8px;background:transparent;box-shadow:none}.shell-preset-copy{gap:3px}.shell-preset-copy small{color:var(--md-sys-color-on-surface-variant);font-size:11px;line-height:1.35}.shell-flags{display:flex;gap:16px;align-items:center}.shell-actions-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.shell-available{display:grid;gap:12px}.shell-available-toolbar{display:grid;gap:12px;grid-template-columns:minmax(200px,1fr)}.shell-available-filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.shell-available-list{display:grid;gap:10px}.shell-available-item{display:grid;gap:8px;grid-template-columns:minmax(0,1fr) auto;border-bottom:1px solid var(--md-sys-color-outline-variant);padding-block:12px;align-items:start;min-width:0}.shell-available-main{min-width:0;overflow-wrap:anywhere}.shell-available-title{display:flex;align-items:center;gap:10px}.shell-available-text{display:grid;gap:4px}.shell-available-label{font-weight:600}.shell-available-meta{display:flex;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--md-sys-color-on-surface-variant)}.shell-available-desc{font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-available-payload{font-size:11px;color:var(--md-sys-color-on-surface-variant)}.shell-action-availability{font-size:12px;line-height:1.5;padding:8px 10px;border-inline-start:2px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container);color:var(--md-sys-color-on-surface-variant)}.shell-available-warning{font-size:11px;color:var(--md-sys-color-error)}.shell-available-actions{display:flex;align-items:flex-start;justify-content:flex-end}.shell-badge{padding:2px 6px;border-radius:999px;border:1px solid var(--md-sys-color-outline-variant);font-size:10px}.shell-code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.shell-actions{display:grid;gap:12px}.shell-action{border-top:1px solid var(--md-sys-color-outline-variant);padding-block:16px;display:grid;gap:8px}.shell-action-row{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}.shell-action-actions{display:flex;justify-content:flex-end}.shell-action-hint,.shell-empty{font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-hint{margin:0;line-height:1.5;align-self:center;font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-quick-inputs{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));align-items:end}.shell-input-path{font-size:12px;color:var(--md-sys-color-on-surface-variant);align-self:center}.shell-editor .mat-mdc-form-field{width:100%;min-width:0}:host{display:block;min-width:0}.shell-editor>*,.shell-action>*{min-width:0}.shell-available-text,.shell-input-path{min-width:0;overflow-wrap:anywhere}.shell-preview-region{min-width:0;background:var(--md-sys-color-surface)}.shell-preview-content{display:grid;gap:12px;padding-top:12px}.shell-preview-heading{cursor:pointer;padding-block:8px}.shell-preview-heading:before{content:\"\\25b8\"}.shell-preview-region[open]>.shell-preview-heading:before{content:\"\\25be\"}.shell-preview-heading:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.shell-preview-heading,.shell-override-summary{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}.shell-preview-heading strong{margin-right:auto;font-size:14px}.shell-preview-heading>span,.shell-override-summary{font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-preview{padding:16px;background:var(--md-sys-color-surface-container-low);min-width:0}.shell-sample-content{display:grid;gap:8px}.shell-sample-content>span{font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-sample-content strong{font-size:32px;line-height:1.15;font-variant-numeric:tabular-nums}.shell-mini-title,.shell-mini-action,.shell-mini-value,.shell-mini-line{display:block;background:var(--md-sys-color-on-surface-variant);opacity:.45;border-radius:2px}.shell-mini-title{width:24px;height:3px}.shell-mini-action{width:4px;height:4px;margin-left:auto}.shell-mini-value{width:20px;height:8px;margin:8px 8px 4px;opacity:.7}.shell-mini-line{width:32px;height:3px;margin-inline:8px}@media(max-width:640px){.shell-editor{padding-inline:0}.shell-grid,.shell-action-row,.shell-quick-inputs,.shell-available-toolbar{grid-template-columns:minmax(0,1fr)}.shell-actions-head,.shell-flags{flex-wrap:wrap}.shell-field--wide{grid-column:auto}}\n"], dependencies: [{ kind: "component", type: WidgetShellComponent, selector: "praxis-widget-shell", inputs: ["shell", "context", "dragSurfaceEnabled", "dragSurfaceLabel"], outputs: ["action", "dragSurfacePointerDown", "dragSurfaceKeydown"] }, { kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { 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.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.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { 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: MatCheckboxModule }, { kind: "component", type: i4$1.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { 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.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i4.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { 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: i9.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: i9.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: SettingsSectionTabsComponent, selector: "praxis-settings-section-tabs", inputs: ["sections", "selectedId", "label", "invalidLabel", "panelId"], outputs: ["selectedIdChange"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4861
|
+
`, isInline: true, styles: [":host{display:block;container:shell-editor / inline-size}.shell-effective-style{display:grid;gap:4px;margin:0;font-size:12px}.shell-effective-style span{color:var(--md-sys-color-on-surface-variant)}.shell-topic-panel,.shell-topic{display:grid;gap:var(--pfx-spacing-4, 16px)}.shell-topic-panel{padding-block:var(--pfx-spacing-4, 16px);min-width:0}.shell-topic-panel[hidden]{display:none}.shell-topic-panel>.shell-grid{margin-block-end:var(--pfx-spacing-4, 16px)}.shell-link-summary{display:grid;gap:var(--pfx-spacing-1, 4px);padding-block:var(--pfx-spacing-3, 12px);overflow-wrap:anywhere}.shell-button-task{display:grid;gap:var(--pfx-spacing-4, 16px);min-width:0}.shell-button-count{margin:0;font-size:14px}.shell-action-technical,.shell-action-scopes{min-width:0;font-size:12px}.shell-action-technical summary,.shell-action-scopes summary{cursor:pointer;padding-block:8px}.shell-action-technical summary:focus-visible,.shell-action-scopes summary:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.shell-action-contract{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px}.shell-action-contract dd{margin:0;overflow-wrap:anywhere}.shell-action-technical .shell-action-row{margin-top:12px}.shell-editor{display:grid;grid-template-columns:minmax(0,1fr);min-width:0;gap:20px;padding:12px 4px 20px}.shell-controls{display:grid;gap:var(--pfx-spacing-4, 16px);min-width:0;container:shell-controls / inline-size}.shell-preview-region{min-width:0}@container shell-controls (max-width: 480px){.shell-controls .shell-grid,.shell-controls .shell-action-row,.shell-controls .shell-quick-inputs,.shell-controls .shell-available-item{grid-template-columns:minmax(0,1fr)}.shell-controls .shell-available-item>button{justify-self:start}}.shell-intro{margin:0;max-width:68ch;color:var(--md-sys-color-on-surface-variant);font-size:14px;line-height:1.5}.shell-details{border-top:1px solid var(--md-sys-color-outline-variant);padding-top:var(--pfx-spacing-4, 16px)}.shell-details summary{cursor:pointer;color:var(--md-sys-color-on-surface);font-weight:600;line-height:1.5}.shell-details summary:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:4px}.shell-option-content{display:grid;gap:var(--pfx-spacing-4, 16px);margin-top:var(--pfx-spacing-4, 16px);min-width:0}.shell-details>.shell-grid{margin-top:var(--pfx-spacing-4, 16px)}.shell-details>.shell-section{margin:var(--pfx-spacing-4, 16px) 0 var(--pfx-spacing-2, 8px)}.shell-validation{color:var(--md-sys-color-error);margin:0;line-height:1.5}.shell-section{margin:4px 0 -8px;color:var(--md-sys-color-on-surface);font-size:14px;font-weight:650;line-height:1.4}.shell-grid{display:grid;gap:12px 16px;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start}.shell-field--wide{grid-column:1 / -1}.shell-preset-preview__body{display:grid;align-content:start;height:100%;color:var(--md-sys-color-on-surface-variant)}.shell-preset-preview__body .shell-mini-value,.shell-preset-preview__body .shell-mini-line{background:currentColor}.shell-preset-copy strong{display:flex;align-items:baseline;justify-content:space-between;gap:var(--pfx-spacing-2, 8px)}.shell-preset-check{color:var(--md-sys-color-primary);flex-shrink:0}.shell-preset-field,.shell-preset-copy{display:grid}.shell-preset-field{gap:8px}.shell-preset-label{color:var(--md-sys-color-on-surface);font-size:13px;font-weight:600}.shell-preset-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.shell-preset-option{min-width:0;display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px;padding:10px;border:1px solid var(--md-sys-color-outline-variant);border-radius:12px;background:var(--md-sys-color-surface);color:var(--md-sys-color-on-surface);text-align:left;cursor:pointer}.shell-preset-option:hover{background:var(--md-sys-color-surface-container-low)}.shell-preset-option:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.shell-preset-option--selected{border-color:var(--md-sys-color-primary);box-shadow:0 0 0 1px var(--md-sys-color-primary)}.shell-preset-preview{box-sizing:border-box;display:block;height:64px;overflow:hidden;border:1px solid var(--md-sys-color-outline-variant);background:var(--shell-mini-bg, var(--md-sys-color-surface));-webkit-backdrop-filter:var(--shell-mini-filter, none);backdrop-filter:var(--shell-mini-filter, none)}@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))){.shell-preset-preview--backdrop{background:var(--shell-mini-fallback, var(--md-sys-color-surface))}}@media(prefers-reduced-transparency:reduce){.shell-preset-preview--backdrop{background:var(--shell-mini-fallback, var(--md-sys-color-surface));-webkit-backdrop-filter:none;backdrop-filter:none}}.shell-preset-preview__header{display:flex;align-items:center;gap:4px;padding-inline:8px;height:22px;border-bottom:1px solid transparent}.shell-preset-preview--none{border-style:dashed;border-radius:8px;background:transparent;box-shadow:none}.shell-preset-copy{gap:3px}.shell-preset-copy small{color:var(--md-sys-color-on-surface-variant);font-size:11px;line-height:1.35}.shell-flags{display:flex;gap:16px;align-items:center}.shell-actions-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.shell-available{display:grid;gap:12px}.shell-available-toolbar{display:grid;gap:12px;grid-template-columns:minmax(200px,1fr)}.shell-available-filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.shell-available-list{display:grid;gap:10px}.shell-available-item{display:grid;gap:8px;grid-template-columns:minmax(0,1fr) auto;border-bottom:1px solid var(--md-sys-color-outline-variant);padding-block:12px;align-items:start;min-width:0}.shell-available-main{min-width:0;overflow-wrap:anywhere}.shell-available-title{display:flex;align-items:center;gap:10px}.shell-available-text{display:grid;gap:4px}.shell-available-label{font-weight:600}.shell-available-meta{display:flex;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--md-sys-color-on-surface-variant)}.shell-available-desc{font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-available-payload{font-size:11px;color:var(--md-sys-color-on-surface-variant)}.shell-action-availability{font-size:12px;line-height:1.5;padding:8px 10px;border-inline-start:2px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container);color:var(--md-sys-color-on-surface-variant)}.shell-available-warning{font-size:11px;color:var(--md-sys-color-error)}.shell-available-actions{display:flex;align-items:flex-start;justify-content:flex-end}.shell-badge{padding:2px 6px;border-radius:999px;border:1px solid var(--md-sys-color-outline-variant);font-size:10px}.shell-code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.shell-actions{display:grid;gap:12px}.shell-action{border-top:1px solid var(--md-sys-color-outline-variant);padding-block:16px;display:grid;gap:8px}.shell-action-row{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}.shell-action-actions{display:flex;justify-content:flex-end}.shell-action-hint,.shell-empty{font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-hint{margin:0;line-height:1.5;align-self:center;font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-quick-inputs{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));align-items:end}.shell-input-path{font-size:12px;color:var(--md-sys-color-on-surface-variant);align-self:center}.shell-editor .mat-mdc-form-field{width:100%;min-width:0}:host{display:block;min-width:0}.shell-editor>*,.shell-action>*{min-width:0}.shell-available-text,.shell-input-path{min-width:0;overflow-wrap:anywhere}.shell-preview-region{min-width:0;background:var(--md-sys-color-surface)}.shell-preview-content{display:grid;gap:12px;padding-top:12px}.shell-preview-heading{cursor:pointer;padding-block:8px}.shell-preview-heading:before{content:\"\\25b8\"}.shell-preview-region[open]>.shell-preview-heading:before{content:\"\\25be\"}.shell-preview-heading:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.shell-preview-heading,.shell-override-summary{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}.shell-preview-heading strong{margin-right:auto;font-size:14px}.shell-preview-heading>span,.shell-override-summary{font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-preview{padding:16px;background:var(--md-sys-color-surface-container-low);min-width:0}.shell-sample-content{display:grid;gap:8px}.shell-sample-content>span{font-size:12px;color:var(--md-sys-color-on-surface-variant)}.shell-sample-content strong{font-size:32px;line-height:1.15;font-variant-numeric:tabular-nums}.shell-mini-title,.shell-mini-action,.shell-mini-value,.shell-mini-line{display:block;background:var(--md-sys-color-on-surface-variant);opacity:.45;border-radius:2px}.shell-mini-title{width:24px;height:3px}.shell-mini-action{width:4px;height:4px;margin-left:auto}.shell-mini-value{width:20px;height:8px;margin:8px 8px 4px;opacity:.7}.shell-mini-line{width:32px;height:3px;margin-inline:8px}@media(max-width:640px){.shell-editor{padding-inline:0}.shell-grid,.shell-action-row,.shell-quick-inputs,.shell-available-toolbar{grid-template-columns:minmax(0,1fr)}.shell-actions-head,.shell-flags{flex-wrap:wrap}.shell-field--wide{grid-column:auto}}\n"], dependencies: [{ kind: "component", type: WidgetShellComponent, selector: "praxis-widget-shell", inputs: ["shell", "context", "dragSurfaceEnabled", "dragSurfaceLabel"], outputs: ["action", "dragSurfacePointerDown", "dragSurfaceKeydown"] }, { kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { 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.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.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { 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: MatCheckboxModule }, { kind: "component", type: i4.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i6.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i6.MatLabel, selector: "mat-label" }, { kind: "directive", type: i6.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: i6.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i6.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { 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: i8.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: i3$1.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: i3$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: SettingsSectionTabsComponent, selector: "praxis-settings-section-tabs", inputs: ["sections", "selectedId", "label", "invalidLabel", "panelId"], outputs: ["selectedIdChange"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4818
4862
|
}
|
|
4819
4863
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: WidgetShellEditorComponent, decorators: [{
|
|
4820
4864
|
type: Component,
|
|
@@ -5760,59 +5804,243 @@ class CanvasConstraintEditorModel {
|
|
|
5760
5804
|
return resolveFriendlyWidgetTitle(widget, key);
|
|
5761
5805
|
}
|
|
5762
5806
|
}
|
|
5807
|
+
const NON_TITLE_LABEL_NODE_TYPES = new Set([
|
|
5808
|
+
'badge',
|
|
5809
|
+
'chip',
|
|
5810
|
+
'tag',
|
|
5811
|
+
'button',
|
|
5812
|
+
'icon',
|
|
5813
|
+
'divider',
|
|
5814
|
+
'spacer',
|
|
5815
|
+
]);
|
|
5816
|
+
const DATA_COLLECTION_NODE_TYPES = new Set([
|
|
5817
|
+
'timeline',
|
|
5818
|
+
'key-value',
|
|
5819
|
+
'list',
|
|
5820
|
+
]);
|
|
5821
|
+
const RICH_NODE_TYPE_LABELS = {
|
|
5822
|
+
header: 'Header',
|
|
5823
|
+
heading: 'Heading',
|
|
5824
|
+
card: 'Card',
|
|
5825
|
+
metric: 'Metric',
|
|
5826
|
+
stat: 'Stat',
|
|
5827
|
+
kpi: 'KPI',
|
|
5828
|
+
callout: 'Callout',
|
|
5829
|
+
alert: 'Alert',
|
|
5830
|
+
timeline: 'Timeline',
|
|
5831
|
+
'key-value': 'Key-value',
|
|
5832
|
+
progress: 'Progress',
|
|
5833
|
+
badge: 'Badge',
|
|
5834
|
+
text: 'Text',
|
|
5835
|
+
paragraph: 'Text',
|
|
5836
|
+
list: 'List',
|
|
5837
|
+
};
|
|
5838
|
+
function extractRawTextCandidate(candidate) {
|
|
5839
|
+
if (typeof candidate === 'string') {
|
|
5840
|
+
return candidate.trim();
|
|
5841
|
+
}
|
|
5842
|
+
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
|
5843
|
+
const rec = candidate;
|
|
5844
|
+
for (const field of ['text', 'defaultValue', 'label', 'key']) {
|
|
5845
|
+
if (typeof rec[field] === 'string' && rec[field].trim()) {
|
|
5846
|
+
return rec[field].trim();
|
|
5847
|
+
}
|
|
5848
|
+
}
|
|
5849
|
+
}
|
|
5850
|
+
return '';
|
|
5851
|
+
}
|
|
5852
|
+
function isGenericPlaceholderTitle(value) {
|
|
5853
|
+
const normalized = value.trim().toLowerCase();
|
|
5854
|
+
return (!normalized ||
|
|
5855
|
+
normalized === 'rich content' ||
|
|
5856
|
+
normalized === 'conteúdo rico' ||
|
|
5857
|
+
normalized === 'conteudo rico' ||
|
|
5858
|
+
normalized === 'widget');
|
|
5859
|
+
}
|
|
5860
|
+
function cleanStaticText(candidate) {
|
|
5861
|
+
const trimmed = extractRawTextCandidate(candidate);
|
|
5862
|
+
return trimmed && !trimmed.includes('${') && !trimmed.includes('{{') ? trimmed : '';
|
|
5863
|
+
}
|
|
5864
|
+
function cleanHybridTemplateText(candidate) {
|
|
5865
|
+
const raw = extractRawTextCandidate(candidate);
|
|
5866
|
+
if (!raw || (!raw.includes('${') && !raw.includes('{{'))) {
|
|
5867
|
+
return '';
|
|
5868
|
+
}
|
|
5869
|
+
const withoutTrailingTokenParens = raw
|
|
5870
|
+
.replace(/\s*\([^)]*(?:\$\{[^}]+\}|\{\{[^}]+\}\})[^)]*\)\s*$/g, '')
|
|
5871
|
+
.trim();
|
|
5872
|
+
if (withoutTrailingTokenParens &&
|
|
5873
|
+
!withoutTrailingTokenParens.includes('${') &&
|
|
5874
|
+
!withoutTrailingTokenParens.includes('{{') &&
|
|
5875
|
+
(withoutTrailingTokenParens.match(/[A-Za-zÀ-ÿ]/g)?.length ?? 0) >= 3) {
|
|
5876
|
+
return withoutTrailingTokenParens;
|
|
5877
|
+
}
|
|
5878
|
+
const stripped = raw
|
|
5879
|
+
.replace(/\$\{[^}]+\}/g, ' ')
|
|
5880
|
+
.replace(/\{\{[^}]+\}\}/g, ' ')
|
|
5881
|
+
.replace(/\(\s*\)/g, ' ')
|
|
5882
|
+
.replace(/\s+/g, ' ')
|
|
5883
|
+
.replace(/^[\s·—\-:|/]+|[\s·—\-:|/]+$/g, '')
|
|
5884
|
+
.trim();
|
|
5885
|
+
if (/^(?:R\$\s*[A-Za-z]?|[%$€£]+)$/i.test(stripped)) {
|
|
5886
|
+
return '';
|
|
5887
|
+
}
|
|
5888
|
+
const letterCount = stripped.match(/[A-Za-zÀ-ÿ]/g)?.length ?? 0;
|
|
5889
|
+
return letterCount >= 4 ? stripped : '';
|
|
5890
|
+
}
|
|
5891
|
+
function parseRichContentNodes(inputs) {
|
|
5892
|
+
if (!inputs)
|
|
5893
|
+
return null;
|
|
5894
|
+
let doc = inputs['document'];
|
|
5895
|
+
if (typeof doc === 'string' && doc.trim().startsWith('{')) {
|
|
5896
|
+
try {
|
|
5897
|
+
doc = JSON.parse(doc);
|
|
5898
|
+
}
|
|
5899
|
+
catch {
|
|
5900
|
+
doc = null;
|
|
5901
|
+
}
|
|
5902
|
+
}
|
|
5903
|
+
if (doc && typeof doc === 'object' && Array.isArray(doc['nodes'])) {
|
|
5904
|
+
return doc['nodes'];
|
|
5905
|
+
}
|
|
5906
|
+
return null;
|
|
5907
|
+
}
|
|
5908
|
+
function summarizeRichContentNodes(nodes) {
|
|
5909
|
+
const seen = new Set();
|
|
5910
|
+
const walk = (list) => {
|
|
5911
|
+
for (const item of list) {
|
|
5912
|
+
if (!item || typeof item !== 'object' || seen.size >= 3)
|
|
5913
|
+
continue;
|
|
5914
|
+
const rec = item;
|
|
5915
|
+
const rawType = typeof rec['type'] === 'string' ? rec['type'].trim().toLowerCase() : '';
|
|
5916
|
+
const mapped = RICH_NODE_TYPE_LABELS[rawType];
|
|
5917
|
+
if (mapped && !seen.has(mapped)) {
|
|
5918
|
+
seen.add(mapped);
|
|
5919
|
+
}
|
|
5920
|
+
const childKeys = DATA_COLLECTION_NODE_TYPES.has(rawType)
|
|
5921
|
+
? ['header', 'content', 'children', 'nodes']
|
|
5922
|
+
: ['header', 'content', 'items', 'children', 'nodes'];
|
|
5923
|
+
for (const childKey of childKeys) {
|
|
5924
|
+
if (Array.isArray(rec[childKey]) && seen.size < 3) {
|
|
5925
|
+
walk(rec[childKey]);
|
|
5926
|
+
}
|
|
5927
|
+
}
|
|
5928
|
+
}
|
|
5929
|
+
};
|
|
5930
|
+
walk(nodes);
|
|
5931
|
+
return [...seen].join(', ');
|
|
5932
|
+
}
|
|
5933
|
+
function extractResourcePathFromInputs(inputs) {
|
|
5934
|
+
if (!inputs)
|
|
5935
|
+
return '';
|
|
5936
|
+
const direct = cleanStaticText(inputs['resourcePath']);
|
|
5937
|
+
if (direct)
|
|
5938
|
+
return direct;
|
|
5939
|
+
const bindings = inputs['bindings'];
|
|
5940
|
+
if (bindings && typeof bindings === 'object') {
|
|
5941
|
+
const boundPath = cleanStaticText(bindings['resourcePath']);
|
|
5942
|
+
if (boundPath)
|
|
5943
|
+
return boundPath;
|
|
5944
|
+
}
|
|
5945
|
+
const config = inputs['config'];
|
|
5946
|
+
if (config && typeof config === 'object') {
|
|
5947
|
+
const ds = config['dataSource'];
|
|
5948
|
+
if (ds && typeof ds === 'object') {
|
|
5949
|
+
const dsPath = cleanStaticText(ds['resourcePath']);
|
|
5950
|
+
if (dsPath)
|
|
5951
|
+
return dsPath;
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
5954
|
+
return '';
|
|
5955
|
+
}
|
|
5956
|
+
function humanizeResourcePathLabel(resourcePath) {
|
|
5957
|
+
const clean = resourcePath.trim().replace(/^\/+|\/+$/g, '');
|
|
5958
|
+
if (!clean)
|
|
5959
|
+
return '';
|
|
5960
|
+
const segment = clean.split('/').pop() || clean;
|
|
5961
|
+
return segment
|
|
5962
|
+
.split(/[-_]+/)
|
|
5963
|
+
.filter(Boolean)
|
|
5964
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
5965
|
+
.join(' ');
|
|
5966
|
+
}
|
|
5967
|
+
/**
|
|
5968
|
+
* Resolves a human-friendly title for a widget instance following the canonical precedence:
|
|
5969
|
+
* 1. `widget.shell.title` (authored shell title)
|
|
5970
|
+
* 2. `widget.definition.title` / `widget.title`
|
|
5971
|
+
* 3. `widget.shell.subtitle`
|
|
5972
|
+
* 4. `inputs.title` / `inputs.label` / `inputs.config.title` / `inputs.config.toolbar.title` / `inputs.chartDocument.title`
|
|
5973
|
+
* 5. `inputs.document.nodes` (Rich Content AST: static title/headline/semantic label, static subtitle/eyebrow, or hybrid template prefix)
|
|
5974
|
+
* 6. `resourcePath` humanized segment (when `allowResourcePathFallback` is enabled)
|
|
5975
|
+
* 7. Technical `key` fallback
|
|
5976
|
+
*/
|
|
5763
5977
|
function resolveFriendlyWidgetTitle(widget, key, options) {
|
|
5764
5978
|
const includeKeySuffix = options?.includeKeySuffix !== false;
|
|
5765
|
-
const formatWithKey = (label) => (includeKeySuffix ? `${label} (${key})` : label);
|
|
5766
|
-
const
|
|
5767
|
-
if (typeof candidate !== 'string')
|
|
5768
|
-
return '';
|
|
5769
|
-
const trimmed = candidate.trim();
|
|
5770
|
-
return trimmed && !trimmed.includes('${') && !trimmed.includes('{{') ? trimmed : '';
|
|
5771
|
-
};
|
|
5772
|
-
const authoredTitle = cleanStatic(widget.shell?.title);
|
|
5979
|
+
const formatWithKey = (label) => (includeKeySuffix && key ? `${label} (${key})` : label);
|
|
5980
|
+
const authoredTitle = cleanStaticText(widget.shell?.title);
|
|
5773
5981
|
if (authoredTitle) {
|
|
5774
5982
|
return authoredTitle;
|
|
5775
5983
|
}
|
|
5776
|
-
const definitionTitle =
|
|
5984
|
+
const definitionTitle = cleanStaticText(widget.definition?.title) || cleanStaticText(widget.title);
|
|
5777
5985
|
if (definitionTitle) {
|
|
5778
5986
|
return definitionTitle;
|
|
5779
5987
|
}
|
|
5780
|
-
const subtitle =
|
|
5988
|
+
const subtitle = cleanStaticText(widget.shell?.subtitle);
|
|
5781
5989
|
if (subtitle) {
|
|
5782
5990
|
return formatWithKey(subtitle);
|
|
5783
5991
|
}
|
|
5784
|
-
const
|
|
5785
|
-
|
|
5992
|
+
const rawInputs = widget.definition?.inputs ?? widget.inputs;
|
|
5993
|
+
const inputs = rawInputs && typeof rawInputs === 'object'
|
|
5994
|
+
? rawInputs
|
|
5786
5995
|
: null;
|
|
5787
5996
|
if (inputs) {
|
|
5788
|
-
const directInputTitle =
|
|
5789
|
-
if (directInputTitle) {
|
|
5997
|
+
const directInputTitle = cleanStaticText(inputs['title']) || cleanStaticText(inputs['label']);
|
|
5998
|
+
if (directInputTitle && !isGenericPlaceholderTitle(directInputTitle)) {
|
|
5790
5999
|
return formatWithKey(directInputTitle);
|
|
5791
6000
|
}
|
|
5792
6001
|
for (const nestedKey of ['config', 'chartDocument']) {
|
|
5793
6002
|
const nested = inputs[nestedKey];
|
|
5794
6003
|
if (nested && typeof nested === 'object') {
|
|
5795
|
-
const
|
|
5796
|
-
|
|
6004
|
+
const nestedRec = nested;
|
|
6005
|
+
const nestedTitle = cleanStaticText(nestedRec['title']) ||
|
|
6006
|
+
(nestedRec['toolbar'] && typeof nestedRec['toolbar'] === 'object'
|
|
6007
|
+
? cleanStaticText(nestedRec['toolbar']['title'])
|
|
6008
|
+
: '');
|
|
6009
|
+
if (nestedTitle && !isGenericPlaceholderTitle(nestedTitle)) {
|
|
5797
6010
|
return formatWithKey(nestedTitle);
|
|
5798
6011
|
}
|
|
5799
6012
|
}
|
|
5800
6013
|
}
|
|
5801
|
-
const
|
|
5802
|
-
if (
|
|
6014
|
+
const docNodes = parseRichContentNodes(inputs);
|
|
6015
|
+
if (docNodes) {
|
|
5803
6016
|
const findNodeTitle = (nodes) => {
|
|
5804
6017
|
for (const node of nodes) {
|
|
5805
6018
|
if (!node || typeof node !== 'object')
|
|
5806
6019
|
continue;
|
|
5807
6020
|
const rec = node;
|
|
5808
|
-
const
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
(rec['
|
|
5812
|
-
|
|
6021
|
+
const nodeType = typeof rec['type'] === 'string' ? rec['type'].trim().toLowerCase() : '';
|
|
6022
|
+
const allowLabelAsTitle = !NON_TITLE_LABEL_NODE_TYPES.has(nodeType);
|
|
6023
|
+
const nodeTitle = cleanStaticText(rec['title']) ||
|
|
6024
|
+
cleanStaticText(rec['headline']) ||
|
|
6025
|
+
(allowLabelAsTitle ? cleanStaticText(rec['label']) : '') ||
|
|
6026
|
+
(nodeType === 'heading' ? cleanStaticText(rec['text']) : '');
|
|
6027
|
+
if (nodeTitle && !isGenericPlaceholderTitle(nodeTitle)) {
|
|
5813
6028
|
return nodeTitle;
|
|
5814
6029
|
}
|
|
5815
|
-
|
|
6030
|
+
const nodeSubtitle = cleanStaticText(rec['subtitle']) || cleanStaticText(rec['eyebrow']);
|
|
6031
|
+
if (nodeSubtitle && !isGenericPlaceholderTitle(nodeSubtitle)) {
|
|
6032
|
+
return nodeSubtitle;
|
|
6033
|
+
}
|
|
6034
|
+
const hybridTitle = cleanHybridTemplateText(rec['title']) ||
|
|
6035
|
+
cleanHybridTemplateText(rec['headline']) ||
|
|
6036
|
+
(allowLabelAsTitle ? cleanHybridTemplateText(rec['label']) : '');
|
|
6037
|
+
if (hybridTitle && !isGenericPlaceholderTitle(hybridTitle)) {
|
|
6038
|
+
return hybridTitle;
|
|
6039
|
+
}
|
|
6040
|
+
const childKeys = DATA_COLLECTION_NODE_TYPES.has(nodeType)
|
|
6041
|
+
? ['content', 'children', 'nodes', 'header']
|
|
6042
|
+
: ['content', 'items', 'children', 'nodes', 'header'];
|
|
6043
|
+
for (const childListKey of childKeys) {
|
|
5816
6044
|
if (Array.isArray(rec[childListKey])) {
|
|
5817
6045
|
const nested = findNodeTitle(rec[childListKey]);
|
|
5818
6046
|
if (nested)
|
|
@@ -5822,14 +6050,283 @@ function resolveFriendlyWidgetTitle(widget, key, options) {
|
|
|
5822
6050
|
}
|
|
5823
6051
|
return '';
|
|
5824
6052
|
};
|
|
5825
|
-
const docTitle = findNodeTitle(
|
|
6053
|
+
const docTitle = findNodeTitle(docNodes);
|
|
5826
6054
|
if (docTitle) {
|
|
5827
6055
|
return formatWithKey(docTitle);
|
|
5828
6056
|
}
|
|
5829
6057
|
}
|
|
6058
|
+
if (options?.allowResourcePathFallback) {
|
|
6059
|
+
const resourcePath = extractResourcePathFromInputs(inputs);
|
|
6060
|
+
const humanized = humanizeResourcePathLabel(resourcePath);
|
|
6061
|
+
if (humanized) {
|
|
6062
|
+
return formatWithKey(humanized);
|
|
6063
|
+
}
|
|
6064
|
+
}
|
|
6065
|
+
if (directInputTitle) {
|
|
6066
|
+
return formatWithKey(directInputTitle);
|
|
6067
|
+
}
|
|
5830
6068
|
}
|
|
5831
6069
|
return key;
|
|
5832
6070
|
}
|
|
6071
|
+
/**
|
|
6072
|
+
* Resolves a friendly label for a widget's component type, prioritizing registry metadata when available.
|
|
6073
|
+
*/
|
|
6074
|
+
function resolveWidgetComponentTypeLabel(componentId, metadataFriendlyName, translateTypeLabel) {
|
|
6075
|
+
if (metadataFriendlyName && metadataFriendlyName.trim()) {
|
|
6076
|
+
return metadataFriendlyName.trim();
|
|
6077
|
+
}
|
|
6078
|
+
const tx = (key, fallback) => translateTypeLabel ? translateTypeLabel(key, fallback) : fallback;
|
|
6079
|
+
const normalized = (componentId || '').toLowerCase();
|
|
6080
|
+
if (normalized.includes('rich-content') || normalized.includes('rich') || normalized.includes('document')) {
|
|
6081
|
+
return tx('richContent', 'Conteúdo Rico');
|
|
6082
|
+
}
|
|
6083
|
+
if (normalized.includes('chart'))
|
|
6084
|
+
return tx('chart', 'Gráfico');
|
|
6085
|
+
if (normalized.includes('table'))
|
|
6086
|
+
return tx('table', 'Tabela');
|
|
6087
|
+
if (normalized.includes('list'))
|
|
6088
|
+
return tx('list', 'Lista');
|
|
6089
|
+
if (normalized.includes('form'))
|
|
6090
|
+
return tx('form', 'Formulário');
|
|
6091
|
+
if (normalized.includes('stat'))
|
|
6092
|
+
return tx('stat', 'Indicador');
|
|
6093
|
+
if (normalized.includes('tabs'))
|
|
6094
|
+
return tx('tabs', 'Abas');
|
|
6095
|
+
return componentId || tx('widget', 'Widget');
|
|
6096
|
+
}
|
|
6097
|
+
/**
|
|
6098
|
+
* Resolves the representative Material icon for a widget instance, inspecting shell icon,
|
|
6099
|
+
* rich-content AST icon/timeline nodes, list leading icon templates, registry metadata, and component family.
|
|
6100
|
+
*/
|
|
6101
|
+
function resolveWidgetEditorIcon(widget, metadataIcon) {
|
|
6102
|
+
const shellIcon = cleanStaticText(widget.shell?.icon);
|
|
6103
|
+
if (shellIcon)
|
|
6104
|
+
return shellIcon;
|
|
6105
|
+
const rawInputs = widget.definition?.inputs ?? widget.inputs;
|
|
6106
|
+
const inputs = rawInputs && typeof rawInputs === 'object'
|
|
6107
|
+
? rawInputs
|
|
6108
|
+
: null;
|
|
6109
|
+
if (inputs) {
|
|
6110
|
+
const docNodes = parseRichContentNodes(inputs);
|
|
6111
|
+
if (docNodes) {
|
|
6112
|
+
const findNodeIcon = (nodes) => {
|
|
6113
|
+
for (const node of nodes) {
|
|
6114
|
+
if (!node || typeof node !== 'object')
|
|
6115
|
+
continue;
|
|
6116
|
+
const rec = node;
|
|
6117
|
+
if (rec['type'] === 'icon') {
|
|
6118
|
+
const directIcon = cleanStaticText(rec['icon']) || cleanStaticText(rec['name']);
|
|
6119
|
+
if (directIcon)
|
|
6120
|
+
return directIcon;
|
|
6121
|
+
}
|
|
6122
|
+
const propIcon = cleanStaticText(rec['icon']);
|
|
6123
|
+
if (propIcon)
|
|
6124
|
+
return propIcon;
|
|
6125
|
+
if (rec['type'] === 'timeline')
|
|
6126
|
+
return 'timeline';
|
|
6127
|
+
for (const childKey of ['header', 'items', 'content', 'children', 'nodes']) {
|
|
6128
|
+
if (Array.isArray(rec[childKey])) {
|
|
6129
|
+
const nestedIcon = findNodeIcon(rec[childKey]);
|
|
6130
|
+
if (nestedIcon)
|
|
6131
|
+
return nestedIcon;
|
|
6132
|
+
}
|
|
6133
|
+
}
|
|
6134
|
+
}
|
|
6135
|
+
return '';
|
|
6136
|
+
};
|
|
6137
|
+
const docIcon = findNodeIcon(docNodes);
|
|
6138
|
+
if (docIcon)
|
|
6139
|
+
return docIcon;
|
|
6140
|
+
}
|
|
6141
|
+
const config = inputs['config'];
|
|
6142
|
+
if (config && typeof config === 'object') {
|
|
6143
|
+
const templating = config['templating'];
|
|
6144
|
+
if (templating && typeof templating === 'object') {
|
|
6145
|
+
const leading = templating['leading'];
|
|
6146
|
+
if (leading && typeof leading === 'object' && leading['type'] === 'icon') {
|
|
6147
|
+
const leadingIcon = cleanStaticText(leading['expr']);
|
|
6148
|
+
if (leadingIcon)
|
|
6149
|
+
return leadingIcon;
|
|
6150
|
+
}
|
|
6151
|
+
}
|
|
6152
|
+
}
|
|
6153
|
+
}
|
|
6154
|
+
if (metadataIcon && metadataIcon.trim()) {
|
|
6155
|
+
return metadataIcon.trim();
|
|
6156
|
+
}
|
|
6157
|
+
const componentType = String(widget.definition?.id || widget.componentId || '').toLowerCase();
|
|
6158
|
+
if (componentType.includes('table'))
|
|
6159
|
+
return 'table_chart';
|
|
6160
|
+
if (componentType.includes('chart'))
|
|
6161
|
+
return 'monitoring';
|
|
6162
|
+
if (componentType.includes('list'))
|
|
6163
|
+
return 'format_list_bulleted';
|
|
6164
|
+
if (componentType.includes('form'))
|
|
6165
|
+
return 'dynamic_form';
|
|
6166
|
+
if (componentType.includes('tabs'))
|
|
6167
|
+
return 'dashboard_customize';
|
|
6168
|
+
if (componentType.includes('stat'))
|
|
6169
|
+
return 'insights';
|
|
6170
|
+
if (componentType.includes('related-resource-outlet'))
|
|
6171
|
+
return 'hub';
|
|
6172
|
+
if (componentType.includes('rich') || componentType.includes('text') || componentType.includes('document')) {
|
|
6173
|
+
return 'article';
|
|
6174
|
+
}
|
|
6175
|
+
if (componentType.includes('source'))
|
|
6176
|
+
return 'input';
|
|
6177
|
+
if (componentType.includes('sink'))
|
|
6178
|
+
return 'output';
|
|
6179
|
+
return 'widgets';
|
|
6180
|
+
}
|
|
6181
|
+
function resolveWidgetEditorDescription(widget, resolvedTitle, typeLabel, metadataDescription, translateTypeLabel) {
|
|
6182
|
+
const candidates = [];
|
|
6183
|
+
const normalizedTitle = resolvedTitle.toLowerCase();
|
|
6184
|
+
const pushCandidate = (raw) => {
|
|
6185
|
+
const clean = raw.trim();
|
|
6186
|
+
if (!clean)
|
|
6187
|
+
return;
|
|
6188
|
+
if (clean.toLowerCase() === normalizedTitle ||
|
|
6189
|
+
normalizedTitle.startsWith(clean.toLowerCase() + ' (') ||
|
|
6190
|
+
normalizedTitle.startsWith(clean.toLowerCase() + ' #')) {
|
|
6191
|
+
return;
|
|
6192
|
+
}
|
|
6193
|
+
if (!candidates.includes(clean)) {
|
|
6194
|
+
candidates.push(clean);
|
|
6195
|
+
}
|
|
6196
|
+
};
|
|
6197
|
+
pushCandidate(cleanStaticText(widget.shell?.subtitle));
|
|
6198
|
+
const rawInputs = widget.definition?.inputs ?? widget.inputs;
|
|
6199
|
+
const inputs = rawInputs && typeof rawInputs === 'object'
|
|
6200
|
+
? rawInputs
|
|
6201
|
+
: null;
|
|
6202
|
+
if (inputs) {
|
|
6203
|
+
pushCandidate(cleanStaticText(inputs['subtitle']));
|
|
6204
|
+
pushCandidate(cleanStaticText(inputs['description']));
|
|
6205
|
+
for (const nestedKey of ['config', 'chartDocument']) {
|
|
6206
|
+
const nested = inputs[nestedKey];
|
|
6207
|
+
if (nested && typeof nested === 'object') {
|
|
6208
|
+
const nestedRec = nested;
|
|
6209
|
+
pushCandidate(cleanStaticText(nestedRec['subtitle']));
|
|
6210
|
+
pushCandidate(cleanStaticText(nestedRec['description']));
|
|
6211
|
+
}
|
|
6212
|
+
}
|
|
6213
|
+
const docNodes = parseRichContentNodes(inputs);
|
|
6214
|
+
if (docNodes) {
|
|
6215
|
+
const collectDocDescriptions = (nodes) => {
|
|
6216
|
+
for (const node of nodes) {
|
|
6217
|
+
if (!node || typeof node !== 'object')
|
|
6218
|
+
continue;
|
|
6219
|
+
const rec = node;
|
|
6220
|
+
const nodeType = typeof rec['type'] === 'string' ? rec['type'].trim().toLowerCase() : '';
|
|
6221
|
+
pushCandidate(cleanStaticText(rec['subtitle']));
|
|
6222
|
+
pushCandidate(cleanStaticText(rec['description']));
|
|
6223
|
+
pushCandidate(cleanHybridTemplateText(rec['title']));
|
|
6224
|
+
if (nodeType === 'text' || nodeType === 'paragraph') {
|
|
6225
|
+
pushCandidate(cleanStaticText(rec['text']) || cleanHybridTemplateText(rec['text']));
|
|
6226
|
+
}
|
|
6227
|
+
const childKeys = DATA_COLLECTION_NODE_TYPES.has(nodeType)
|
|
6228
|
+
? ['content', 'children', 'nodes']
|
|
6229
|
+
: ['content', 'items', 'children', 'nodes'];
|
|
6230
|
+
for (const childKey of childKeys) {
|
|
6231
|
+
if (Array.isArray(rec[childKey])) {
|
|
6232
|
+
collectDocDescriptions(rec[childKey]);
|
|
6233
|
+
}
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
};
|
|
6237
|
+
collectDocDescriptions(docNodes);
|
|
6238
|
+
if (candidates.length === 0) {
|
|
6239
|
+
const structuralSummary = summarizeRichContentNodes(docNodes);
|
|
6240
|
+
if (structuralSummary) {
|
|
6241
|
+
pushCandidate(structuralSummary);
|
|
6242
|
+
}
|
|
6243
|
+
}
|
|
6244
|
+
}
|
|
6245
|
+
const resourcePath = extractResourcePathFromInputs(inputs);
|
|
6246
|
+
if (resourcePath) {
|
|
6247
|
+
pushCandidate(resourcePath);
|
|
6248
|
+
}
|
|
6249
|
+
if (candidates.length === 0) {
|
|
6250
|
+
const chartDoc = inputs['chartDocument'];
|
|
6251
|
+
const config = inputs['config'];
|
|
6252
|
+
const chartType = cleanStaticText(chartDoc?.['chartType']) ||
|
|
6253
|
+
cleanStaticText(config?.['chartType']) ||
|
|
6254
|
+
cleanStaticText(config?.['type']);
|
|
6255
|
+
if (chartType) {
|
|
6256
|
+
pushCandidate(chartType.toUpperCase());
|
|
6257
|
+
}
|
|
6258
|
+
const formMode = cleanStaticText(inputs['mode']) || cleanStaticText(config?.['mode']);
|
|
6259
|
+
if (formMode) {
|
|
6260
|
+
const modePrefix = translateTypeLabel
|
|
6261
|
+
? translateTypeLabel('formModePrefix', 'Modo')
|
|
6262
|
+
: 'Modo';
|
|
6263
|
+
pushCandidate(`${modePrefix}: ${formMode}`);
|
|
6264
|
+
}
|
|
6265
|
+
}
|
|
6266
|
+
}
|
|
6267
|
+
if (candidates.length === 0 && metadataDescription) {
|
|
6268
|
+
pushCandidate(metadataDescription);
|
|
6269
|
+
}
|
|
6270
|
+
const primaryDesc = candidates[0] || '';
|
|
6271
|
+
if (!primaryDesc) {
|
|
6272
|
+
return typeLabel;
|
|
6273
|
+
}
|
|
6274
|
+
const truncated = primaryDesc.length > 72 ? `${primaryDesc.slice(0, 69).trimEnd()}…` : primaryDesc;
|
|
6275
|
+
return truncated.toLowerCase().includes(typeLabel.toLowerCase())
|
|
6276
|
+
? truncated
|
|
6277
|
+
: `${truncated} · ${typeLabel}`;
|
|
6278
|
+
}
|
|
6279
|
+
/**
|
|
6280
|
+
* Resolves the complete editor presentation (`title`, `description`, `icon`, `typeLabel`)
|
|
6281
|
+
* for a widget instance, automatically disambiguating unnamed or homonymous widgets across the page.
|
|
6282
|
+
*/
|
|
6283
|
+
function resolveWidgetEditorPresentation(widget, allWidgets = [], metadata) {
|
|
6284
|
+
const key = String(widget?.key || '').trim();
|
|
6285
|
+
const componentId = String(widget?.definition?.id || widget?.componentId || '').trim();
|
|
6286
|
+
const typeLabel = resolveWidgetComponentTypeLabel(componentId, metadata?.friendlyName, metadata?.translateTypeLabel);
|
|
6287
|
+
const resolveRawSemantic = (candidate) => resolveFriendlyWidgetTitle(candidate, '', {
|
|
6288
|
+
includeKeySuffix: false,
|
|
6289
|
+
allowResourcePathFallback: true,
|
|
6290
|
+
}).trim();
|
|
6291
|
+
const semanticTitle = resolveRawSemantic(widget);
|
|
6292
|
+
const isGenericDefaultTitle = isGenericPlaceholderTitle(semanticTitle) ||
|
|
6293
|
+
semanticTitle === key;
|
|
6294
|
+
let title = semanticTitle;
|
|
6295
|
+
if (isGenericDefaultTitle) {
|
|
6296
|
+
const peers = allWidgets.filter(peer => String(peer?.definition?.id || peer?.componentId || '').trim() === componentId);
|
|
6297
|
+
const peerIndex = peers.findIndex(peer => peer.key === key);
|
|
6298
|
+
title = peers.length > 1 && peerIndex >= 0 ? `${typeLabel} #${peerIndex + 1}` : typeLabel;
|
|
6299
|
+
}
|
|
6300
|
+
else if (allWidgets.length > 1) {
|
|
6301
|
+
const normalizedSemantic = semanticTitle.toLowerCase();
|
|
6302
|
+
const collidingPeers = allWidgets.filter(peer => {
|
|
6303
|
+
const peerTitle = resolveRawSemantic(peer);
|
|
6304
|
+
return !isGenericPlaceholderTitle(peerTitle) && peerTitle.toLowerCase() === normalizedSemantic;
|
|
6305
|
+
});
|
|
6306
|
+
if (collidingPeers.length > 1) {
|
|
6307
|
+
const sameTypePeers = collidingPeers.filter(peer => String(peer?.definition?.id || peer?.componentId || '').trim() === componentId);
|
|
6308
|
+
if (sameTypePeers.length > 1) {
|
|
6309
|
+
const sameTypeIdx = sameTypePeers.findIndex(peer => peer.key === key);
|
|
6310
|
+
if (sameTypeIdx >= 0) {
|
|
6311
|
+
title = `${semanticTitle} #${sameTypeIdx + 1}`;
|
|
6312
|
+
}
|
|
6313
|
+
}
|
|
6314
|
+
else {
|
|
6315
|
+
title = `${semanticTitle} (${typeLabel})`;
|
|
6316
|
+
}
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
6319
|
+
const icon = resolveWidgetEditorIcon(widget, metadata?.icon);
|
|
6320
|
+
const description = resolveWidgetEditorDescription(widget, title, typeLabel, metadata?.description, metadata?.translateTypeLabel);
|
|
6321
|
+
return {
|
|
6322
|
+
key,
|
|
6323
|
+
title,
|
|
6324
|
+
description,
|
|
6325
|
+
icon,
|
|
6326
|
+
typeLabel,
|
|
6327
|
+
componentId,
|
|
6328
|
+
};
|
|
6329
|
+
}
|
|
5833
6330
|
|
|
5834
6331
|
const nestedWidgetAccessor = new NestedWidgetConfigAccessor();
|
|
5835
6332
|
const CONNECTION_EDITOR_STATE_NODE_ID = '__page_state__';
|
|
@@ -5895,14 +6392,35 @@ function deriveConnectionEditorGraph(page, options = {}) {
|
|
|
5895
6392
|
inputPorts.push(created);
|
|
5896
6393
|
}
|
|
5897
6394
|
}
|
|
6395
|
+
const resolveDefaultNodeTitle = () => {
|
|
6396
|
+
const explicitShellTitle = editableText(widget.shell?.title);
|
|
6397
|
+
if (explicitShellTitle && !isGenericPlaceholderTitle(explicitShellTitle))
|
|
6398
|
+
return explicitShellTitle;
|
|
6399
|
+
const rawSemantic = resolveFriendlyWidgetTitle(widget, '', { includeKeySuffix: false }).trim();
|
|
6400
|
+
const semanticTitle = isGenericPlaceholderTitle(rawSemantic) ? '' : rawSemantic;
|
|
6401
|
+
if (!semanticTitle)
|
|
6402
|
+
return widgetId;
|
|
6403
|
+
const collidingPeers = widgets.filter((peer) => {
|
|
6404
|
+
const peerExplicit = editableText(peer.shell?.title).trim();
|
|
6405
|
+
const peerSemantic = (peerExplicit && !isGenericPlaceholderTitle(peerExplicit) ? peerExplicit : '') ||
|
|
6406
|
+
resolveFriendlyWidgetTitle(peer, '', { includeKeySuffix: false }).trim();
|
|
6407
|
+
return !isGenericPlaceholderTitle(peerSemantic) && peerSemantic.toLowerCase() === semanticTitle.toLowerCase();
|
|
6408
|
+
});
|
|
6409
|
+
if (collidingPeers.length <= 1)
|
|
6410
|
+
return semanticTitle;
|
|
6411
|
+
const sameTypePeers = collidingPeers.filter((peer) => (peer.definition?.id ?? 'unknown') === componentType);
|
|
6412
|
+
if (sameTypePeers.length > 1) {
|
|
6413
|
+
const peerIdx = sameTypePeers.findIndex((peer) => peer.key === widgetId);
|
|
6414
|
+
return peerIdx >= 0 ? `${semanticTitle} #${peerIdx + 1}` : semanticTitle;
|
|
6415
|
+
}
|
|
6416
|
+
const typeLabel = resolveWidgetComponentTypeLabel(componentType, metadata?.friendlyName);
|
|
6417
|
+
return `${semanticTitle} (${typeLabel})`;
|
|
6418
|
+
};
|
|
5898
6419
|
return {
|
|
5899
6420
|
id: widgetId,
|
|
5900
|
-
title: options.widgetTitle?.(widget)
|
|
5901
|
-
|| editableText(widget.shell?.title)
|
|
5902
|
-
|| resolveFriendlyWidgetTitle(widget, widgetId, { includeKeySuffix: false })
|
|
5903
|
-
|| widgetId,
|
|
6421
|
+
title: options.widgetTitle?.(widget) || resolveDefaultNodeTitle(),
|
|
5904
6422
|
subtitle: componentType,
|
|
5905
|
-
icon: widget
|
|
6423
|
+
icon: resolveWidgetEditorIcon(widget, metadata?.icon),
|
|
5906
6424
|
x: center.x + Math.cos(angle) * radiusX,
|
|
5907
6425
|
y: center.y + Math.sin(angle) * radiusY,
|
|
5908
6426
|
outputs: outputPorts,
|
|
@@ -6224,26 +6742,6 @@ function inputPortPriority(port) {
|
|
|
6224
6742
|
return 8;
|
|
6225
6743
|
return 40;
|
|
6226
6744
|
}
|
|
6227
|
-
function iconForComponentType(componentType) {
|
|
6228
|
-
const normalized = (componentType ?? '').toLowerCase();
|
|
6229
|
-
if (normalized.includes('table'))
|
|
6230
|
-
return 'table_chart';
|
|
6231
|
-
if (normalized.includes('chart'))
|
|
6232
|
-
return 'monitoring';
|
|
6233
|
-
if (normalized.includes('form'))
|
|
6234
|
-
return 'dynamic_form';
|
|
6235
|
-
if (normalized.includes('tabs'))
|
|
6236
|
-
return 'dashboard_customize';
|
|
6237
|
-
if (normalized.includes('related-resource-outlet'))
|
|
6238
|
-
return 'hub';
|
|
6239
|
-
if (normalized.includes('rich') || normalized.includes('text') || normalized.includes('document'))
|
|
6240
|
-
return 'article';
|
|
6241
|
-
if (normalized.includes('source'))
|
|
6242
|
-
return 'input';
|
|
6243
|
-
if (normalized.includes('sink'))
|
|
6244
|
-
return 'output';
|
|
6245
|
-
return 'widgets';
|
|
6246
|
-
}
|
|
6247
6745
|
function configuredWidgetEventOutputs(widget) {
|
|
6248
6746
|
const componentType = String(widget.definition?.id ?? '').toLowerCase();
|
|
6249
6747
|
if (!componentType.includes('chart'))
|
|
@@ -7852,15 +8350,34 @@ class ConnectionEditorComponent {
|
|
|
7852
8350
|
globalActionIds: this.availableGlobalActionIds(),
|
|
7853
8351
|
widgetTitle: widget => {
|
|
7854
8352
|
const title = this.i18n.resolve(widget.shell?.title, '').trim();
|
|
7855
|
-
if (title && !title.includes('${') && !title.includes('{{'))
|
|
8353
|
+
if (title && !title.includes('${') && !title.includes('{{') && !isGenericPlaceholderTitle(title))
|
|
7856
8354
|
return title;
|
|
7857
|
-
const
|
|
7858
|
-
|
|
7859
|
-
return semanticTitle;
|
|
8355
|
+
const rawSemantic = resolveFriendlyWidgetTitle(widget, '', { includeKeySuffix: false }).trim();
|
|
8356
|
+
const semanticTitle = isGenericPlaceholderTitle(rawSemantic) ? '' : rawSemantic;
|
|
7860
8357
|
const metadata = this.componentRegistry.get(widget.definition.id);
|
|
8358
|
+
const allWidgets = this.workingPage()?.widgets ?? [];
|
|
8359
|
+
if (semanticTitle) {
|
|
8360
|
+
const collidingPeers = allWidgets.filter(peer => {
|
|
8361
|
+
const peerShell = this.i18n.resolve(peer.shell?.title, '').trim();
|
|
8362
|
+
const validPeerShell = peerShell && !peerShell.includes('${') && !peerShell.includes('{{') && !isGenericPlaceholderTitle(peerShell)
|
|
8363
|
+
? peerShell
|
|
8364
|
+
: '';
|
|
8365
|
+
const peerSemantic = validPeerShell || resolveFriendlyWidgetTitle(peer, '', { includeKeySuffix: false }).trim();
|
|
8366
|
+
return !isGenericPlaceholderTitle(peerSemantic) && peerSemantic.toLowerCase() === semanticTitle.toLowerCase();
|
|
8367
|
+
});
|
|
8368
|
+
if (collidingPeers.length <= 1)
|
|
8369
|
+
return semanticTitle;
|
|
8370
|
+
const sameTypePeers = collidingPeers.filter(peer => peer.definition.id === widget.definition.id);
|
|
8371
|
+
if (sameTypePeers.length > 1) {
|
|
8372
|
+
const idx = sameTypePeers.findIndex(peer => peer.key === widget.key);
|
|
8373
|
+
return idx >= 0 ? `${semanticTitle} #${idx + 1}` : semanticTitle;
|
|
8374
|
+
}
|
|
8375
|
+
const typeLabel = resolveWidgetComponentTypeLabel(widget.definition.id, metadata?.friendlyName, (typeKey, fallback) => this.tx(`widgetTypes.${typeKey}`, fallback));
|
|
8376
|
+
return `${semanticTitle} (${typeLabel})`;
|
|
8377
|
+
}
|
|
7861
8378
|
if (!metadata)
|
|
7862
8379
|
return widget.key;
|
|
7863
|
-
const peers =
|
|
8380
|
+
const peers = allWidgets.filter(peer => peer.definition.id === widget.definition.id);
|
|
7864
8381
|
return peers.length > 1 ? `${metadata.friendlyName} ${peers.findIndex(peer => peer.key === widget.key) + 1}` : metadata.friendlyName;
|
|
7865
8382
|
},
|
|
7866
8383
|
}), ...(ngDevMode ? [{ debugName: "editorGraph" }] : /* istanbul ignore next */ []));
|
|
@@ -14501,7 +15018,10 @@ class DynamicPageConfigEditorComponent {
|
|
|
14501
15018
|
limitsErrorMatcher = { isErrorState: (control) => !!control?.invalid };
|
|
14502
15019
|
injectedData = inject(SETTINGS_PANEL_DATA$1, { optional: true });
|
|
14503
15020
|
isHostedInSettingsPanel = this.injectedData != null;
|
|
15021
|
+
destroyRef = inject(DestroyRef);
|
|
14504
15022
|
i18n = inject(PraxisI18nService);
|
|
15023
|
+
componentRegistry = inject(ComponentMetadataRegistry, { optional: true });
|
|
15024
|
+
resourceDiscovery = inject(ResourceDiscoveryService, { optional: true });
|
|
14505
15025
|
dataSourceRuntime = inject(WidgetPageDataSourceRuntimeService);
|
|
14506
15026
|
stateRuntime = inject(WidgetPageStateRuntimeService);
|
|
14507
15027
|
pageInput = null;
|
|
@@ -14524,6 +15044,11 @@ class DynamicPageConfigEditorComponent {
|
|
|
14524
15044
|
composerCollectionKindControl = new FormControl('table', { nonNullable: true });
|
|
14525
15045
|
composerDetailSurfaceControl = new FormControl('drawer', { nonNullable: true });
|
|
14526
15046
|
composerMessage = signal('', ...(ngDevMode ? [{ debugName: "composerMessage" }] : /* istanbul ignore next */ []));
|
|
15047
|
+
composerSelectedResource = signal(null, ...(ngDevMode ? [{ debugName: "composerSelectedResource" }] : /* istanbul ignore next */ []));
|
|
15048
|
+
composerSchemaFields = signal([], ...(ngDevMode ? [{ debugName: "composerSchemaFields" }] : /* istanbul ignore next */ []));
|
|
15049
|
+
composerSchemaPath = '';
|
|
15050
|
+
composerSchemaSub;
|
|
15051
|
+
lastAutoComposerTitle = '';
|
|
14527
15052
|
binderTokenControl = new FormControl('', { nonNullable: true });
|
|
14528
15053
|
binderWidgetControl = new FormControl('', { nonNullable: true });
|
|
14529
15054
|
binderInputPathControl = new FormControl('value', { nonNullable: true });
|
|
@@ -14544,6 +15069,9 @@ class DynamicPageConfigEditorComponent {
|
|
|
14544
15069
|
this.arrangementFeedback.nativeElement.scrollIntoView({ block: 'nearest' });
|
|
14545
15070
|
}
|
|
14546
15071
|
}, read: () => this.refreshRenderedValidation() });
|
|
15072
|
+
this.composerResourcePathControl.valueChanges.pipe(takeUntilDestroyed()).subscribe((path) => {
|
|
15073
|
+
this.loadComposerSchemaFields(path);
|
|
15074
|
+
});
|
|
14547
15075
|
this.dataSourceNodes.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => {
|
|
14548
15076
|
if (!this.hydrating && !this.syncingDataSources)
|
|
14549
15077
|
this.syncDataSourcesJsonFromNodes();
|
|
@@ -14556,6 +15084,7 @@ class DynamicPageConfigEditorComponent {
|
|
|
14556
15084
|
this.refreshPanelState(); });
|
|
14557
15085
|
}
|
|
14558
15086
|
ngOnInit() { this.hydrate(); }
|
|
15087
|
+
ngOnDestroy() { this.composerSchemaSub?.unsubscribe(); }
|
|
14559
15088
|
get selectedTabIndex() { return this.tabOrder.indexOf(this.activeTab()); }
|
|
14560
15089
|
get isConfigurationValid() { return this.isValid$.value; }
|
|
14561
15090
|
get hasPendingChanges() { return this.isDirty$.value; }
|
|
@@ -14728,6 +15257,54 @@ class DynamicPageConfigEditorComponent {
|
|
|
14728
15257
|
}
|
|
14729
15258
|
addGroupingNode(seed) { this.groupingNodes.push(this.createGroupingNodeGroup(seed)); this.refreshPanelState(); }
|
|
14730
15259
|
removeGroupingNode(index) { this.groupingNodes.removeAt(index); this.refreshPanelState(); }
|
|
15260
|
+
groupingSelectedKeysCache = new WeakMap();
|
|
15261
|
+
groupingSelectedWidgetKeys(index) {
|
|
15262
|
+
const group = this.groupingNodes.at(index);
|
|
15263
|
+
if (!group)
|
|
15264
|
+
return [];
|
|
15265
|
+
const control = group.controls.widgetKeysCsv;
|
|
15266
|
+
const raw = control.value;
|
|
15267
|
+
const cached = this.groupingSelectedKeysCache.get(control);
|
|
15268
|
+
if (cached && cached.raw === raw) {
|
|
15269
|
+
return cached.keys;
|
|
15270
|
+
}
|
|
15271
|
+
const keys = raw
|
|
15272
|
+
.split(',')
|
|
15273
|
+
.map((item) => item.trim())
|
|
15274
|
+
.filter(Boolean);
|
|
15275
|
+
this.groupingSelectedKeysCache.set(control, { raw, keys });
|
|
15276
|
+
return keys;
|
|
15277
|
+
}
|
|
15278
|
+
groupingSelectedWidgetsLabel(index) {
|
|
15279
|
+
const selectedKeys = this.groupingSelectedWidgetKeys(index);
|
|
15280
|
+
if (!selectedKeys.length)
|
|
15281
|
+
return '';
|
|
15282
|
+
const widgets = this.readEffectiveWidgets();
|
|
15283
|
+
return selectedKeys
|
|
15284
|
+
.map((key) => {
|
|
15285
|
+
const match = widgets.find((w) => w.key === key);
|
|
15286
|
+
return match ? this.resolveWidgetTitle(match, widgets) : key;
|
|
15287
|
+
})
|
|
15288
|
+
.join(', ');
|
|
15289
|
+
}
|
|
15290
|
+
groupingSelectedWidgetIcon(index) {
|
|
15291
|
+
const selectedKeys = this.groupingSelectedWidgetKeys(index);
|
|
15292
|
+
if (!selectedKeys.length)
|
|
15293
|
+
return 'widgets';
|
|
15294
|
+
if (selectedKeys.length === 1) {
|
|
15295
|
+
return this.widgetIconForKey(selectedKeys[0]);
|
|
15296
|
+
}
|
|
15297
|
+
return 'layers';
|
|
15298
|
+
}
|
|
15299
|
+
onGroupingWidgetSelectionChange(index, selectedKeys) {
|
|
15300
|
+
const group = this.groupingNodes.at(index);
|
|
15301
|
+
if (!group)
|
|
15302
|
+
return;
|
|
15303
|
+
const knownKeys = new Set(this.readEffectiveWidgets().map((w) => w.key));
|
|
15304
|
+
const preservedUnknown = this.groupingSelectedWidgetKeys(index).filter((key) => !knownKeys.has(key));
|
|
15305
|
+
const merged = [...new Set([...selectedKeys.map((k) => k.trim()).filter(Boolean), ...preservedUnknown])];
|
|
15306
|
+
group.controls.widgetKeysCsv.setValue(merged.join(', '));
|
|
15307
|
+
}
|
|
14731
15308
|
addDataSourceNode(seed) {
|
|
14732
15309
|
this.dataSourceNodes.push(this.createDataSourceNodeGroup(seed), { emitEvent: false });
|
|
14733
15310
|
this.syncDataSourcesJsonFromNodes();
|
|
@@ -14759,7 +15336,14 @@ class DynamicPageConfigEditorComponent {
|
|
|
14759
15336
|
...(operation !== 'COUNT' ? { field: metricField } : {}),
|
|
14760
15337
|
alias: metricField,
|
|
14761
15338
|
},
|
|
14762
|
-
metrics: [
|
|
15339
|
+
metrics: [
|
|
15340
|
+
{
|
|
15341
|
+
...(operation !== 'COUNT' ? { field: metricField } : {}),
|
|
15342
|
+
op: metricOp,
|
|
15343
|
+
operation,
|
|
15344
|
+
alias: metricField,
|
|
15345
|
+
},
|
|
15346
|
+
],
|
|
14763
15347
|
};
|
|
14764
15348
|
if (!group.controls.kind.value)
|
|
14765
15349
|
group.controls.kind.setValue('stats-query', { emitEvent: false });
|
|
@@ -14783,7 +15367,14 @@ class DynamicPageConfigEditorComponent {
|
|
|
14783
15367
|
...(operation !== 'COUNT' ? { field: metricField } : {}),
|
|
14784
15368
|
alias: metricField,
|
|
14785
15369
|
},
|
|
14786
|
-
metrics: [
|
|
15370
|
+
metrics: [
|
|
15371
|
+
{
|
|
15372
|
+
...(operation !== 'COUNT' ? { field: metricField } : {}),
|
|
15373
|
+
op: metricOp,
|
|
15374
|
+
operation,
|
|
15375
|
+
alias: metricField,
|
|
15376
|
+
},
|
|
15377
|
+
],
|
|
14787
15378
|
};
|
|
14788
15379
|
if (!group.controls.kind.value)
|
|
14789
15380
|
group.controls.kind.setValue('stats-query', { emitEvent: false });
|
|
@@ -14898,7 +15489,7 @@ class DynamicPageConfigEditorComponent {
|
|
|
14898
15489
|
targetStatePath,
|
|
14899
15490
|
transform,
|
|
14900
15491
|
});
|
|
14901
|
-
if (
|
|
15492
|
+
if (previewResult.ok === false) {
|
|
14902
15493
|
return {
|
|
14903
15494
|
status: 'error',
|
|
14904
15495
|
sourceKind: 'sample',
|
|
@@ -14914,6 +15505,20 @@ class DynamicPageConfigEditorComponent {
|
|
|
14914
15505
|
tokens: this.collectStateTokensForValue(targetStatePath, previewResult.value),
|
|
14915
15506
|
};
|
|
14916
15507
|
}
|
|
15508
|
+
resolveEndpointKey(rawKey) {
|
|
15509
|
+
const trimmed = String(rawKey ?? '').trim();
|
|
15510
|
+
if (!trimmed) {
|
|
15511
|
+
return undefined;
|
|
15512
|
+
}
|
|
15513
|
+
if (trimmed === 'statsGroupBy' ||
|
|
15514
|
+
trimmed === 'statsTimeseries' ||
|
|
15515
|
+
trimmed === 'statsDistribution' ||
|
|
15516
|
+
trimmed === 'statsComparison' ||
|
|
15517
|
+
trimmed === 'filter') {
|
|
15518
|
+
return undefined;
|
|
15519
|
+
}
|
|
15520
|
+
return trimmed;
|
|
15521
|
+
}
|
|
14917
15522
|
addGuidedDerivedMeasure(config) {
|
|
14918
15523
|
const targetPath = (config?.targetPath ?? this.derivedPathControl.value).trim();
|
|
14919
15524
|
if (!targetPath)
|
|
@@ -15066,12 +15671,204 @@ class DynamicPageConfigEditorComponent {
|
|
|
15066
15671
|
return [...tokens];
|
|
15067
15672
|
}
|
|
15068
15673
|
bindablePageWidgets() {
|
|
15069
|
-
const widgets = this.
|
|
15070
|
-
return widgets.map((widget) => (
|
|
15071
|
-
|
|
15072
|
-
|
|
15073
|
-
|
|
15074
|
-
|
|
15674
|
+
const widgets = this.readEffectiveWidgets();
|
|
15675
|
+
return widgets.map((widget) => this.resolveWidgetPresentation(widget, widgets));
|
|
15676
|
+
}
|
|
15677
|
+
selectedBinderWidget() {
|
|
15678
|
+
const selectedKey = this.binderWidgetControl.value;
|
|
15679
|
+
if (!selectedKey)
|
|
15680
|
+
return null;
|
|
15681
|
+
const widgets = this.readEffectiveWidgets();
|
|
15682
|
+
const target = widgets.find((w) => w.key === selectedKey);
|
|
15683
|
+
return target ? this.resolveWidgetPresentation(target, widgets) : null;
|
|
15684
|
+
}
|
|
15685
|
+
selectedBinderWidgetInputSuggestions() {
|
|
15686
|
+
const selectedKey = this.binderWidgetControl.value;
|
|
15687
|
+
if (!selectedKey)
|
|
15688
|
+
return [];
|
|
15689
|
+
const widgets = this.readEffectiveWidgets();
|
|
15690
|
+
const target = widgets.find((w) => w.key === selectedKey);
|
|
15691
|
+
if (!target)
|
|
15692
|
+
return [];
|
|
15693
|
+
const suggestions = new Set();
|
|
15694
|
+
const inputs = this.isPlainObject(target.definition?.inputs)
|
|
15695
|
+
? target.definition.inputs
|
|
15696
|
+
: this.isPlainObject(target.inputs)
|
|
15697
|
+
? target.inputs
|
|
15698
|
+
: {};
|
|
15699
|
+
const placeholders = new Set();
|
|
15700
|
+
this.collectTemplatePlaceholdersRecursive(inputs, '', placeholders);
|
|
15701
|
+
for (const placeholder of placeholders) {
|
|
15702
|
+
const clean = placeholder.replace(/^context\./, '').trim();
|
|
15703
|
+
if (clean) {
|
|
15704
|
+
suggestions.add(`context.${clean}`);
|
|
15705
|
+
}
|
|
15706
|
+
}
|
|
15707
|
+
if (this.isPlainObject(inputs['context'])) {
|
|
15708
|
+
for (const ctxKey of Object.keys(inputs['context'])) {
|
|
15709
|
+
if (ctxKey.trim()) {
|
|
15710
|
+
suggestions.add(`context.${ctxKey.trim()}`);
|
|
15711
|
+
}
|
|
15712
|
+
}
|
|
15713
|
+
}
|
|
15714
|
+
const compId = String(target.definition?.id || target.componentId || '').toLowerCase();
|
|
15715
|
+
if (compId.includes('rich') || compId.includes('document')) {
|
|
15716
|
+
if (suggestions.size === 0) {
|
|
15717
|
+
suggestions.add('context.value');
|
|
15718
|
+
}
|
|
15719
|
+
}
|
|
15720
|
+
else if (compId.includes('stat')) {
|
|
15721
|
+
suggestions.add('value');
|
|
15722
|
+
suggestions.add('subtitle');
|
|
15723
|
+
}
|
|
15724
|
+
else if (compId.includes('table') || compId.includes('list')) {
|
|
15725
|
+
suggestions.add('queryContext');
|
|
15726
|
+
suggestions.add('resourcePath');
|
|
15727
|
+
}
|
|
15728
|
+
else if (compId.includes('chart')) {
|
|
15729
|
+
suggestions.add('chartDocument.title');
|
|
15730
|
+
}
|
|
15731
|
+
else {
|
|
15732
|
+
suggestions.add('value');
|
|
15733
|
+
}
|
|
15734
|
+
return [...suggestions].slice(0, 6);
|
|
15735
|
+
}
|
|
15736
|
+
binderDeclarativePipes = [
|
|
15737
|
+
'| number',
|
|
15738
|
+
'| round:1',
|
|
15739
|
+
'| percent:1',
|
|
15740
|
+
'| currency:"BRL"',
|
|
15741
|
+
'| default:0',
|
|
15742
|
+
'| uppercase',
|
|
15743
|
+
];
|
|
15744
|
+
static ALLOWED_DECLARATIVE_PIPES = new Set([
|
|
15745
|
+
'default',
|
|
15746
|
+
'coalesce',
|
|
15747
|
+
'round',
|
|
15748
|
+
'number',
|
|
15749
|
+
'percent',
|
|
15750
|
+
'currency',
|
|
15751
|
+
'uppercase',
|
|
15752
|
+
'upper',
|
|
15753
|
+
'lowercase',
|
|
15754
|
+
'lower',
|
|
15755
|
+
]);
|
|
15756
|
+
selectBinderInputPathSuggestion(path) {
|
|
15757
|
+
const trimmed = path.trim();
|
|
15758
|
+
if (!trimmed)
|
|
15759
|
+
return;
|
|
15760
|
+
this.binderInputPathControl.setValue(trimmed);
|
|
15761
|
+
}
|
|
15762
|
+
selectBinderTokenSuggestion(token) {
|
|
15763
|
+
const trimmed = token.trim();
|
|
15764
|
+
if (!trimmed)
|
|
15765
|
+
return;
|
|
15766
|
+
this.binderTokenControl.setValue(trimmed);
|
|
15767
|
+
}
|
|
15768
|
+
appendBinderTokenPipe(pipeSegment) {
|
|
15769
|
+
const cleanPipe = String(pipeSegment || '').replace(/^\|+/, '').trim();
|
|
15770
|
+
if (!cleanPipe)
|
|
15771
|
+
return;
|
|
15772
|
+
const rawCurrent = this.binderTokenControl.value.trim();
|
|
15773
|
+
const baseToken = rawCurrent || this.availableStateBindingTokens()[0] || '{{state.kpis.total}}';
|
|
15774
|
+
if (baseToken.startsWith('{{') && baseToken.endsWith('}}')) {
|
|
15775
|
+
const inner = baseToken.slice(2, -2).trim();
|
|
15776
|
+
const existingSegments = inner.split('|').map((s) => s.trim()).filter(Boolean);
|
|
15777
|
+
if (existingSegments[existingSegments.length - 1] === cleanPipe)
|
|
15778
|
+
return;
|
|
15779
|
+
this.binderTokenControl.setValue(`{{${inner} | ${cleanPipe}}}`);
|
|
15780
|
+
return;
|
|
15781
|
+
}
|
|
15782
|
+
if (baseToken.startsWith('${') && baseToken.endsWith('}')) {
|
|
15783
|
+
const inner = baseToken.slice(2, -1).trim();
|
|
15784
|
+
const existingSegments = inner.split('|').map((s) => s.trim()).filter(Boolean);
|
|
15785
|
+
if (existingSegments[existingSegments.length - 1] === cleanPipe)
|
|
15786
|
+
return;
|
|
15787
|
+
this.binderTokenControl.setValue(`\${${inner} | ${cleanPipe}}`);
|
|
15788
|
+
return;
|
|
15789
|
+
}
|
|
15790
|
+
const normalizedInner = baseToken.startsWith('state.') ? baseToken : `state.${baseToken}`;
|
|
15791
|
+
this.binderTokenControl.setValue(`{{${normalizedInner} | ${cleanPipe}}}`);
|
|
15792
|
+
}
|
|
15793
|
+
hasInvalidDeclarativePipe(rawExpression) {
|
|
15794
|
+
const expr = String(rawExpression ?? '').trim();
|
|
15795
|
+
if (!expr || !expr.includes('|'))
|
|
15796
|
+
return false;
|
|
15797
|
+
const isValidPipeSpec = (pipeSpec) => {
|
|
15798
|
+
const trimmed = pipeSpec.trim();
|
|
15799
|
+
if (!trimmed)
|
|
15800
|
+
return false;
|
|
15801
|
+
const colonIdx = trimmed.indexOf(':');
|
|
15802
|
+
const pipeName = (colonIdx >= 0 ? trimmed.slice(0, colonIdx) : trimmed).trim().toLowerCase();
|
|
15803
|
+
const rawArg = colonIdx >= 0 ? trimmed.slice(colonIdx + 1).trim() : '';
|
|
15804
|
+
if (!DynamicPageConfigEditorComponent.ALLOWED_DECLARATIVE_PIPES.has(pipeName)) {
|
|
15805
|
+
return false;
|
|
15806
|
+
}
|
|
15807
|
+
if ((pipeName === 'default' || pipeName === 'coalesce') && colonIdx < 0) {
|
|
15808
|
+
return false;
|
|
15809
|
+
}
|
|
15810
|
+
if ((pipeName === 'round' || pipeName === 'number' || pipeName === 'percent') && rawArg !== '') {
|
|
15811
|
+
const numericArg = Number(rawArg.replace(/^['"]|['"]$/g, ''));
|
|
15812
|
+
if (!Number.isFinite(numericArg)) {
|
|
15813
|
+
return false;
|
|
15814
|
+
}
|
|
15815
|
+
}
|
|
15816
|
+
return true;
|
|
15817
|
+
};
|
|
15818
|
+
const tokenRegex = /(?:\$\{([^}]*)\}|\{\{([^}]*)\}\})/g;
|
|
15819
|
+
let match;
|
|
15820
|
+
while ((match = tokenRegex.exec(expr)) !== null) {
|
|
15821
|
+
const tokenBody = (match[1] ?? match[2] ?? '').trim();
|
|
15822
|
+
if (!tokenBody.includes('|'))
|
|
15823
|
+
continue;
|
|
15824
|
+
const parts = tokenBody.split('|');
|
|
15825
|
+
if (parts.slice(1).some((p) => !isValidPipeSpec(p))) {
|
|
15826
|
+
return true;
|
|
15827
|
+
}
|
|
15828
|
+
}
|
|
15829
|
+
const strippedExpr = expr.replace(/(?:\$\{[^}]*\}|\{\{[^}]*\}\})/g, '__TOKEN__');
|
|
15830
|
+
if (strippedExpr.includes('|')) {
|
|
15831
|
+
const topParts = strippedExpr.split('|');
|
|
15832
|
+
if (topParts.slice(1).some((p) => !isValidPipeSpec(p))) {
|
|
15833
|
+
return true;
|
|
15834
|
+
}
|
|
15835
|
+
}
|
|
15836
|
+
return false;
|
|
15837
|
+
}
|
|
15838
|
+
binderTokenPipeError() {
|
|
15839
|
+
const raw = this.binderTokenControl.value.trim();
|
|
15840
|
+
if (!raw || !this.hasInvalidDeclarativePipe(raw))
|
|
15841
|
+
return null;
|
|
15842
|
+
return this.tx('editor.state.binderPipeInvalid', 'Invalid declarative pipe. Allowed CSP-safe pipes: default, coalesce, round[:digits], number[:digits], percent[:digits], currency[:code], uppercase, lowercase.');
|
|
15843
|
+
}
|
|
15844
|
+
binderTokenLivePreview() {
|
|
15845
|
+
const raw = this.binderTokenControl.value.trim();
|
|
15846
|
+
if (!raw || this.binderTokenPipeError())
|
|
15847
|
+
return null;
|
|
15848
|
+
return this.resolveStateTokenPreview(raw);
|
|
15849
|
+
}
|
|
15850
|
+
invalidWidgetStateBindingPipes() {
|
|
15851
|
+
const out = [];
|
|
15852
|
+
const widgets = this.readEffectiveWidgets();
|
|
15853
|
+
for (const widget of widgets) {
|
|
15854
|
+
const inputs = widget.definition?.inputs || widget.inputs || {};
|
|
15855
|
+
if (!this.isPlainObject(inputs))
|
|
15856
|
+
continue;
|
|
15857
|
+
this.collectInvalidWidgetPipesRecursive(widget.key, this.resolveWidgetTitle(widget, widgets), inputs, '', out);
|
|
15858
|
+
}
|
|
15859
|
+
return out;
|
|
15860
|
+
}
|
|
15861
|
+
loadStateBindingIntoForm(binding) {
|
|
15862
|
+
this.binderWidgetControl.setValue(binding.widgetKey);
|
|
15863
|
+
this.binderInputPathControl.setValue(binding.inputPath);
|
|
15864
|
+
this.binderTokenControl.setValue(binding.token ?? binding.expression ?? '');
|
|
15865
|
+
}
|
|
15866
|
+
widgetIconForKey(widgetKey) {
|
|
15867
|
+
const widgets = this.readEffectiveWidgets();
|
|
15868
|
+
const widget = widgets.find((w) => w.key === widgetKey);
|
|
15869
|
+
if (!widget)
|
|
15870
|
+
return 'widgets';
|
|
15871
|
+
return this.resolveWidgetPresentation(widget, widgets).icon;
|
|
15075
15872
|
}
|
|
15076
15873
|
bindStateTokenToWidget(widgetKey, inputPath, stateToken) {
|
|
15077
15874
|
const targetWidgetKey = (widgetKey ?? this.binderWidgetControl.value).trim();
|
|
@@ -15079,13 +15876,18 @@ class DynamicPageConfigEditorComponent {
|
|
|
15079
15876
|
const rawToken = (stateToken ?? this.binderTokenControl.value).trim();
|
|
15080
15877
|
if (!targetWidgetKey || !rawToken)
|
|
15081
15878
|
return false;
|
|
15082
|
-
|
|
15879
|
+
if (this.hasInvalidDeclarativePipe(rawToken)) {
|
|
15880
|
+
this.binderMessage.set(this.tx('editor.state.binderPipeInvalid', 'Invalid declarative pipe. Allowed CSP-safe pipes: default, coalesce, round[:digits], number[:digits], percent[:digits], currency[:code], uppercase, lowercase.'));
|
|
15881
|
+
return false;
|
|
15882
|
+
}
|
|
15883
|
+
const normalizedToken = rawToken.startsWith('{{') || rawToken.startsWith('${')
|
|
15083
15884
|
? rawToken
|
|
15084
15885
|
: `{{${rawToken.startsWith('state.') ? rawToken : 'state.' + rawToken}}}`;
|
|
15085
15886
|
const widgets = this.clone(this.effectivePageInput().widgets || []);
|
|
15086
15887
|
const target = widgets.find((item) => item.key === targetWidgetKey);
|
|
15087
15888
|
if (!target)
|
|
15088
15889
|
return false;
|
|
15890
|
+
const targetTitle = this.resolveWidgetTitle(target, widgets);
|
|
15089
15891
|
target.definition = target.definition || { id: target.componentId || 'praxis-StatCard' };
|
|
15090
15892
|
const inputs = this.isPlainObject(target.definition.inputs)
|
|
15091
15893
|
? { ...target.definition.inputs }
|
|
@@ -15100,19 +15902,199 @@ class DynamicPageConfigEditorComponent {
|
|
|
15100
15902
|
this.binderInputPathControl.setValue(targetInputPath, { emitEvent: false });
|
|
15101
15903
|
this.binderMessage.set(this.tx('editor.state.boundSuccess', 'Bound {{token}} to {{widget}}.{{input}} in draft.')
|
|
15102
15904
|
.replace('{{token}}', normalizedToken)
|
|
15103
|
-
.replace('{{widget}}',
|
|
15905
|
+
.replace('{{widget}}', targetTitle)
|
|
15104
15906
|
.replace('{{input}}', targetInputPath));
|
|
15105
15907
|
this.refreshPanelState();
|
|
15106
15908
|
return true;
|
|
15107
15909
|
}
|
|
15108
15910
|
currentWidgetStateBindings() {
|
|
15109
15911
|
const bindings = [];
|
|
15110
|
-
|
|
15912
|
+
const widgets = this.readEffectiveWidgets();
|
|
15913
|
+
for (const widget of widgets) {
|
|
15111
15914
|
const inputs = widget.definition?.inputs || widget.inputs || {};
|
|
15112
|
-
this.collectWidgetBindingsRecursive(widget.key, this.resolveWidgetTitle(widget), inputs, '', bindings);
|
|
15915
|
+
this.collectWidgetBindingsRecursive(widget.key, this.resolveWidgetTitle(widget, widgets), inputs, '', bindings);
|
|
15113
15916
|
}
|
|
15114
15917
|
return bindings;
|
|
15115
15918
|
}
|
|
15919
|
+
resolveStateTokenPreview(rawToken) {
|
|
15920
|
+
const inner = String(rawToken || '')
|
|
15921
|
+
.trim()
|
|
15922
|
+
.replace(/^(?:\$\{|\{\{)|(?:\}|\}\})$/g, '')
|
|
15923
|
+
.trim();
|
|
15924
|
+
if (!inner)
|
|
15925
|
+
return null;
|
|
15926
|
+
const segments = inner.split('|').map((s) => s.trim()).filter(Boolean);
|
|
15927
|
+
const cleaned = (segments[0] || '')
|
|
15928
|
+
.replace(/^(?:\$\{|\{\{)|(?:\}|\}\})$/g, '')
|
|
15929
|
+
.trim();
|
|
15930
|
+
if (!cleaned)
|
|
15931
|
+
return null;
|
|
15932
|
+
const normalizedPath = cleaned.replace(/^state\./, '');
|
|
15933
|
+
if (!normalizedPath)
|
|
15934
|
+
return null;
|
|
15935
|
+
const currentState = this.parseRecordJson(this.stateControl.value, {}) || {};
|
|
15936
|
+
const baseValues = this.isPlainObject(currentState['values'])
|
|
15937
|
+
? this.clone(currentState['values'])
|
|
15938
|
+
: this.isPlainObject(currentState['initial'])
|
|
15939
|
+
? this.clone(currentState['initial'])
|
|
15940
|
+
: {};
|
|
15941
|
+
if (this.isPlainObject(currentState['schema'])) {
|
|
15942
|
+
for (const [key, descriptor] of Object.entries(currentState['schema'])) {
|
|
15943
|
+
if (baseValues[key] === undefined && this.isPlainObject(descriptor) && descriptor['initial'] !== undefined) {
|
|
15944
|
+
baseValues[key] = this.clone(descriptor['initial']);
|
|
15945
|
+
}
|
|
15946
|
+
}
|
|
15947
|
+
}
|
|
15948
|
+
for (let i = 0; i < this.dataSourceNodes.length; i += 1) {
|
|
15949
|
+
const group = this.dataSourceNodes.at(i);
|
|
15950
|
+
const targetPath = group.controls.targetStatePath.value.trim() || group.controls.id.value.trim();
|
|
15951
|
+
if (!targetPath)
|
|
15952
|
+
continue;
|
|
15953
|
+
const preview = this.getDataSourcePreview(i);
|
|
15954
|
+
if (preview.status === 'ok' && preview.value !== undefined) {
|
|
15955
|
+
this.assignNestedValue(baseValues, targetPath, preview.value);
|
|
15956
|
+
}
|
|
15957
|
+
}
|
|
15958
|
+
const derivedPreview = this.getDerivedStatePreview();
|
|
15959
|
+
let rawValue;
|
|
15960
|
+
let unpipedFormatted = null;
|
|
15961
|
+
if (normalizedPath.startsWith('derived.')) {
|
|
15962
|
+
const derivedKey = normalizedPath.slice('derived.'.length);
|
|
15963
|
+
const entry = derivedPreview.entries.find((e) => e.key === derivedKey);
|
|
15964
|
+
if (entry) {
|
|
15965
|
+
rawValue = entry.value;
|
|
15966
|
+
unpipedFormatted = entry.formattedValue;
|
|
15967
|
+
}
|
|
15968
|
+
}
|
|
15969
|
+
else {
|
|
15970
|
+
const fromBase = this.readNestedValue(baseValues, normalizedPath);
|
|
15971
|
+
if (fromBase !== undefined && fromBase !== null) {
|
|
15972
|
+
rawValue = fromBase;
|
|
15973
|
+
}
|
|
15974
|
+
else {
|
|
15975
|
+
const leafKey = normalizedPath.split('.').pop() || normalizedPath;
|
|
15976
|
+
const derivedFallback = derivedPreview.entries.find((e) => e.key === normalizedPath)
|
|
15977
|
+
?? derivedPreview.entries.find((e) => e.key === leafKey);
|
|
15978
|
+
if (derivedFallback) {
|
|
15979
|
+
rawValue = derivedFallback.value;
|
|
15980
|
+
unpipedFormatted = derivedFallback.formattedValue;
|
|
15981
|
+
}
|
|
15982
|
+
}
|
|
15983
|
+
}
|
|
15984
|
+
if (segments.length > 1) {
|
|
15985
|
+
let piped = rawValue;
|
|
15986
|
+
for (let i = 1; i < segments.length; i += 1) {
|
|
15987
|
+
piped = applyDeclarativeValuePipe(piped, segments[i]);
|
|
15988
|
+
}
|
|
15989
|
+
if (piped === undefined || piped === null)
|
|
15990
|
+
return null;
|
|
15991
|
+
return typeof piped === 'object' ? JSON.stringify(piped) : String(piped);
|
|
15992
|
+
}
|
|
15993
|
+
if (unpipedFormatted !== null) {
|
|
15994
|
+
return unpipedFormatted;
|
|
15995
|
+
}
|
|
15996
|
+
if (rawValue !== undefined && rawValue !== null) {
|
|
15997
|
+
return typeof rawValue === 'object' ? JSON.stringify(rawValue) : String(rawValue);
|
|
15998
|
+
}
|
|
15999
|
+
return null;
|
|
16000
|
+
}
|
|
16001
|
+
resolveStateTokenMatch(placeholderName, rawTokens = this.availableStateBindingTokens()) {
|
|
16002
|
+
const clean = placeholderName.replace(/^(?:state|context)\./, '').trim();
|
|
16003
|
+
if (!clean)
|
|
16004
|
+
return null;
|
|
16005
|
+
const contextKey = clean.split('.').pop();
|
|
16006
|
+
const tokenPaths = rawTokens
|
|
16007
|
+
.map((token) => token.replace(/^\{\{|\}\}$/g, '').trim())
|
|
16008
|
+
.filter((path) => path.startsWith('state.') && path.length > 'state.'.length);
|
|
16009
|
+
const exactDerived = `state.derived.${clean}`;
|
|
16010
|
+
if (tokenPaths.includes(exactDerived)) {
|
|
16011
|
+
return { token: `{{${exactDerived}}}`, statePath: exactDerived, targetInputPath: `context.${contextKey}` };
|
|
16012
|
+
}
|
|
16013
|
+
const exactDirect = `state.${clean}`;
|
|
16014
|
+
if (tokenPaths.includes(exactDirect)) {
|
|
16015
|
+
return { token: `{{${exactDirect}}}`, statePath: exactDirect, targetInputPath: `context.${contextKey}` };
|
|
16016
|
+
}
|
|
16017
|
+
const suffixMatches = tokenPaths.filter((candidate) => candidate.endsWith(`.${clean}`));
|
|
16018
|
+
if (suffixMatches.length === 1) {
|
|
16019
|
+
return {
|
|
16020
|
+
token: `{{${suffixMatches[0]}}}`,
|
|
16021
|
+
statePath: suffixMatches[0],
|
|
16022
|
+
targetInputPath: `context.${contextKey}`,
|
|
16023
|
+
};
|
|
16024
|
+
}
|
|
16025
|
+
const segments = clean.split('.').filter(Boolean);
|
|
16026
|
+
for (let i = segments.length - 1; i >= 1; i -= 1) {
|
|
16027
|
+
const prefix = segments.slice(0, i).join('.');
|
|
16028
|
+
const prefixDerived = `state.derived.${prefix}`;
|
|
16029
|
+
if (tokenPaths.includes(prefixDerived)) {
|
|
16030
|
+
return { token: `{{${prefixDerived}}}`, statePath: prefixDerived, targetInputPath: `context.${prefix}` };
|
|
16031
|
+
}
|
|
16032
|
+
const prefixDirect = `state.${prefix}`;
|
|
16033
|
+
if (tokenPaths.includes(prefixDirect)) {
|
|
16034
|
+
return { token: `{{${prefixDirect}}}`, statePath: prefixDirect, targetInputPath: `context.${prefix}` };
|
|
16035
|
+
}
|
|
16036
|
+
const prefixSuffixMatches = tokenPaths.filter((candidate) => candidate.endsWith(`.${prefix}`));
|
|
16037
|
+
if (prefixSuffixMatches.length === 1) {
|
|
16038
|
+
return {
|
|
16039
|
+
token: `{{${prefixSuffixMatches[0]}}}`,
|
|
16040
|
+
statePath: prefixSuffixMatches[0],
|
|
16041
|
+
targetInputPath: `context.${prefix}`,
|
|
16042
|
+
};
|
|
16043
|
+
}
|
|
16044
|
+
}
|
|
16045
|
+
return null;
|
|
16046
|
+
}
|
|
16047
|
+
unboundWidgetStatePlaceholders() {
|
|
16048
|
+
const out = [];
|
|
16049
|
+
const seenSuggestedTargets = new Set();
|
|
16050
|
+
const widgets = this.readEffectiveWidgets();
|
|
16051
|
+
const rawTokens = this.availableStateBindingTokens();
|
|
16052
|
+
for (const widget of widgets) {
|
|
16053
|
+
const inputs = this.isPlainObject(widget.definition?.inputs)
|
|
16054
|
+
? widget.definition.inputs
|
|
16055
|
+
: this.isPlainObject(widget.inputs)
|
|
16056
|
+
? widget.inputs
|
|
16057
|
+
: {};
|
|
16058
|
+
if (!Object.keys(inputs).length)
|
|
16059
|
+
continue;
|
|
16060
|
+
const placeholders = new Set();
|
|
16061
|
+
this.collectTemplatePlaceholdersRecursive(inputs, '', placeholders);
|
|
16062
|
+
if (!placeholders.size)
|
|
16063
|
+
continue;
|
|
16064
|
+
const widgetTitle = this.resolveWidgetTitle(widget, widgets);
|
|
16065
|
+
for (const placeholder of placeholders) {
|
|
16066
|
+
const cleanPlaceholder = placeholder.replace(/^context\./, '').trim();
|
|
16067
|
+
const contextKey = cleanPlaceholder.split('.').pop();
|
|
16068
|
+
const matched = this.resolveStateTokenMatch(placeholder, rawTokens);
|
|
16069
|
+
const suggestedInputPath = matched?.targetInputPath ?? `context.${contextKey}`;
|
|
16070
|
+
if (!this.isPlaceholderBoundInContext(inputs, cleanPlaceholder, contextKey)) {
|
|
16071
|
+
const dedupeKey = `${widget.key}::${suggestedInputPath}`;
|
|
16072
|
+
if (!seenSuggestedTargets.has(dedupeKey)) {
|
|
16073
|
+
seenSuggestedTargets.add(dedupeKey);
|
|
16074
|
+
out.push({ widgetKey: widget.key, widgetTitle, placeholder, suggestedInputPath });
|
|
16075
|
+
}
|
|
16076
|
+
}
|
|
16077
|
+
}
|
|
16078
|
+
}
|
|
16079
|
+
return out;
|
|
16080
|
+
}
|
|
16081
|
+
selectUnboundPlaceholder(item) {
|
|
16082
|
+
this.binderWidgetControl.setValue(item.widgetKey);
|
|
16083
|
+
const available = this.availableStateBindingTokens();
|
|
16084
|
+
const matched = this.resolveStateTokenMatch(item.placeholder, available);
|
|
16085
|
+
this.binderInputPathControl.setValue(matched?.targetInputPath ?? item.suggestedInputPath);
|
|
16086
|
+
if (matched) {
|
|
16087
|
+
this.binderTokenControl.setValue(matched.token);
|
|
16088
|
+
return;
|
|
16089
|
+
}
|
|
16090
|
+
const contextKey = item.placeholder.split('.').pop() || item.placeholder;
|
|
16091
|
+
const fallback = available.find((t) => t === `{{state.${item.placeholder}}}` || t === `{{state.derived.${item.placeholder}}}`)
|
|
16092
|
+
?? available.find((t) => t === `{{state.${contextKey}}}` || t === `{{state.derived.${contextKey}}}`)
|
|
16093
|
+
?? available.find((t) => t.endsWith(`.${contextKey}}}`));
|
|
16094
|
+
if (fallback) {
|
|
16095
|
+
this.binderTokenControl.setValue(fallback);
|
|
16096
|
+
}
|
|
16097
|
+
}
|
|
15116
16098
|
unbindStateTokenFromWidget(widgetKey, inputPath) {
|
|
15117
16099
|
const targetWidgetKey = String(widgetKey || '').trim();
|
|
15118
16100
|
const targetInputPath = String(inputPath || '').trim();
|
|
@@ -15122,6 +16104,7 @@ class DynamicPageConfigEditorComponent {
|
|
|
15122
16104
|
const target = widgets.find((item) => item.key === targetWidgetKey);
|
|
15123
16105
|
if (!target)
|
|
15124
16106
|
return false;
|
|
16107
|
+
const targetTitle = this.resolveWidgetTitle(target, widgets);
|
|
15125
16108
|
const inputs = this.isPlainObject(target.definition?.inputs)
|
|
15126
16109
|
? target.definition.inputs
|
|
15127
16110
|
: this.isPlainObject(target.inputs)
|
|
@@ -15134,7 +16117,7 @@ class DynamicPageConfigEditorComponent {
|
|
|
15134
16117
|
}
|
|
15135
16118
|
this.stagedWidgets = widgets;
|
|
15136
16119
|
this.binderMessage.set(this.tx('editor.state.unboundSuccess', 'Removed binding {{widget}}.{{input}} from draft.')
|
|
15137
|
-
.replace('{{widget}}',
|
|
16120
|
+
.replace('{{widget}}', targetTitle)
|
|
15138
16121
|
.replace('{{input}}', targetInputPath));
|
|
15139
16122
|
this.refreshPanelState();
|
|
15140
16123
|
return true;
|
|
@@ -15143,22 +16126,6 @@ class DynamicPageConfigEditorComponent {
|
|
|
15143
16126
|
const rawTokens = this.availableStateBindingTokens();
|
|
15144
16127
|
if (!rawTokens.length)
|
|
15145
16128
|
return 0;
|
|
15146
|
-
const tokenPaths = rawTokens
|
|
15147
|
-
.map((token) => token.replace(/^\{\{|\}\}$/g, '').trim())
|
|
15148
|
-
.filter((path) => path.startsWith('state.') && path.length > 'state.'.length);
|
|
15149
|
-
const resolveMatchingStatePath = (placeholderName) => {
|
|
15150
|
-
const clean = placeholderName.replace(/^state\./, '').trim();
|
|
15151
|
-
if (!clean)
|
|
15152
|
-
return null;
|
|
15153
|
-
const exactDerived = `state.derived.${clean}`;
|
|
15154
|
-
if (tokenPaths.includes(exactDerived))
|
|
15155
|
-
return exactDerived;
|
|
15156
|
-
const exactDirect = `state.${clean}`;
|
|
15157
|
-
if (tokenPaths.includes(exactDirect))
|
|
15158
|
-
return exactDirect;
|
|
15159
|
-
const suffixMatches = tokenPaths.filter((candidate) => candidate.endsWith(`.${clean}`));
|
|
15160
|
-
return suffixMatches.length === 1 ? suffixMatches[0] : null;
|
|
15161
|
-
};
|
|
15162
16129
|
const widgets = this.clone(this.effectivePageInput().widgets || []);
|
|
15163
16130
|
let boundCount = 0;
|
|
15164
16131
|
for (const widget of widgets) {
|
|
@@ -15176,16 +16143,15 @@ class DynamicPageConfigEditorComponent {
|
|
|
15176
16143
|
widget.definition = widget.definition || { id: widget.componentId || 'praxis-rich-content' };
|
|
15177
16144
|
widget.definition.inputs = inputs;
|
|
15178
16145
|
for (const placeholder of placeholders) {
|
|
15179
|
-
const
|
|
15180
|
-
|
|
15181
|
-
|
|
15182
|
-
const contextKey = placeholder.split('.').pop();
|
|
15183
|
-
const targetInputPath = `context.${contextKey}`;
|
|
15184
|
-
const existingVal = this.readNestedValue(inputs, targetInputPath);
|
|
15185
|
-
if (typeof existingVal === 'string' && (existingVal.includes('${state.') || existingVal.includes('{{state.'))) {
|
|
16146
|
+
const cleanPlaceholder = placeholder.replace(/^context\./, '').trim();
|
|
16147
|
+
const contextKey = cleanPlaceholder.split('.').pop();
|
|
16148
|
+
if (this.isPlaceholderBoundInContext(inputs, cleanPlaceholder, contextKey)) {
|
|
15186
16149
|
continue;
|
|
15187
16150
|
}
|
|
15188
|
-
this.
|
|
16151
|
+
const matched = this.resolveStateTokenMatch(placeholder, rawTokens);
|
|
16152
|
+
if (!matched)
|
|
16153
|
+
continue;
|
|
16154
|
+
this.assignNestedValue(inputs, matched.targetInputPath, matched.token);
|
|
15189
16155
|
boundCount += 1;
|
|
15190
16156
|
}
|
|
15191
16157
|
}
|
|
@@ -15200,6 +16166,139 @@ class DynamicPageConfigEditorComponent {
|
|
|
15200
16166
|
}
|
|
15201
16167
|
return boundCount;
|
|
15202
16168
|
}
|
|
16169
|
+
onComposerResourceSelected(entry) {
|
|
16170
|
+
this.composerSelectedResource.set(entry);
|
|
16171
|
+
const label = entry?.label?.trim() || '';
|
|
16172
|
+
const resourcePath = (entry?.path || entry?.value || '').trim();
|
|
16173
|
+
if (label) {
|
|
16174
|
+
const currentTitle = this.composerTitleControl.value.trim();
|
|
16175
|
+
const previousAutoTitle = this.lastAutoComposerTitle.trim();
|
|
16176
|
+
const humanizedFallback = resourcePath ? this.humanizeResourceTitle(resourcePath) : '';
|
|
16177
|
+
if (!currentTitle || currentTitle === previousAutoTitle || currentTitle === humanizedFallback) {
|
|
16178
|
+
this.lastAutoComposerTitle = label;
|
|
16179
|
+
this.composerTitleControl.setValue(label);
|
|
16180
|
+
}
|
|
16181
|
+
}
|
|
16182
|
+
if (entry?.capabilities) {
|
|
16183
|
+
if (entry.capabilities.stats === false) {
|
|
16184
|
+
this.composerIncludeIndicatorsControl.setValue(false);
|
|
16185
|
+
}
|
|
16186
|
+
else if (entry.capabilities.stats === true && this.composerIncludeIndicatorsControl.pristine) {
|
|
16187
|
+
this.composerIncludeIndicatorsControl.setValue(true);
|
|
16188
|
+
}
|
|
16189
|
+
}
|
|
16190
|
+
const normalizedPath = resourcePath.replace(/^\/+|\/+$/g, '');
|
|
16191
|
+
const fallbackFields = (entry?.responseFields || []).map((f) => ({
|
|
16192
|
+
name: f.name,
|
|
16193
|
+
label: f.label || f.name,
|
|
16194
|
+
type: f.type || 'string',
|
|
16195
|
+
}));
|
|
16196
|
+
if (normalizedPath !== this.composerSchemaPath || fallbackFields.length > 0 || !entry) {
|
|
16197
|
+
this.composerSchemaPath = '';
|
|
16198
|
+
this.composerSchemaFields.set(fallbackFields);
|
|
16199
|
+
this.syncDefaultComposerGroupField(fallbackFields);
|
|
16200
|
+
}
|
|
16201
|
+
if (resourcePath) {
|
|
16202
|
+
this.loadComposerSchemaFields(resourcePath);
|
|
16203
|
+
}
|
|
16204
|
+
}
|
|
16205
|
+
syncDefaultComposerGroupField(fields) {
|
|
16206
|
+
if (!fields.length || this.composerGroupFieldControl.dirty)
|
|
16207
|
+
return;
|
|
16208
|
+
const current = (this.composerGroupFieldControl.value || '').trim();
|
|
16209
|
+
if (current && fields.some((f) => f.name === current))
|
|
16210
|
+
return;
|
|
16211
|
+
const isBoolean = (f) => this.getComposerFieldTypeBadge(f).toLowerCase() === 'boolean';
|
|
16212
|
+
const isStatusSemantic = (f) => /^(ativo|status|situacao|enabled|active)/i.test(String(f.name || ''));
|
|
16213
|
+
const candidate = fields.find((f) => isBoolean(f) && isStatusSemantic(f))
|
|
16214
|
+
?? fields.find((f) => isBoolean(f))
|
|
16215
|
+
?? fields.find((f) => /^(ativo|status|situacao|enabled|active|tipo|categoria|estado)/i.test(String(f.name || '')))
|
|
16216
|
+
?? fields[0];
|
|
16217
|
+
if (candidate?.name) {
|
|
16218
|
+
this.composerGroupFieldControl.setValue(candidate.name, { emitEvent: false });
|
|
16219
|
+
}
|
|
16220
|
+
}
|
|
16221
|
+
filteredComposerGroupFieldOptions() {
|
|
16222
|
+
const fields = this.composerSchemaFields();
|
|
16223
|
+
if (!fields.length)
|
|
16224
|
+
return [];
|
|
16225
|
+
const query = (this.composerGroupFieldControl.value || '').trim().toLowerCase();
|
|
16226
|
+
if (!query || !this.composerGroupFieldControl.dirty) {
|
|
16227
|
+
return fields;
|
|
16228
|
+
}
|
|
16229
|
+
if (fields.some((field) => String(field.name || '').toLowerCase() === query)) {
|
|
16230
|
+
return [...fields].sort((a, b) => String(a.name || '').toLowerCase() === query
|
|
16231
|
+
? -1
|
|
16232
|
+
: String(b.name || '').toLowerCase() === query
|
|
16233
|
+
? 1
|
|
16234
|
+
: 0);
|
|
16235
|
+
}
|
|
16236
|
+
return fields.filter((field) => {
|
|
16237
|
+
const name = String(field.name || '').toLowerCase();
|
|
16238
|
+
const label = String(field.label || '').toLowerCase();
|
|
16239
|
+
const type = this.getComposerFieldTypeBadge(field).toLowerCase();
|
|
16240
|
+
const description = this.getComposerFieldDescription(field).toLowerCase();
|
|
16241
|
+
return (name.includes(query)
|
|
16242
|
+
|| label.includes(query)
|
|
16243
|
+
|| type.includes(query)
|
|
16244
|
+
|| description.includes(query));
|
|
16245
|
+
});
|
|
16246
|
+
}
|
|
16247
|
+
getComposerFieldTypeBadge(field) {
|
|
16248
|
+
const raw = (field?.['dataType']
|
|
16249
|
+
?? field?.type
|
|
16250
|
+
?? field?.controlType
|
|
16251
|
+
?? '');
|
|
16252
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
16253
|
+
}
|
|
16254
|
+
getComposerFieldDescription(field) {
|
|
16255
|
+
const raw = (field?.description
|
|
16256
|
+
?? field?.['hint']
|
|
16257
|
+
?? field?.['helpText']
|
|
16258
|
+
?? '');
|
|
16259
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
16260
|
+
}
|
|
16261
|
+
loadComposerSchemaFields(rawPath) {
|
|
16262
|
+
const path = String(rawPath || '').trim().replace(/^\/+|\/+$/g, '');
|
|
16263
|
+
if (!path
|
|
16264
|
+
|| !this.resourceDiscovery
|
|
16265
|
+
|| (typeof this.resourceDiscovery.canResolveSchemas === 'function' && !this.resourceDiscovery.canResolveSchemas())) {
|
|
16266
|
+
this.composerSchemaSub?.unsubscribe();
|
|
16267
|
+
this.composerSchemaPath = '';
|
|
16268
|
+
if (!path) {
|
|
16269
|
+
this.composerSchemaFields.set([]);
|
|
16270
|
+
}
|
|
16271
|
+
return;
|
|
16272
|
+
}
|
|
16273
|
+
if (path === this.composerSchemaPath)
|
|
16274
|
+
return;
|
|
16275
|
+
const selectedResourcePath = (this.composerSelectedResource()?.path || this.composerSelectedResource()?.value || '')
|
|
16276
|
+
.trim()
|
|
16277
|
+
.replace(/^\/+|\/+$/g, '');
|
|
16278
|
+
if (selectedResourcePath && selectedResourcePath !== path) {
|
|
16279
|
+
this.composerSelectedResource.set(null);
|
|
16280
|
+
this.composerSchemaFields.set([]);
|
|
16281
|
+
}
|
|
16282
|
+
this.composerSchemaPath = path;
|
|
16283
|
+
this.composerSchemaSub?.unsubscribe();
|
|
16284
|
+
this.composerSchemaSub = this.resourceDiscovery
|
|
16285
|
+
.getResourceSchemaFields(path)
|
|
16286
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
16287
|
+
.subscribe({
|
|
16288
|
+
next: (fields) => {
|
|
16289
|
+
const resolved = Array.isArray(fields) ? fields : [];
|
|
16290
|
+
if (resolved.length > 0 || !this.composerSchemaFields().length) {
|
|
16291
|
+
this.composerSchemaFields.set(resolved);
|
|
16292
|
+
this.syncDefaultComposerGroupField(resolved);
|
|
16293
|
+
}
|
|
16294
|
+
},
|
|
16295
|
+
error: () => {
|
|
16296
|
+
if (!this.composerSelectedResource()?.responseFields?.length) {
|
|
16297
|
+
this.composerSchemaFields.set([]);
|
|
16298
|
+
}
|
|
16299
|
+
},
|
|
16300
|
+
});
|
|
16301
|
+
}
|
|
15203
16302
|
composeCollectionScreen(overrides) {
|
|
15204
16303
|
const resourcePath = (overrides?.resourcePath ?? this.composerResourcePathControl.value).trim();
|
|
15205
16304
|
if (!resourcePath)
|
|
@@ -15235,9 +16334,9 @@ class DynamicPageConfigEditorComponent {
|
|
|
15235
16334
|
}
|
|
15236
16335
|
if (includeIndicators) {
|
|
15237
16336
|
const kpiDefs = [
|
|
15238
|
-
{ key: 'kpi-total', title: 'Total de registros', token: '{{state.kpis.total}}', col: 1 },
|
|
15239
|
-
{ key: 'kpi-active', title: 'Registros ativos', token: '{{state.kpis.active}}', col: 5 },
|
|
15240
|
-
{ key: 'kpi-rate', title: 'Taxa de ativação (%)', token: '{{state.kpis.activeRatePct}}', col: 9 },
|
|
16337
|
+
{ key: 'kpi-total', title: 'Total de registros', token: '{{state.kpis.total | default:0 | number}}', col: 1 },
|
|
16338
|
+
{ key: 'kpi-active', title: 'Registros ativos', token: '{{state.kpis.active | default:0 | number}}', col: 5 },
|
|
16339
|
+
{ key: 'kpi-rate', title: 'Taxa de ativação (%)', token: '{{state.kpis.activeRatePct | default:0 | round:1}}', col: 9 },
|
|
15241
16340
|
];
|
|
15242
16341
|
for (const kpi of kpiDefs) {
|
|
15243
16342
|
widgets.push({
|
|
@@ -15406,7 +16505,7 @@ class DynamicPageConfigEditorComponent {
|
|
|
15406
16505
|
groupBy: groupField,
|
|
15407
16506
|
filter: {},
|
|
15408
16507
|
metric: { operation: 'COUNT', alias: 'id' },
|
|
15409
|
-
metrics: [{
|
|
16508
|
+
metrics: [{ op: 'count', operation: 'COUNT', alias: 'id' }],
|
|
15410
16509
|
},
|
|
15411
16510
|
transform: {
|
|
15412
16511
|
kind: 'group-by-map',
|
|
@@ -15414,6 +16513,14 @@ class DynamicPageConfigEditorComponent {
|
|
|
15414
16513
|
mappings: {
|
|
15415
16514
|
true: 'active',
|
|
15416
16515
|
false: 'inactive',
|
|
16516
|
+
1: 'active',
|
|
16517
|
+
0: 'inactive',
|
|
16518
|
+
ATIVO: 'active',
|
|
16519
|
+
INATIVO: 'inactive',
|
|
16520
|
+
ACTIVE: 'active',
|
|
16521
|
+
INACTIVE: 'inactive',
|
|
16522
|
+
A: 'active',
|
|
16523
|
+
I: 'inactive',
|
|
15417
16524
|
},
|
|
15418
16525
|
ratios: {
|
|
15419
16526
|
activeRatePct: {
|
|
@@ -15482,9 +16589,12 @@ class DynamicPageConfigEditorComponent {
|
|
|
15482
16589
|
})
|
|
15483
16590
|
.map((widget) => {
|
|
15484
16591
|
const item = items[widget.key] || { col: 1, row: 1, colSpan: 12, rowSpan: 1 };
|
|
16592
|
+
const presentation = this.resolveWidgetPresentation(widget, page.widgets);
|
|
15485
16593
|
return {
|
|
15486
16594
|
key: widget.key,
|
|
15487
|
-
label:
|
|
16595
|
+
label: presentation.title,
|
|
16596
|
+
icon: presentation.icon,
|
|
16597
|
+
description: presentation.description,
|
|
15488
16598
|
col: item.col,
|
|
15489
16599
|
row: item.row,
|
|
15490
16600
|
colSpan: item.colSpan,
|
|
@@ -15520,9 +16630,10 @@ class DynamicPageConfigEditorComponent {
|
|
|
15520
16630
|
}
|
|
15521
16631
|
duplicateFilterOwners() {
|
|
15522
16632
|
const page = this.effectivePageInput();
|
|
16633
|
+
const widgets = page.widgets || [];
|
|
15523
16634
|
const links = page.composition?.links || [];
|
|
15524
16635
|
const results = [];
|
|
15525
|
-
for (const widget of
|
|
16636
|
+
for (const widget of widgets) {
|
|
15526
16637
|
const compId = widget.definition?.id || widget.componentId || '';
|
|
15527
16638
|
const isTable = compId === 'praxis-Table' || compId === 'table' || compId === 'praxis-table';
|
|
15528
16639
|
if (!isTable)
|
|
@@ -15538,12 +16649,18 @@ class DynamicPageConfigEditorComponent {
|
|
|
15538
16649
|
continue;
|
|
15539
16650
|
const filterLinks = links.filter((link) => this.isFilterLinkTargetingWidget(link, widget.key));
|
|
15540
16651
|
if (filterLinks.length > 0) {
|
|
16652
|
+
const externalSourceKeys = [
|
|
16653
|
+
...new Set(filterLinks.map((l) => l.from?.kind === 'component-port' ? l.from.ref.widget : (l.source?.widgetKey || 'state'))),
|
|
16654
|
+
];
|
|
16655
|
+
const externalSourceLabels = externalSourceKeys.map((srcKey) => {
|
|
16656
|
+
const srcWidget = widgets.find((w) => w.key === srcKey);
|
|
16657
|
+
return srcWidget ? this.resolveWidgetTitle(srcWidget, widgets) : srcKey;
|
|
16658
|
+
});
|
|
15541
16659
|
results.push({
|
|
15542
16660
|
tableKey: widget.key,
|
|
15543
|
-
tableTitle: this.resolveWidgetTitle(widget),
|
|
15544
|
-
externalSourceKeys
|
|
15545
|
-
|
|
15546
|
-
],
|
|
16661
|
+
tableTitle: this.resolveWidgetTitle(widget, widgets),
|
|
16662
|
+
externalSourceKeys,
|
|
16663
|
+
externalSourceLabels,
|
|
15547
16664
|
linkIds: filterLinks.map((l) => l.id),
|
|
15548
16665
|
});
|
|
15549
16666
|
}
|
|
@@ -15595,8 +16712,17 @@ class DynamicPageConfigEditorComponent {
|
|
|
15595
16712
|
this.stagedComposition = composition;
|
|
15596
16713
|
this.refreshPanelState();
|
|
15597
16714
|
}
|
|
15598
|
-
|
|
15599
|
-
|
|
16715
|
+
resolveWidgetPresentation(widget, allWidgets) {
|
|
16716
|
+
const widgets = allWidgets ?? this.readEffectiveWidgets();
|
|
16717
|
+
const componentId = String(widget?.definition?.id || widget?.componentId || '').trim();
|
|
16718
|
+
const meta = componentId ? this.componentRegistry?.get(componentId) : undefined;
|
|
16719
|
+
return resolveWidgetEditorPresentation(widget, widgets, {
|
|
16720
|
+
...meta,
|
|
16721
|
+
translateTypeLabel: (typeKey, fallback) => this.tx(`widgetTypes.${typeKey}`, fallback),
|
|
16722
|
+
});
|
|
16723
|
+
}
|
|
16724
|
+
resolveWidgetTitle(widget, allWidgets) {
|
|
16725
|
+
return this.resolveWidgetPresentation(widget, allWidgets).title;
|
|
15600
16726
|
}
|
|
15601
16727
|
isFilterLinkTargetingWidget(link, widgetKey) {
|
|
15602
16728
|
const targetWidget = link?.to?.kind === 'component-port' ? link.to.ref.widget : link?.target?.widgetKey;
|
|
@@ -15630,6 +16756,12 @@ class DynamicPageConfigEditorComponent {
|
|
|
15630
16756
|
const scope = target.scope === 'base' ? this.tx('editor.limits.base', 'Base layout') : this.deviceLabels[target.scope];
|
|
15631
16757
|
return target.title + ' · ' + scope;
|
|
15632
16758
|
}
|
|
16759
|
+
limitsTargetIcon(index) {
|
|
16760
|
+
const target = this.canvasLimits.targets[index];
|
|
16761
|
+
if (!target)
|
|
16762
|
+
return 'dashboard_customize';
|
|
16763
|
+
return this.widgetIconForKey(target.key);
|
|
16764
|
+
}
|
|
15633
16765
|
limitFieldError(control) {
|
|
15634
16766
|
const code = control.getError('canvasLimit');
|
|
15635
16767
|
if (code === 'rendered-height')
|
|
@@ -16043,6 +17175,18 @@ class DynamicPageConfigEditorComponent {
|
|
|
16043
17175
|
...(this.stagedComposition ? { composition: this.clone(this.stagedComposition) } : {}),
|
|
16044
17176
|
};
|
|
16045
17177
|
}
|
|
17178
|
+
readEffectiveWidgets() {
|
|
17179
|
+
if (this.stagedWidgets)
|
|
17180
|
+
return this.stagedWidgets;
|
|
17181
|
+
if (this.pageInput && typeof this.pageInput === 'object') {
|
|
17182
|
+
return this.pageInput.widgets || [];
|
|
17183
|
+
}
|
|
17184
|
+
const injectedPage = this.injectedData?.page;
|
|
17185
|
+
if (injectedPage && typeof injectedPage === 'object') {
|
|
17186
|
+
return injectedPage.widgets || [];
|
|
17187
|
+
}
|
|
17188
|
+
return this.currentPageInput()?.widgets || [];
|
|
17189
|
+
}
|
|
16046
17190
|
currentPageInput() { return this.parsePage(this.pageInput) || this.parsePage(this.injectedData?.page || null); }
|
|
16047
17191
|
parsePage(input) { if (!input)
|
|
16048
17192
|
return null; if (typeof input === 'string') {
|
|
@@ -16262,10 +17406,26 @@ class DynamicPageConfigEditorComponent {
|
|
|
16262
17406
|
];
|
|
16263
17407
|
}
|
|
16264
17408
|
if (tKind === 'group-by-map' || kindRaw === 'stats-query' || endpointKeyRaw === 'statsGroupBy') {
|
|
16265
|
-
|
|
16266
|
-
|
|
16267
|
-
|
|
16268
|
-
|
|
17409
|
+
let keys = ['true', 'false'];
|
|
17410
|
+
if (tObj && this.isPlainObject(tObj['mappings'])) {
|
|
17411
|
+
const entries = Object.entries(tObj['mappings']).filter(([k, v]) => k.trim().length > 0 && typeof v === 'string' && v.trim().length > 0);
|
|
17412
|
+
const orderedEntries = [
|
|
17413
|
+
...entries.filter(([k]) => !/^\d+$/.test(k)),
|
|
17414
|
+
...entries.filter(([k]) => /^\d+$/.test(k)),
|
|
17415
|
+
];
|
|
17416
|
+
const seenTargets = new Set();
|
|
17417
|
+
const distinctKeys = [];
|
|
17418
|
+
for (const [k, v] of orderedEntries) {
|
|
17419
|
+
const target = String(v).trim();
|
|
17420
|
+
if (!seenTargets.has(target)) {
|
|
17421
|
+
seenTargets.add(target);
|
|
17422
|
+
distinctKeys.push(k);
|
|
17423
|
+
}
|
|
17424
|
+
}
|
|
17425
|
+
if (distinctKeys.length > 0) {
|
|
17426
|
+
keys = distinctKeys;
|
|
17427
|
+
}
|
|
17428
|
+
}
|
|
16269
17429
|
return keys.map((key, idx) => {
|
|
16270
17430
|
const numericKey = Number(key);
|
|
16271
17431
|
const bucketVal = key === 'true' ? true : key === 'false' ? false : Number.isFinite(numericKey) ? numericKey : key;
|
|
@@ -16280,14 +17440,17 @@ class DynamicPageConfigEditorComponent {
|
|
|
16280
17440
|
totalElements: 24,
|
|
16281
17441
|
};
|
|
16282
17442
|
}
|
|
16283
|
-
collectStateTokensForValue(prefix, value) {
|
|
17443
|
+
collectStateTokensForValue(prefix, value, depth = 0) {
|
|
16284
17444
|
const cleanPrefix = prefix.replace(/^state\./, '').trim();
|
|
16285
17445
|
if (!cleanPrefix)
|
|
16286
17446
|
return [];
|
|
16287
17447
|
const tokens = [`{{state.${cleanPrefix}}}`];
|
|
16288
|
-
if (this.isPlainObject(value)) {
|
|
16289
|
-
for (const key of Object.
|
|
16290
|
-
|
|
17448
|
+
if (depth < 4 && this.isPlainObject(value)) {
|
|
17449
|
+
for (const [key, childVal] of Object.entries(value)) {
|
|
17450
|
+
const trimmedKey = key.trim();
|
|
17451
|
+
if (!trimmedKey)
|
|
17452
|
+
continue;
|
|
17453
|
+
tokens.push(...this.collectStateTokensForValue(`${cleanPrefix}.${trimmedKey}`, childVal, depth + 1));
|
|
16291
17454
|
}
|
|
16292
17455
|
}
|
|
16293
17456
|
return tokens;
|
|
@@ -16304,7 +17467,13 @@ class DynamicPageConfigEditorComponent {
|
|
|
16304
17467
|
}
|
|
16305
17468
|
cursor = cursor[key];
|
|
16306
17469
|
}
|
|
16307
|
-
|
|
17470
|
+
const leafKey = segments[segments.length - 1];
|
|
17471
|
+
if (this.isPlainObject(cursor[leafKey]) && this.isPlainObject(value)) {
|
|
17472
|
+
cursor[leafKey] = { ...cursor[leafKey], ...value };
|
|
17473
|
+
}
|
|
17474
|
+
else {
|
|
17475
|
+
cursor[leafKey] = value;
|
|
17476
|
+
}
|
|
16308
17477
|
}
|
|
16309
17478
|
readNestedValue(target, dotPath) {
|
|
16310
17479
|
const segments = dotPath.split('.').map((s) => s.trim()).filter(Boolean);
|
|
@@ -16320,31 +17489,82 @@ class DynamicPageConfigEditorComponent {
|
|
|
16320
17489
|
const segments = dotPath.split('.').map((s) => s.trim()).filter(Boolean);
|
|
16321
17490
|
if (!segments.length)
|
|
16322
17491
|
return false;
|
|
17492
|
+
const trail = [];
|
|
16323
17493
|
let cursor = target;
|
|
16324
17494
|
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
16325
17495
|
const key = segments[i];
|
|
16326
17496
|
if (!this.isPlainObject(cursor[key]))
|
|
16327
17497
|
return false;
|
|
17498
|
+
trail.push({ parent: cursor, key });
|
|
16328
17499
|
cursor = cursor[key];
|
|
16329
17500
|
}
|
|
16330
17501
|
const leaf = segments[segments.length - 1];
|
|
16331
17502
|
if (!(leaf in cursor))
|
|
16332
17503
|
return false;
|
|
16333
17504
|
delete cursor[leaf];
|
|
17505
|
+
for (let i = trail.length - 1; i >= 0; i -= 1) {
|
|
17506
|
+
const { parent, key } = trail[i];
|
|
17507
|
+
if (this.isPlainObject(parent[key]) && Object.keys(parent[key]).length === 0) {
|
|
17508
|
+
delete parent[key];
|
|
17509
|
+
}
|
|
17510
|
+
else {
|
|
17511
|
+
break;
|
|
17512
|
+
}
|
|
17513
|
+
}
|
|
16334
17514
|
return true;
|
|
16335
17515
|
}
|
|
17516
|
+
isPlaceholderBoundInContext(inputs, cleanPlaceholder, contextKey) {
|
|
17517
|
+
const isNonEmptyValue = (val) => val !== undefined && val !== null && (typeof val !== 'string' || val.trim() !== '');
|
|
17518
|
+
if (typeof inputs['context'] === 'string' && inputs['context'].trim() !== '') {
|
|
17519
|
+
return true;
|
|
17520
|
+
}
|
|
17521
|
+
const segments = cleanPlaceholder.split('.').filter(Boolean);
|
|
17522
|
+
const hasNumericSegment = segments.some((segment) => /^\d+$/.test(segment));
|
|
17523
|
+
if (segments.length <= 2 &&
|
|
17524
|
+
!hasNumericSegment &&
|
|
17525
|
+
isNonEmptyValue(this.readNestedValue(inputs, `context.${contextKey}`))) {
|
|
17526
|
+
return true;
|
|
17527
|
+
}
|
|
17528
|
+
if (cleanPlaceholder !== contextKey && isNonEmptyValue(this.readNestedValue(inputs, `context.${cleanPlaceholder}`))) {
|
|
17529
|
+
return true;
|
|
17530
|
+
}
|
|
17531
|
+
for (let i = 1; i < segments.length; i += 1) {
|
|
17532
|
+
const parentPrefix = segments.slice(0, i).join('.');
|
|
17533
|
+
const parentVal = this.readNestedValue(inputs, `context.${parentPrefix}`);
|
|
17534
|
+
if (typeof parentVal === 'string' && parentVal.trim() !== '') {
|
|
17535
|
+
return true;
|
|
17536
|
+
}
|
|
17537
|
+
}
|
|
17538
|
+
return false;
|
|
17539
|
+
}
|
|
16336
17540
|
collectTemplatePlaceholdersRecursive(value, path, acc) {
|
|
16337
17541
|
if (typeof value === 'string') {
|
|
16338
17542
|
if (path === 'context' || path.startsWith('context.'))
|
|
16339
17543
|
return;
|
|
16340
|
-
const regex =
|
|
17544
|
+
const regex = /(?:\$\{([^}]+)\}|\{\{\s*([^}]+?)\s*\}\})/g;
|
|
16341
17545
|
let match;
|
|
17546
|
+
let foundExplicit = false;
|
|
16342
17547
|
while ((match = regex.exec(value)) !== null) {
|
|
16343
|
-
|
|
17548
|
+
foundExplicit = true;
|
|
17549
|
+
const rawToken = (match[1] ?? match[2] ?? '').trim();
|
|
17550
|
+
const token = rawToken.split('|')[0]?.trim() || '';
|
|
16344
17551
|
if (token && !token.startsWith('state.')) {
|
|
16345
17552
|
acc.add(token);
|
|
16346
17553
|
}
|
|
16347
17554
|
}
|
|
17555
|
+
if (!foundExplicit && /(?:^|\.)[a-zA-Z]+Expr$/.test(path)) {
|
|
17556
|
+
const trimmed = value.trim();
|
|
17557
|
+
if (/^[a-zA-Z_$][\w$.]*(?:\s*\|.+)?$/.test(trimmed)) {
|
|
17558
|
+
const rawToken = trimmed.split('|')[0]?.trim() || '';
|
|
17559
|
+
const token = rawToken.replace(/^state\.(?:derived\.)?/, '');
|
|
17560
|
+
if (token
|
|
17561
|
+
&& token !== 'true'
|
|
17562
|
+
&& token !== 'false'
|
|
17563
|
+
&& token !== 'null') {
|
|
17564
|
+
acc.add(token);
|
|
17565
|
+
}
|
|
17566
|
+
}
|
|
17567
|
+
}
|
|
16348
17568
|
return;
|
|
16349
17569
|
}
|
|
16350
17570
|
if (Array.isArray(value)) {
|
|
@@ -16366,11 +17586,55 @@ class DynamicPageConfigEditorComponent {
|
|
|
16366
17586
|
if (typeof v === 'string' && (v.includes('{{state.') || v.includes('${state.'))) {
|
|
16367
17587
|
acc.push({ widgetKey, widgetTitle, inputPath: nextPath, token: v });
|
|
16368
17588
|
}
|
|
17589
|
+
else if (Array.isArray(v)) {
|
|
17590
|
+
for (let i = 0; i < v.length; i += 1) {
|
|
17591
|
+
const item = v[i];
|
|
17592
|
+
const itemPath = `${nextPath}[${i}]`;
|
|
17593
|
+
if (typeof item === 'string' && (item.includes('{{state.') || item.includes('${state.'))) {
|
|
17594
|
+
acc.push({ widgetKey, widgetTitle, inputPath: itemPath, token: item });
|
|
17595
|
+
}
|
|
17596
|
+
else if (this.isPlainObject(item)) {
|
|
17597
|
+
this.collectWidgetBindingsRecursive(widgetKey, widgetTitle, item, itemPath, acc);
|
|
17598
|
+
}
|
|
17599
|
+
}
|
|
17600
|
+
}
|
|
16369
17601
|
else if (this.isPlainObject(v)) {
|
|
16370
17602
|
this.collectWidgetBindingsRecursive(widgetKey, widgetTitle, v, nextPath, acc);
|
|
16371
17603
|
}
|
|
16372
17604
|
}
|
|
16373
17605
|
}
|
|
17606
|
+
collectInvalidWidgetPipesRecursive(widgetKey, widgetTitle, obj, prefix, acc) {
|
|
17607
|
+
const isPipedCandidate = (path, val) => {
|
|
17608
|
+
if (!val.includes('|'))
|
|
17609
|
+
return false;
|
|
17610
|
+
return val.includes('{{') || val.includes('${') || /(?:^|\.)[a-zA-Z]+Expr$/.test(path);
|
|
17611
|
+
};
|
|
17612
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
17613
|
+
const nextPath = prefix ? `${prefix}.${k}` : k;
|
|
17614
|
+
if (typeof v === 'string') {
|
|
17615
|
+
if (isPipedCandidate(nextPath, v) && this.hasInvalidDeclarativePipe(v)) {
|
|
17616
|
+
acc.push({ widgetKey, widgetTitle, inputPath: nextPath, expression: v });
|
|
17617
|
+
}
|
|
17618
|
+
}
|
|
17619
|
+
else if (Array.isArray(v)) {
|
|
17620
|
+
for (let i = 0; i < v.length; i += 1) {
|
|
17621
|
+
const item = v[i];
|
|
17622
|
+
const itemPath = `${nextPath}[${i}]`;
|
|
17623
|
+
if (typeof item === 'string') {
|
|
17624
|
+
if (isPipedCandidate(itemPath, item) && this.hasInvalidDeclarativePipe(item)) {
|
|
17625
|
+
acc.push({ widgetKey, widgetTitle, inputPath: itemPath, expression: item });
|
|
17626
|
+
}
|
|
17627
|
+
}
|
|
17628
|
+
else if (this.isPlainObject(item)) {
|
|
17629
|
+
this.collectInvalidWidgetPipesRecursive(widgetKey, widgetTitle, item, itemPath, acc);
|
|
17630
|
+
}
|
|
17631
|
+
}
|
|
17632
|
+
}
|
|
17633
|
+
else if (this.isPlainObject(v)) {
|
|
17634
|
+
this.collectInvalidWidgetPipesRecursive(widgetKey, widgetTitle, v, nextPath, acc);
|
|
17635
|
+
}
|
|
17636
|
+
}
|
|
17637
|
+
}
|
|
16374
17638
|
humanizeResourceTitle(resourcePath) {
|
|
16375
17639
|
const lastSegment = resourcePath.split('/').filter(Boolean).pop() || 'Coleção';
|
|
16376
17640
|
return lastSegment
|
|
@@ -16380,11 +17644,11 @@ class DynamicPageConfigEditorComponent {
|
|
|
16380
17644
|
clone(value) { if (value == null || typeof value !== 'object')
|
|
16381
17645
|
return value; return JSON.parse(JSON.stringify(value)); }
|
|
16382
17646
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicPageConfigEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
16383
|
-
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()], viewQueries: [{ propertyName: "arrangementFeedback", first: true, predicate: ["arrangementFeedback"], descendants: true }], ngImport: i0, template: "<div class=\"editor\">\n @if (!isHostedInSettingsPanel) { <div class=\"head\">\n <div>{{ tx('editor.title', 'Page settings') }}</div>\n <button mat-stroked-button type=\"button\" (click)=\"reset()\">{{ tx('editor.reload', 'Reload') }}</button>\n </div> }\n\n @if (nonLimitError) { <p class=\"limits-error\" role=\"alert\">{{ nonLimitError }}</p> }\n @if (limitsError() || previousAdjustableHeights().length) {\n <section class=\"card limits-blocking-banner\" role=\"region\" [attr.aria-label]=\"tx('editor.limits.blockedBannerTitle', 'Height geometry requires review in Widget limits')\" data-testid=\"preset-geometry-blocking-banner\">\n @if (limitsError()) {\n <div class=\"head\">\n <strong>{{ tx('editor.limits.blockedBannerTitle', 'Height geometry requires review in Widget limits') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.limits.blockedBannerHelp', 'One or more widgets use an adjustable pixel height that exceeds the current usable area or requires review before applying the page or template:') }}</p>\n <ul class=\"blocked-widgets-list\">\n @for (item of blockedGeometryWidgets; track item.scope + ':' + item.key) {\n <li>\n <strong>{{ item.title }}</strong>\n @if (item.title !== item.key) { <code>({{ item.key }})</code> }\n <span> \u00B7 {{ item.scopeLabel }}</span>\n @if (item.heightPx !== null) {\n <span> \u00B7 {{ tx('editor.height.fixed', 'Adjustable') }}: <strong>{{ item.heightPx }}px</strong></span>\n }\n </li>\n }\n </ul>\n <p class=\"limits-error\" role=\"alert\">{{ limitsError() }}</p>\n <div class=\"head\">\n <button mat-stroked-button type=\"button\" data-testid=\"goto-blocked-widget-limits\" (click)=\"navigateToBlockedLimit()\">\n <mat-icon aria-hidden=\"true\">tune</mat-icon>{{ tx('editor.limits.gotoLimitsAction', 'Go to Adjustments \u2192 Widget limits') }}\n </button>\n <button mat-flat-button type=\"button\" data-testid=\"switch-blocked-widgets-auto-height\" (click)=\"switchBlockedWidgetsToAutoHeight()\">\n <mat-icon aria-hidden=\"true\">height</mat-icon>{{ tx('editor.limits.switchToAutoHeightAction', 'Switch affected widgets to Automatic height') }}\n </button>\n </div>\n }\n @if (previousAdjustableHeights().length) {\n <div class=\"previous-heights-summary\" data-testid=\"previous-adjustable-heights-summary\">\n <span>{{ tx('editor.limits.previousHeightsSummary', 'Previous adjustable heights preserved for reference:') }}</span>\n @for (prev of previousAdjustableHeights(); track prev.scope + ':' + prev.key) {\n <span class=\"previous-height-chip\">{{ prev.title }} ({{ prev.scopeLabel }}): <strong>{{ prev.heightPx }}px</strong></span>\n }\n </div>\n }\n </section>\n }\n <mat-tab-group class=\"editor-tabs studio-tabs\" [mat-stretch-tabs]=\"false\" [selectedIndex]=\"workspaceTab()\" (selectedIndexChange)=\"workspaceTab.set($event)\">\n <mat-tab [label]=\"tx('studio.visualTab', 'Visual templates')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"studio-visual\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.visualEyebrow', 'Appearance') }}</span><h2>{{ tx('studio.visualTitle', 'Give the page a visual language') }}</h2><p>{{ tx('studio.visualHelp', 'Choose surfaces, density and chart styling together. Individual widget overrides still take precedence.') }}</p></header>\n <button mat-stroked-button type=\"button\" [attr.aria-pressed]=\"!themePresetControl.value\" (click)=\"themePresetControl.setValue('')\">{{ tx('editor.layout.layoutDefault', 'Layout default') }}</button>\n <p class=\"selection-line\">{{ tx('presets.effectiveTheme', 'Effective page theme') }}: <strong>{{ selectedThemePresetEntry?.label || themePresetControl.value || recommendedThemePresetEntry?.label || tx('presets.hostTheme', 'Host theme') }}</strong></p>\n <div class=\"studio-gallery\">\n @for (theme of themePresetEntries; track theme.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"themePresetControl.value === theme.id\" [attr.aria-pressed]=\"themePresetControl.value === theme.id\" [attr.data-theme-preset]=\"theme.id\" (click)=\"themePresetControl.setValue(theme.id)\">\n <span class=\"theme-sample\" aria-hidden=\"true\" [style.background]=\"themeTokens(theme.id)['--pdx-page-bg']\" [style.--sample-accent]=\"themeTokens(theme.id)['--pdx-page-accent']\" [style.--sample-surface]=\"themeTokens(theme.id)['--pdx-page-surface']\" [style.--sample-radius]=\"themeTokens(theme.id)['--pdx-page-card-radius']\" [style.--sample-shadow]=\"themeTokens(theme.id)['--pdx-page-card-shadow']\" [style.gap]=\"themeTokens(theme.id)['--pdx-page-gap']\">\n <span class=\"sample-metric\"><b>128</b><i></i></span><span class=\"sample-metric\"><b>86%</b><i></i></span><span class=\"sample-chart\"><i></i><i></i><i></i><i></i><i></i></span>\n </span>\n <span class=\"choice-title\">{{ theme.label }}@if (themePresetControl.value === theme.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span>\n <span class=\"choice-description\">{{ tx('studio.theme.' + theme.id, theme.label) }}</span>\n <span class=\"choice-meta\">{{ tx('studio.density.' + theme.density, theme.density) }} \u00B7 {{ shellName(theme.shellPreset) }}</span>\n </button>\n }\n </div>\n <p class=\"hint\">{{ tx('studio.samplesHelp', 'Illustrative samples using the preset tokens. Apply to see the result with your content.') }}</p>\n <details class=\"shell-options\"><summary>{{ tx('studio.shellTitle', 'Customize widget surfaces') }} \u00B7 {{ shellPresetEntries.length }}</summary>\n <p>{{ tx('studio.shellHelp', 'Override the page theme\u2019s default widget surface. Per-widget appearance settings keep their priority.') }}</p>\n <button mat-stroked-button type=\"button\" [attr.aria-pressed]=\"!shellPresetControl.value\" (click)=\"shellPresetControl.setValue('')\">{{ tx('editor.layout.inheritShell', 'Inherit appearance') }}</button>\n <div class=\"studio-gallery shell-gallery\">@for (shell of shellPresetEntries; track shell.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"shellPresetControl.value === shell.id\" [attr.aria-pressed]=\"shellPresetControl.value === shell.id\" (click)=\"shellPresetControl.setValue(shell.id)\">\n <span class=\"shell-sample\" aria-hidden=\"true\" [style.background]=\"shell.appearance.card?.background\" [style.border-color]=\"shell.appearance.card?.borderColor\" [style.border-radius]=\"shell.appearance.card?.borderRadius\" [style.box-shadow]=\"shell.appearance.card?.shadow\"><span [style.background]=\"shell.appearance.header?.background\" [style.border-color]=\"shell.appearance.header?.borderColor\"><mat-icon>analytics</mat-icon><i></i></span><span [style.background]=\"shell.appearance.body?.background\"><i></i><i></i></span></span>\n <span class=\"choice-title\">{{ shellPresetLabel(shell) }}@if (shellPresetControl.value === shell.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span><span class=\"choice-description\">{{ shellDescription(shell) }}</span>\n </button>\n }</div>\n </details>\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.layoutTab', 'Layout templates')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"studio-layout\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.layoutEyebrow', 'Composition model') }}</span><h2>{{ tx('studio.layoutTitle', 'Choose the structure for your use case') }}</h2><p>{{ tx('studio.layoutHelp', 'Compare the roles and default theme of each model. To redistribute the widgets already on this canvas, open Arrange.') }}</p></header>\n @if (duplicateFilterOwners().length) {\n <section class=\"card filter-owner-banner\" role=\"region\" [attr.aria-label]=\"tx('studio.filterOwnerBannerTitle', 'Duplicate filter region detected')\" data-testid=\"duplicate-filter-owner-banner\">\n <div class=\"head\">\n <strong>{{ tx('studio.filterOwnerBannerTitle', 'Single filter region recommendation') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('studio.filterOwnerBannerHelp', 'A table has both its integrated filter bar and an external filter link active. Choose a single query owner to avoid competing filter regions:') }}</p>\n @for (item of duplicateFilterOwners(); track item.tableKey) {\n <div class=\"filter-owner-item\" [attr.data-testid]=\"'duplicate-filter-owner-' + item.tableKey\">\n <span><strong>{{ item.tableTitle }}</strong> <code>({{ item.tableKey }})</code> \u2190 {{ item.externalSourceKeys.join(', ') }}</span>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'consolidate-filter-table-toolbar-' + item.tableKey\" (click)=\"consolidateFilterOwnership(item.tableKey, 'keep-table-toolbar')\">\n {{ tx('studio.keepTableToolbarFilter', 'Use integrated table filter') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'consolidate-filter-external-link-' + item.tableKey\" (click)=\"consolidateFilterOwnership(item.tableKey, 'keep-external-link')\">\n {{ tx('studio.keepExternalFilterLink', 'Use external filter bar') }}\n </button>\n </div>\n </div>\n }\n </section>\n }\n <details class=\"card composer-card\" data-testid=\"collection-screen-composer\">\n <summary>{{ tx('studio.composer.title', 'Guided collection screen composer (5-minute flow)') }}</summary>\n <p class=\"hint\">{{ tx('studio.composer.help', 'Assemble a complete collection screen with header, KPI indicators, single filter region, primary collection, and optional detail surface in the draft.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.resourcePath', 'Resource path') }}</mat-label>\n <input matInput [formControl]=\"composerResourcePathControl\" data-testid=\"composer-resource-path\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.screenTitle', 'Screen title') }}</mat-label>\n <input matInput [formControl]=\"composerTitleControl\" data-testid=\"composer-title\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.groupField', 'KPI group-by field') }}</mat-label>\n <input matInput [formControl]=\"composerGroupFieldControl\" data-testid=\"composer-group-field\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.filterMode', 'Query filter owner') }}</mat-label>\n <mat-select [formControl]=\"composerFilterModeControl\" data-testid=\"composer-filter-mode\">\n <mat-option value=\"table-integrated\">{{ tx('studio.composer.filterIntegrated', 'Integrated table toolbar') }}</mat-option>\n <mat-option value=\"external-filter-bar\">{{ tx('studio.composer.filterExternal', 'Dedicated filter bar widget') }}</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.collectionKind', 'Primary collection') }}</mat-label>\n <mat-select [formControl]=\"composerCollectionKindControl\" data-testid=\"composer-collection-kind\">\n <mat-option value=\"table\">praxis-Table</mat-option>\n <mat-option value=\"list\">praxis-List</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.detailSurface', 'Secondary detail surface') }}</mat-label>\n <mat-select [formControl]=\"composerDetailSurfaceControl\" data-testid=\"composer-detail-surface\">\n <mat-option value=\"drawer\">Drawer</mat-option>\n <mat-option value=\"modal\">Modal</mat-option>\n <mat-option value=\"none\">{{ tx('studio.composer.noSurface', 'None') }}</mat-option>\n </mat-select>\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-flat-button type=\"button\" data-testid=\"composer-generate-btn\" (click)=\"composeCollectionScreen()\">\n <mat-icon aria-hidden=\"true\">auto_awesome</mat-icon>{{ tx('studio.composer.generate', 'Generate collection screen in draft') }}\n </button>\n </div>\n @if (composerMessage()) {\n <p class=\"studio-status\" role=\"status\" data-testid=\"composer-status-message\">{{ composerMessage() }}</p>\n }\n </details>\n <div class=\"studio-actions\"><button mat-stroked-button type=\"button\" (click)=\"layoutPresetControl.setValue('')\">{{ tx('studio.inheritLayout', 'Use inherited model') }}</button><button mat-button type=\"button\" (click)=\"workspaceTab.set(2)\">{{ tx('studio.goArrange', 'Arrange existing widgets') }}<mat-icon iconPositionEnd>arrow_forward</mat-icon></button></div>\n <praxis-page-preset-comparison [expanded]=\"true\" [layoutId]=\"layoutPresetControl.value\" [layoutPresetOptions]=\"layoutPresetOptions\" [themeId]=\"themePresetControl.value\" [canvas]=\"true\" (choose)=\"layoutPresetControl.setValue($event)\" />\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.arrangeTab', 'Arrange')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"canvas-arrangement\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.arrangeEyebrow', 'Existing widgets') }} \u00B7 {{ canvasColumnsControl.value }} {{ tx('editor.layout.cols', 'Columns') }}</span><h2>{{ tx('studio.arrangeTitle', 'Find the right balance of space') }}</h2><p>{{ tx('studio.arrangeHelp', 'Choose a distribution, compare the real positions, then use it in the draft. Widgets keep their reading order and content.') }}</p></header>\n <div #arrangementFeedback tabindex=\"-1\" class=\"arrangement-feedback\">\n @if (arrangementPreview(); as preview) {\n <section class=\"arrangement-review\" aria-live=\"polite\" data-testid=\"arrangement-preview\">\n <div class=\"review-heading\"><h3>{{ tx('studio.reviewTitle', 'Compare before using') }}</h3><span>{{ preview.keys.length }} {{ tx('studio.changedWidgets', 'widgets change') }}</span></div>\n <div class=\"canvas-comparison\">\n @for (side of ['before', 'after']; track side) {\n <figure><figcaption>{{ side === 'before' ? tx('studio.before', 'Before') : tx('studio.after', 'After') }}</figcaption>\n <div class=\"canvas-map\" [style.grid-template-columns]=\"'repeat(' + preview.canvas.columns + ', minmax(0, 1fr))'\" [style.grid-template-rows]=\"'repeat(' + previewRowCount(preview.before, preview.canvas) + ', minmax(0, 1fr))'\" role=\"img\" [attr.aria-label]=\"side === 'before' ? tx('studio.before', 'Before') : tx('studio.after', 'After')\">\n @for (item of previewItems(side === 'before' ? preview.before : preview.canvas); track item.key) {\n <span [style.grid-column]=\"item.col + ' / span ' + item.colSpan\" [style.grid-row]=\"item.row + ' / span ' + item.rowSpan\" [class.changed]=\"preview.keys.includes(item.key)\" [matTooltip]=\"item.label\">{{ item.number }}</span>\n }\n </div>\n </figure>\n }\n </div>\n <p class=\"hint\">{{ tx('studio.mapHelp', 'Numbered map of your widgets. Positions and spans are to scale; content height is evaluated on the page.') }}</p>\n <details><summary>{{ tx('studio.widgetDetails', 'Widget positions and sizes') }}</summary><ol class=\"widget-changes\">@for (item of previewItems(preview.canvas); track item.key) {<li><strong>{{ item.label }}</strong><span>{{ tx('studio.before', 'Before') }}: {{ tx('editor.arrange.position', 'Column / row') }} {{ preview.before.items[item.key].col }} / {{ preview.before.items[item.key].row }} \u00B7 {{ tx('studio.gridSize', 'Size in columns \u00D7 rows') }} {{ preview.before.items[item.key].colSpan }} \u00D7 {{ preview.before.items[item.key].rowSpan }}</span><span>{{ tx('studio.after', 'After') }}: {{ tx('editor.arrange.position', 'Column / row') }} {{ item.col }} / {{ item.row }} \u00B7 {{ tx('studio.gridSize', 'Size in columns \u00D7 rows') }} {{ item.colSpan }} \u00D7 {{ item.rowSpan }}</span></li>}</ol></details>\n <button mat-flat-button type=\"button\" data-testid=\"arrangement-use\" (click)=\"useArrangement()\">{{ tx('editor.arrange.use', 'Use arrangement in draft') }}<mat-icon iconPositionEnd>arrow_forward</mat-icon></button>\n </section>\n }\n @if (arrangementMessage()) {<p class=\"studio-status\" role=\"status\">{{ arrangementMessage() }}</p>}\n @if (arrangementUndoAvailable()) {<button mat-stroked-button type=\"button\" data-testid=\"arrangement-undo\" (click)=\"undoArrangement()\"><mat-icon>undo</mat-icon>{{ tx('editor.arrange.undo', 'Undo arrangement') }}</button>}\n </div>\n <div class=\"studio-gallery arrangement-gallery\">\n @for (choice of arrangementChoices; track choice.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"arrangementSelection() === choice.id\" [attr.aria-pressed]=\"arrangementSelection() === choice.id\" [disabled]=\"!hasCanvasWidgets || !arrangementFits(choice.id)\" [attr.data-testid]=\"'arrange-' + choice.id\" (click)=\"prepareArrangement(choice.id)\">\n <span class=\"distribution-sample\" aria-hidden=\"true\" [style.grid-template-columns]=\"(arrangementFits(choice.id) ? arrangementWidths(choice.id) : choice.weights).join('fr ') + 'fr'\">@for (weight of choice.weights; track $index) {<i>{{ arrangementWidths(choice.id)[$index] }}</i>}@for (weight of choice.weights; track $index) {<i></i>}</span>\n <span class=\"choice-title\">{{ tx('editor.arrange.' + choice.id, choice.id) }}@if (arrangementSelection() === choice.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span>\n <span class=\"choice-description\">{{ tx('studio.arrange.' + choice.id, choice.id) }}</span>\n <span class=\"choice-meta\">{{ arrangementFits(choice.id) ? arrangementWidths(choice.id).join(' + ') : tx('studio.moreColumns', 'Needs more grid columns') }}</span>\n </button>\n }\n </div>\n @if (hasCanvasWidgets) {\n <details class=\"card quick-geometry-card\" data-testid=\"quick-widget-geometry-list\">\n <summary>{{ tx('studio.quickGeometryTitle', 'Quick widget spans (columns \u00D7 rows)') }}</summary>\n <div class=\"quick-geometry-rows\">\n @for (w of quickCanvasWidgets(); track w.key) {\n <div class=\"quick-geometry-row\" [attr.data-testid]=\"'quick-geometry-row-' + w.key\">\n <span class=\"quick-geometry-label\"><strong>{{ w.label }}</strong> <small>({{ w.colSpan }}\u00D7{{ w.rowSpan }})</small></span>\n <div class=\"studio-actions\">\n @for (span of [3, 4, 6, 12]; track span) {\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'quick-colspan-' + w.key + '-' + span\" (click)=\"setWidgetCanvasSpan(w.key, span)\">{{ span }}c</button>\n }\n @for (rSpan of [1, 2, 4]; track rSpan) {\n <button mat-button type=\"button\" [attr.data-testid]=\"'quick-rowspan-' + w.key + '-' + rSpan\" (click)=\"setWidgetCanvasSpan(w.key, w.colSpan, rSpan)\">{{ rSpan }}r</button>\n }\n </div>\n </div>\n }\n </div>\n </details>\n }\n @if (!hasCanvasWidgets) {<p role=\"status\">{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>}\n <details class=\"arrangement-rules\"><summary>{{ tx('studio.rules', 'Scope, devices and locked widgets') }}</summary><p>{{ tx('editor.arrange.help', 'Base canvas, in current reading order. Inheriting devices follow the base; explicit device positions are preserved. Locked positions stay in place.') }}</p><button mat-button type=\"button\" (click)=\"onSelectedTabIndexChange(5)\">{{ tx('studio.goLimits', 'Review widget limits') }}</button></details>\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.settingsTab', 'Adjustments')\">\n <div class=\"studio-panel\"><mat-tab-group class=\"editor-tabs\" [mat-stretch-tabs]=\"false\" [selectedIndex]=\"selectedTabIndex\" (selectedIndexChange)=\"selectDetailTab($event)\">\n <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 class=\"page-inset-field\" appearance=\"outline\"><mat-label>{{ tx('editor.layout.paddingInline', 'Page side inset') }}</mat-label><input matInput [formControl]=\"paddingInlineControl\" placeholder=\"24px\" /><button praxisIconButton=\"help_outline\" size=\"dense\" matSuffix type=\"button\" [matTooltip]=\"tx('editor.hints.paddingInline', 'Shared space beside all page widgets. Use a CSS length or theme token; 0px removes it. Leave empty to inherit the host theme.')\" [attr.aria-label]=\"tx('editor.layout.paddingInline', 'Page side inset')\"></button></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></div></mat-tab>\n <mat-tab [label]=\"tx('editor.tabs.grouping', 'Grouping')\"><div class=\"panel\">\n <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>\n <mat-tab [label]=\"tx('editor.tabs.devices', 'Device Layouts')\">\n <div class=\"panel\">\n <mat-button-toggle-group [value]=\"activeDevice()\" (change)=\"activeDevice.set($event.value)\" [attr.aria-label]=\"tx('editor.devices.target', 'Device to configure')\">\n @for (device of deviceKeys; track device) {\n <mat-button-toggle [value]=\"device\">{{ deviceLabels[device] }}</mat-button-toggle>\n }\n </mat-button-toggle-group>\n @for (device of deviceKeys; track device) {\n @if (activeDevice() === device) {\n <div class=\"card\">\n <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>\n <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>\n @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> }\n <div class=\"grid\">\n <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>\n <mat-form-field class=\"page-inset-field\" appearance=\"outline\"><mat-label>{{ tx('editor.layout.paddingInline', 'Page side inset') }}</mat-label><input matInput [formControl]=\"deviceForms[device].controls.paddingInline\" placeholder=\"16px\" /></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>\n <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>\n </div>\n <details><summary>{{ tx('editor.devices.advanced', 'Advanced overrides') }}</summary><div class=\"grid\">\n <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>\n <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>\n <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-hint>{{ tx('editor.devices.widgetOverridesHelp', 'Supported per-widget properties on this device: className, span, order, and hidden. Does not override internal widget definition or inputs.') }}</mat-hint></mat-form-field>\n </div></details>\n @if (!stackedDevices.has(device)) { <div class=\"hint\">{{ tx('editor.preview.deviceCanvasHint', 'Existing canvas item overrides are preserved.') }}</div> }\n </div>\n }\n }\n </div>\n </mat-tab>\n <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>\n <mat-tab [label]=\"tx('editor.tabs.state', 'Page State')\">\n <div class=\"panel\">\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.stateDefinitionJson', 'Page state (values, schema, derived \u2014 JSON)') }}</mat-label>\n <textarea matInput rows=\"10\" [formControl]=\"stateControl\" data-testid=\"page-state-json\"></textarea>\n </mat-form-field>\n\n <section class=\"panel\" data-testid=\"page-datasources-guided\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.dataSourcesTitle', 'Data Sources') }}</strong>\n <button mat-stroked-button type=\"button\" data-testid=\"page-datasources-add\" (click)=\"addDataSourceNode()\">\n <mat-icon>add</mat-icon>{{ tx('editor.state.addDataSource', 'Add data source') }}\n </button>\n </div>\n <p class=\"hint\">{{ tx('editor.state.dataSourcesCopy', 'Bind HTTP or resource endpoints directly into page state paths with reactive parameters.') }}</p>\n @if (!dataSourceNodes.controls.length) {\n <p class=\"hint\" data-testid=\"page-datasources-empty\">{{ tx('editor.state.emptyDataSources', 'No declarative data sources configured.') }}</p>\n }\n @for (ds of dataSourceNodes.controls; track $index) {\n <div class=\"card\" [formGroup]=\"ds\" [attr.data-testid]=\"'page-datasource-card-' + $index\">\n <div class=\"head\">\n <span>{{ dataSourceLabel($index) }}</span>\n <button\n praxisIconButton=\"delete\"\n size=\"compact\"\n type=\"button\"\n [attr.data-testid]=\"'page-datasource-remove-' + $index\"\n [attr.aria-label]=\"tx('editor.state.removeDataSourceAria', 'Remove data source')\"\n (click)=\"removeDataSourceNode($index)\"\n ></button>\n </div>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.dataSourceId', 'ID') }}</mat-label>\n <input matInput formControlName=\"id\" [attr.data-testid]=\"'page-datasource-id-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.dataSourceKind', 'Kind') }}</mat-label>\n <mat-select formControlName=\"kind\" [attr.data-testid]=\"'page-datasource-kind-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"remote-resource\">remote-resource</mat-option>\n <mat-option value=\"stats-query\">stats-query</mat-option>\n <mat-option value=\"raw-http\">raw-http</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.resourcePath', 'Resource path') }}</mat-label>\n <input matInput formControlName=\"resourcePath\" [attr.data-testid]=\"'page-datasource-resourcePath-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.endpointKey', 'Endpoint key') }}</mat-label>\n <input matInput formControlName=\"endpointKey\" [attr.data-testid]=\"'page-datasource-endpointKey-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.method', 'HTTP method') }}</mat-label>\n <mat-select formControlName=\"method\" [attr.data-testid]=\"'page-datasource-method-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.auto', 'Auto') }}</mat-option>\n <mat-option value=\"GET\">GET</mat-option>\n <mat-option value=\"POST\">POST</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.targetStatePath', 'Target state path') }}</mat-label>\n <input matInput formControlName=\"targetStatePath\" [attr.data-testid]=\"'page-datasource-targetStatePath-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.errorPolicy', 'Error policy') }}</mat-label>\n <mat-select formControlName=\"errorPolicy\" [attr.data-testid]=\"'page-datasource-errorPolicy-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"propagate\">propagate</mat-option>\n <mat-option value=\"silent\">silent</mat-option>\n <mat-option value=\"fallback\">fallback</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.refreshIntervalMs', 'Refresh interval (ms)') }}</mat-label>\n <input matInput type=\"number\" min=\"0\" formControlName=\"refreshIntervalMs\" [attr.data-testid]=\"'page-datasource-refreshIntervalMs-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.autoFetch', 'Auto fetch') }}</mat-label>\n <mat-select formControlName=\"autoFetch\" [attr.data-testid]=\"'page-datasource-autoFetch-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"true\">true</mat-option>\n <mat-option value=\"false\">false</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.description', 'Description') }}</mat-label>\n <input matInput formControlName=\"description\" [attr.data-testid]=\"'page-datasource-description-' + $index\" />\n </mat-form-field>\n <div class=\"wide guided-presets-bar\" [attr.data-testid]=\"'page-datasource-guided-presets-' + $index\">\n <span class=\"hint\">{{ tx('editor.state.guidedPresets', 'Guided query & transform templates:') }}</span>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-groupby-' + $index\" (click)=\"applyGuidedDataSourceQuery($index, { mode: 'group-by', groupByField: 'ativo', metricField: 'id', metricOp: 'count' }); applyGuidedDataSourceTransform($index, { kind: 'group-by-map', totalField: 'total', mappingsText: 'true:active, false:inactive', ratioTargetField: 'activeRatePct', ratioNumeratorField: 'active', ratioDenominatorField: 'total', ratioScale: 100, ratioDecimals: 1 })\">\n {{ tx('editor.state.presetGroupByKpi', 'Group-by + Total + Ratio (%)') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-weighted-' + $index\" (click)=\"applyGuidedDataSourceTransform($index, { kind: 'group-by-map', totalField: 'total', weightedAverageField: 'weightedAvg', weightedAverageDecimals: 2, mappingsText: '1:low, 5:high' })\">\n {{ tx('editor.state.presetWeightedAvg', 'Weighted average') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-timeseries-' + $index\" (click)=\"applyGuidedDataSourceQuery($index, { mode: 'timeseries', groupByField: 'createdAt', period: 'month', metricField: 'id', metricOp: 'count' }); applyGuidedDataSourceTransform($index, { kind: 'timeseries-last', mappingsText: 'count:latestCount, avg:latestAvg' })\">\n {{ tx('editor.state.presetTimeseriesLast', 'Timeseries latest point') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-path-' + $index\" (click)=\"applyGuidedDataSourceTransform($index, { kind: 'path', path: 'totalElements' })\">\n {{ tx('editor.state.presetPathExtract', 'Extract path (totalElements)') }}\n </button>\n </div>\n </div>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.queryJson', 'Query (JSON)') }}</mat-label>\n <textarea matInput rows=\"3\" formControlName=\"queryJson\" [attr.data-testid]=\"'page-datasource-queryJson-' + $index\"></textarea>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.transformJson', 'Transform (JSON)') }}</mat-label>\n <textarea matInput rows=\"3\" formControlName=\"transformJson\" [attr.data-testid]=\"'page-datasource-transformJson-' + $index\"></textarea>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.fallbackJson', 'Fallback value (JSON)') }}</mat-label>\n <textarea matInput rows=\"2\" formControlName=\"fallbackJson\" [attr.data-testid]=\"'page-datasource-fallbackJson-' + $index\"></textarea>\n </mat-form-field>\n </div>\n @let preview = getDataSourcePreview($index);\n <div class=\"datasource-preview-box\" [attr.data-testid]=\"'page-datasource-preview-' + $index\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.previewTitle', 'Transform & State Preview') }}</strong>\n <span class=\"previous-height-chip\">{{ preview.sourceKind === 'live' ? tx('editor.state.previewLive', 'Live response') : tx('editor.state.previewSample', 'Simulated sample') }}</span>\n </div>\n @if (preview.status === 'error') {\n <p class=\"limits-error\" role=\"alert\" [attr.data-testid]=\"'page-datasource-preview-error-' + $index\">{{ preview.error }}</p>\n } @else {\n <pre class=\"datasource-preview-json\" [attr.data-testid]=\"'page-datasource-preview-json-' + $index\">{{ preview.formattedJson }}</pre>\n @if (preview.tokens.length) {\n <div class=\"studio-actions\" [attr.data-testid]=\"'page-datasource-preview-tokens-' + $index\">\n <span class=\"hint\">{{ tx('editor.state.availableTokens', 'Click a token to prepare widget binding:') }}</span>\n @for (token of preview.tokens; track token) {\n <button mat-button type=\"button\" (click)=\"binderTokenControl.setValue(token)\"><code>{{ token }}</code></button>\n }\n </div>\n }\n }\n </div>\n </div>\n }\n </section>\n\n <section class=\"card\" data-testid=\"page-derived-measures-guided\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.derivedMeasuresTitle', 'Guided Derived Measures') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.state.derivedMeasuresCopy', 'Create percentages, ratios, weighted averages, or formatted measures from page state paths without writing raw JsonLogic.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedTargetPath', 'Measure key') }}</mat-label>\n <input matInput [formControl]=\"derivedPathControl\" placeholder=\"activeRatePct\" data-testid=\"derived-target-path\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedKind', 'Calculation') }}</mat-label>\n <mat-select [formControl]=\"derivedKindControl\" data-testid=\"derived-kind\">\n <mat-option value=\"ratio-percent\">{{ tx('editor.state.derivedRatio', 'Ratio / Percentage') }}</mat-option>\n <mat-option value=\"weighted-average\">{{ tx('editor.state.derivedWeighted', 'Weighted average') }}</mat-option>\n <mat-option value=\"format-value\">{{ tx('editor.state.derivedFormat', 'Formatted value') }}</mat-option>\n <mat-option value=\"template\">{{ tx('editor.state.derivedTemplate', 'State reference') }}</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedNumerator', 'Numerator / Source path') }}</mat-label>\n <input matInput [formControl]=\"derivedNumeratorControl\" placeholder=\"kpis.active\" data-testid=\"derived-numerator\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedDenominator', 'Denominator path') }}</mat-label>\n <input matInput [formControl]=\"derivedDenominatorControl\" placeholder=\"kpis.total\" data-testid=\"derived-denominator\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedFormatKind', 'Format') }}</mat-label>\n <mat-select [formControl]=\"derivedFormatControl\" data-testid=\"derived-format\">\n <mat-option value=\"percent\">percent (%)</mat-option>\n <mat-option value=\"number\">number</mat-option>\n <mat-option value=\"integer\">integer</mat-option>\n <mat-option value=\"currency\">currency (R$)</mat-option>\n <mat-option value=\"boolean\">boolean</mat-option>\n </mat-select>\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" data-testid=\"derived-add-btn\" (click)=\"addGuidedDerivedMeasure()\">\n <mat-icon aria-hidden=\"true\">functions</mat-icon>{{ tx('editor.state.addDerivedAction', 'Add measure to page state') }}\n </button>\n </div>\n @let derivedPreview = getDerivedStatePreview();\n @if (derivedPreview.entries.length || derivedPreview.diagnostics.length) {\n <div class=\"datasource-preview-box\" data-testid=\"page-derived-preview\">\n @for (diag of derivedPreview.diagnostics; track diag) {\n <p class=\"limits-error\" role=\"alert\">{{ diag }}</p>\n }\n @if (derivedPreview.entries.length) {\n <div class=\"studio-actions\">\n @for (entry of derivedPreview.entries; track entry.key) {\n <button mat-button type=\"button\" (click)=\"binderTokenControl.setValue(entry.token)\">\n <strong>{{ entry.key }}</strong>: {{ entry.formattedValue }} <code>({{ entry.token }})</code>\n </button>\n }\n </div>\n }\n </div>\n }\n </section>\n\n <section class=\"card\" data-testid=\"page-state-component-binder\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.binderTitle', 'Connect State or Measure to Widget') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.state.binderCopy', 'Bind any state path or derived measure directly to a widget input in the draft.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderToken', 'State token') }}</mat-label>\n <input matInput [formControl]=\"binderTokenControl\" placeholder=\"{{ '{{state.kpis.total}}' }}\" data-testid=\"binder-token\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderWidget', 'Target widget') }}</mat-label>\n <mat-select [formControl]=\"binderWidgetControl\" data-testid=\"binder-widget\">\n @for (w of bindablePageWidgets(); track w.key) {\n <mat-option [value]=\"w.key\">{{ w.title }} ({{ w.key }})</mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderInputPath', 'Widget input path') }}</mat-label>\n <input matInput [formControl]=\"binderInputPathControl\" placeholder=\"value\" data-testid=\"binder-input-path\" />\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" data-testid=\"binder-bind-btn\" (click)=\"bindStateTokenToWidget()\">\n <mat-icon aria-hidden=\"true\">link</mat-icon>{{ tx('editor.state.bindAction', 'Bind token to widget in draft') }}\n </button>\n <button mat-stroked-button type=\"button\" data-testid=\"binder-auto-bind-btn\" (click)=\"autoBindWidgetStatePlaceholders()\">\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>{{ tx('editor.state.autoBindAction', 'Auto-bind ${...} widget placeholders') }}\n </button>\n </div>\n @if (binderMessage()) {\n <p class=\"studio-status\" role=\"status\" data-testid=\"binder-status-message\">{{ binderMessage() }}</p>\n }\n @if (currentWidgetStateBindings().length) {\n <ul class=\"blocked-widgets-list\" data-testid=\"active-widget-state-bindings\">\n @for (binding of currentWidgetStateBindings(); track binding.widgetKey + ':' + binding.inputPath) {\n <li>\n <strong>{{ binding.widgetTitle }}</strong>.<code>{{ binding.inputPath }}</code> \u2190 <code>{{ binding.token }}</code>\n <button mat-icon-button type=\"button\" [attr.data-testid]=\"'binder-unbind-' + binding.widgetKey + '-' + binding.inputPath\"\n [attr.aria-label]=\"tx('editor.state.unbindAction', 'Remove binding')\"\n (click)=\"unbindStateTokenFromWidget(binding.widgetKey, binding.inputPath)\">\n <mat-icon aria-hidden=\"true\">link_off</mat-icon>\n </button>\n </li>\n }\n </ul>\n }\n </section>\n\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.dataSourcesJson', 'page.dataSources (JSON)') }}</mat-label>\n <textarea matInput rows=\"8\" [formControl]=\"dataSourcesControl\" [placeholder]=\"tx('editor.hints.dataSourcesExample', '')\" data-testid=\"page-datasources-json\"></textarea>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab [label]=\"tx('editor.limits.tab', 'Widget limits')\">\n <section class=\"limits-panel\" data-testid=\"canvas-limits-editor\">\n @if (workspaceTab() === 3 && activeTab() === 'limits' && canvasLimits.targets.length) {\n <div class=\"selection-identity\" tabindex=\"-1\" data-testid=\"canvas-limits-identity\">\n <mat-icon aria-hidden=\"true\">dashboard_customize</mat-icon>\n <strong>{{ limitsTargetLabel(limitsTargetControl.value) }}</strong>\n </div>\n }\n\n <p class=\"hint\" id=\"canvas-limits-help\">{{ tx('editor.limits.help', 'Limits use grid columns and rows, not pixels. Leave blank for defaults, or to inherit the base limits on a device.') }}</p>\n @if (canvasLimits.targets.length) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.target', 'Widget and layout') }}</mat-label>\n <mat-select [formControl]=\"limitsTargetControl\" data-testid=\"canvas-limits-target\">\n @for (target of canvasLimits.targets; track target.scope + ':' + target.key; let index = $index) {\n <mat-option [value]=\"index\">{{ limitsTargetLabel(index) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n <p class=\"hint\">{{ limitsGeometryLabel() }}</p>\n @if (canvasLimits.form.controls[limitsTargetControl.value]; as limitsForm) {\n <div class=\"limits-fields\" aria-describedby=\"canvas-limits-help\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.minColSpan', 'Minimum width (columns)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.minColSpan\" data-testid=\"canvas-limit-minColSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minColSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.maxColSpan', 'Maximum width (columns)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.maxColSpan\" data-testid=\"canvas-limit-maxColSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxColSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.minRowSpan', 'Minimum height (rows)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.minRowSpan\" data-testid=\"canvas-limit-minRowSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minRowSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.maxRowSpan', 'Maximum height (rows)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.maxRowSpan\" data-testid=\"canvas-limit-maxRowSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxRowSpan) }}</mat-error>\n </mat-form-field>\n </div>\n @if (limitsUseContentHeight()) {\n <p class=\"limits-notice\"><mat-icon aria-hidden=\"true\">height</mat-icon><span>{{ tx('editor.limits.autoHeight', 'Choose natural height or an adjustable height with internal scrolling.') }}</span></p>\n <mat-button-toggle-group class=\"height-modes\" [formControl]=\"limitsForm.controls.heightMode\" [attr.aria-label]=\"tx('editor.height.mode', 'Widget height')\" data-testid=\"canvas-height-mode\">\n <mat-button-toggle value=\"inherit\">{{ tx('editor.height.inherit', 'Default') }}</mat-button-toggle>\n <mat-button-toggle value=\"auto\">{{ tx('editor.height.auto', 'Automatic') }}</mat-button-toggle>\n <mat-button-toggle value=\"fixed\">{{ tx('editor.height.fixed', 'Adjustable') }}</mat-button-toggle>\n </mat-button-toggle-group>\n @if (limitsForm.controls.heightMode.value === 'fixed') {\n <div class=\"limits-fields\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.value', 'Height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.heightPx\" data-testid=\"canvas-height-px\">\n <mat-error>{{ limitFieldError(limitsForm.controls.heightPx) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.top', 'Top inset (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"0\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.offsetTopPx\" data-testid=\"canvas-height-top\">\n <mat-error>{{ limitFieldError(limitsForm.controls.offsetTopPx) }}</mat-error>\n </mat-form-field>\n </div>\n }\n <div class=\"limits-fields\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.min', 'Minimum height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.minHeightPx\" data-testid=\"canvas-limit-minHeightPx\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minHeightPx) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.max', 'Maximum height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.maxHeightPx\" data-testid=\"canvas-limit-maxHeightPx\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxHeightPx) }}</mat-error>\n </mat-form-field>\n </div>\n }\n }\n @if (limitsSummaryError) { <p class=\"limits-error\" role=\"alert\">{{ limitsSummaryError }}</p> }\n } @else {\n <p>{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>\n }\n </section>\n </mat-tab>\n </mat-tab-group></div>\n </mat-tab>\n </mat-tab-group>\n <details class=\"summary-details\">\n <summary>{{ tx('editor.summaryAria', 'Configuration summary') }}</summary>\n <div class=\"summary\">\n @if (!isHostedInSettingsPanel) {\n <span>{{ validationStateLabel }}</span>\n <span>{{ dirtyStateLabel }}</span>\n }\n <span>{{ selectedLayoutPresetLabel }}</span>\n <span>{{ selectedThemePresetLabel }}</span>\n <span>{{ canvasSummaryLabel }}</span>\n </div>\n </details>\n </div>\n", styles: [".page-inset-field{grid-column:1/-1;min-width:0}: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{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{align-items:stretch}.head>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)}.limits-blocking-banner{border-color:var(--md-sys-color-error, #b3261e);gap:8px}.blocked-widgets-list{margin:0;padding-inline-start:20px;display:grid;gap:4px}.previous-heights-summary{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font:var(--mat-sys-body-small, inherit);color:var(--md-sys-color-on-surface-variant, currentColor)}.previous-height-chip{padding:2px 8px;border-radius:999px;background:var(--md-sys-color-surface-container-highest, rgba(128, 128, 128, .14))}.wide{grid-column:1/-1}.hint{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px}.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}:host{container-type:inline-size}.studio-panel{display:grid;gap:var(--p-space-4, 16px);padding-block:var(--p-space-5, 20px);min-width:0}.studio-heading h2{font-size:var(--p-font-size-lg, 20px);line-height:1.25;margin:var(--p-space-2, 8px) 0;font-weight:600}.studio-heading p{margin:0;color:var(--md-sys-color-on-surface-variant);line-height:1.5}.studio-panel h3{font-size:var(--p-font-size-md, 16px);margin:0}.studio-panel>button{justify-self:start}.studio-gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--p-space-3, 12px)}.studio-choice{display:flex;flex-direction:column;gap:var(--p-space-2, 8px);min-width:0;padding:var(--p-space-3, 12px);text-align:start;border:1px solid var(--md-sys-color-outline-variant);border-radius:var(--p-radius-lg, 12px);background:var(--md-sys-color-surface);color:var(--md-sys-color-on-surface);font:inherit;cursor:pointer;transition:border-color var(--md-sys-motion-duration-short2, .1s),background-color var(--md-sys-motion-duration-short2, .1s)}.studio-choice:hover:not(:disabled){border-color:var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low)}.studio-choice.selected{border-color:var(--md-sys-color-primary);box-shadow:inset 0 0 0 1px var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low)}.studio-choice:focus-visible,.studio-panel summary:focus-visible{outline:3px solid var(--md-sys-color-primary);outline-offset:3px}.studio-choice:disabled{opacity:var(--p-disabled-opacity, .5);cursor:not-allowed}.choice-title{display:flex;align-items:center;justify-content:space-between;gap:var(--p-space-2, 8px);width:100%;font-weight:600;line-height:1.35}.choice-title mat-icon{color:var(--md-sys-color-primary);flex-shrink:0;font-size:20px;height:20px;width:20px}.choice-description{font-size:var(--p-font-size-sm, 13px);line-height:1.5;color:var(--md-sys-color-on-surface-variant)}.choice-meta{font-size:var(--p-font-size-xs, 12px);color:var(--md-sys-color-on-surface-variant);margin-top:auto;padding-top:var(--p-space-1, 4px)}.theme-sample{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));padding:var(--p-space-3, 12px);border-radius:var(--p-radius-md, 8px);min-height:128px;align-self:stretch;margin-bottom:var(--p-space-1, 4px)}.sample-metric,.sample-chart{background:var(--sample-surface);border-radius:var(--sample-radius);box-shadow:var(--sample-shadow);padding:var(--p-space-2, 8px)}.sample-metric b{display:block;font-size:var(--p-font-size-md, 16px)}.sample-metric i{display:block;height:3px;width:55%;margin-top:var(--p-space-1, 4px);background:var(--sample-accent)}.sample-chart{grid-column:1/-1;display:flex;height:40px;align-items:flex-end;gap:var(--p-space-2, 8px)}.sample-chart i{flex:1;background:var(--sample-accent);height:50%;border-radius:var(--p-radius-xs, 2px)}.sample-chart i:nth-child(2){height:85%}.sample-chart i:nth-child(3){height:65%}.sample-chart i:nth-child(4){height:100%}.sample-chart i:nth-child(5){height:75%}.shell-options,.arrangement-rules{border-top:1px solid var(--md-sys-color-outline-variant);padding-top:var(--p-space-3, 12px)}.studio-panel summary{cursor:pointer;font-weight:500;line-height:1.5;padding-block:var(--p-space-2, 8px)}.shell-gallery{margin-top:var(--p-space-4, 16px)}.shell-sample{display:block;align-self:stretch;border:1px solid;overflow:hidden;margin:var(--p-space-2, 8px)}.shell-sample>span{display:flex;gap:var(--p-space-2, 8px);align-items:center;padding:var(--p-space-2, 8px);border-bottom:1px solid transparent}.shell-sample i{height:4px;width:60%;background:var(--md-sys-color-on-surface-variant);opacity:.4}.shell-sample>span:last-child{min-height:30px;flex-direction:column;align-items:flex-start}.shell-sample>span:last-child i:last-child{width:40%}.selection-line{margin:0;font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-on-surface-variant)}.studio-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--p-space-2, 8px)}.distribution-sample{display:grid;grid-template-rows:repeat(2,1fr);gap:var(--p-space-1, 4px);align-self:stretch;height:76px;padding:var(--p-space-2, 8px);background:var(--md-sys-color-surface-container);border-radius:var(--p-radius-md, 8px)}.distribution-sample i{display:grid;place-items:center;background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container);border-radius:var(--p-radius-sm, 4px);font-size:var(--p-font-size-xs, 12px);font-style:normal}.distribution-sample i:nth-child(n+4){opacity:.65}.selected .distribution-sample i{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.arrangement-review{display:grid;gap:var(--p-space-3, 12px);padding-block:var(--p-space-4, 16px);border-top:2px solid var(--md-sys-color-primary)}.review-heading{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:var(--p-space-2, 8px)}.review-heading>span{font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-primary)}.canvas-comparison{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--p-space-3, 12px)}.canvas-comparison figure{margin:0;min-width:0}.canvas-comparison figcaption{font-weight:500;margin-bottom:var(--p-space-2, 8px)}.canvas-map{display:grid;gap:2px;height:220px;padding:var(--p-space-2, 8px);border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container-low);border-radius:var(--p-radius-md, 8px)}.canvas-map span{display:grid;place-items:center;min-height:0;min-width:0;overflow:hidden;font-size:var(--p-font-size-xs, 12px);color:var(--md-sys-color-on-secondary-container);background:var(--md-sys-color-secondary-container);border-radius:var(--p-radius-xs, 2px)}.canvas-map .changed{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.widget-changes{padding-inline-start:var(--p-space-5, 20px);margin:0}.widget-changes li{padding-block:var(--p-space-2, 8px);overflow-wrap:anywhere}.widget-changes span{display:block;font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-on-surface-variant)}.studio-status{border-inline-start:3px solid var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low);padding:var(--p-space-3, 12px);margin:0;line-height:1.5}@container (max-width:420px){.studio-gallery{grid-template-columns:minmax(0,1fr)}.canvas-map{height:180px}}@media(prefers-reduced-motion:reduce){.studio-choice{transition:none}}.arrangement-feedback:empty{display:none}.arrangement-feedback:focus{outline:none}.arrangement-feedback:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:3px}@container (min-width:620px){.arrangement-gallery{grid-template-columns:repeat(3,minmax(0,1fr))}}:host ::ng-deep .studio-tabs>.mat-mdc-tab-header{position:sticky;top:0;z-index:var(--praxis-layer-sticky-local, 100);background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface))}:host ::ng-deep .studio-tabs>.mat-mdc-tab-header .mdc-tab{flex:1 1 0;padding-inline:var(--p-space-1, 4px)}.arrangement-feedback{scroll-margin-block-start:var(--p-space-16, 64px)}.filter-owner-banner{border-color:var(--md-sys-color-tertiary, var(--md-sys-color-primary))}.filter-owner-item{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px}.quick-geometry-rows{display:grid;gap:8px}.quick-geometry-row{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px;padding-block:4px;border-bottom:1px solid var(--md-sys-color-outline-variant, rgba(128, 128, 128, .2))}.guided-presets-bar{display:grid;gap:6px}.datasource-preview-box{display:grid;gap:8px;padding:12px;border-radius:6px;background:var(--md-sys-color-surface-container, rgba(128, 128, 128, .08))}.datasource-preview-json{margin:0;padding:8px;border-radius:4px;font-size:12px;overflow-x:auto;background:var(--md-sys-color-surface, rgba(0, 0, 0, .04))}\n"], dependencies: [{ kind: "component", type: PagePresetComparisonComponent, selector: "praxis-page-preset-comparison", inputs: ["expanded", "layoutId", "themeId", "layoutPresetOptions", "canvas"], outputs: ["choose"] }, { 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: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", 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.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i4.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { 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: i9.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: i9.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "directive", type: i8.MatTabContent, selector: "[matTabContent]" }, { 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: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
17647
|
+
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()], viewQueries: [{ propertyName: "arrangementFeedback", first: true, predicate: ["arrangementFeedback"], descendants: true }], ngImport: i0, template: "<div class=\"editor\">\n @if (!isHostedInSettingsPanel) { <div class=\"head\">\n <div>{{ tx('editor.title', 'Page settings') }}</div>\n <button mat-stroked-button type=\"button\" (click)=\"reset()\">{{ tx('editor.reload', 'Reload') }}</button>\n </div> }\n\n @if (nonLimitError) { <p class=\"limits-error\" role=\"alert\">{{ nonLimitError }}</p> }\n @if (limitsError() || previousAdjustableHeights().length) {\n <section class=\"card limits-blocking-banner\" role=\"region\" [attr.aria-label]=\"tx('editor.limits.blockedBannerTitle', 'Height geometry requires review in Widget limits')\" data-testid=\"preset-geometry-blocking-banner\">\n @if (limitsError()) {\n <div class=\"head\">\n <strong>{{ tx('editor.limits.blockedBannerTitle', 'Height geometry requires review in Widget limits') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.limits.blockedBannerHelp', 'One or more widgets use an adjustable pixel height that exceeds the current usable area or requires review before applying the page or template:') }}</p>\n <ul class=\"blocked-widgets-list\">\n @for (item of blockedGeometryWidgets; track item.scope + ':' + item.key) {\n <li>\n <strong>{{ item.title }}</strong>\n @if (item.title !== item.key && !item.title.endsWith('(' + item.key + ')')) { <code>({{ item.key }})</code> }\n <span> \u00B7 {{ item.scopeLabel }}</span>\n @if (item.heightPx !== null) {\n <span> \u00B7 {{ tx('editor.height.fixed', 'Adjustable') }}: <strong>{{ item.heightPx }}px</strong></span>\n }\n </li>\n }\n </ul>\n <p class=\"limits-error\" role=\"alert\">{{ limitsError() }}</p>\n <div class=\"head\">\n <button mat-stroked-button type=\"button\" data-testid=\"goto-blocked-widget-limits\" (click)=\"navigateToBlockedLimit()\">\n <mat-icon aria-hidden=\"true\">tune</mat-icon>{{ tx('editor.limits.gotoLimitsAction', 'Go to Adjustments \u2192 Widget limits') }}\n </button>\n <button mat-flat-button type=\"button\" data-testid=\"switch-blocked-widgets-auto-height\" (click)=\"switchBlockedWidgetsToAutoHeight()\">\n <mat-icon aria-hidden=\"true\">height</mat-icon>{{ tx('editor.limits.switchToAutoHeightAction', 'Switch affected widgets to Automatic height') }}\n </button>\n </div>\n }\n @if (previousAdjustableHeights().length) {\n <div class=\"previous-heights-summary\" data-testid=\"previous-adjustable-heights-summary\">\n <span>{{ tx('editor.limits.previousHeightsSummary', 'Previous adjustable heights preserved for reference:') }}</span>\n @for (prev of previousAdjustableHeights(); track prev.scope + ':' + prev.key) {\n <span class=\"previous-height-chip\">{{ prev.title }} ({{ prev.scopeLabel }}): <strong>{{ prev.heightPx }}px</strong></span>\n }\n </div>\n }\n </section>\n }\n <mat-tab-group class=\"editor-tabs studio-tabs\" [mat-stretch-tabs]=\"false\" [selectedIndex]=\"workspaceTab()\" (selectedIndexChange)=\"workspaceTab.set($event)\">\n <mat-tab [label]=\"tx('studio.visualTab', 'Visual templates')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"studio-visual\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.visualEyebrow', 'Appearance') }}</span><h2>{{ tx('studio.visualTitle', 'Give the page a visual language') }}</h2><p>{{ tx('studio.visualHelp', 'Choose surfaces, density and chart styling together. Individual widget overrides still take precedence.') }}</p></header>\n <button mat-stroked-button type=\"button\" [attr.aria-pressed]=\"!themePresetControl.value\" (click)=\"themePresetControl.setValue('')\">{{ tx('editor.layout.layoutDefault', 'Layout default') }}</button>\n <p class=\"selection-line\">{{ tx('presets.effectiveTheme', 'Effective page theme') }}: <strong>{{ selectedThemePresetEntry?.label || themePresetControl.value || recommendedThemePresetEntry?.label || tx('presets.hostTheme', 'Host theme') }}</strong></p>\n <div class=\"studio-gallery\">\n @for (theme of themePresetEntries; track theme.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"themePresetControl.value === theme.id\" [attr.aria-pressed]=\"themePresetControl.value === theme.id\" [attr.data-theme-preset]=\"theme.id\" (click)=\"themePresetControl.setValue(theme.id)\">\n <span class=\"theme-sample\" aria-hidden=\"true\" [style.background]=\"themeTokens(theme.id)['--pdx-page-bg']\" [style.--sample-accent]=\"themeTokens(theme.id)['--pdx-page-accent']\" [style.--sample-surface]=\"themeTokens(theme.id)['--pdx-page-surface']\" [style.--sample-radius]=\"themeTokens(theme.id)['--pdx-page-card-radius']\" [style.--sample-shadow]=\"themeTokens(theme.id)['--pdx-page-card-shadow']\" [style.gap]=\"themeTokens(theme.id)['--pdx-page-gap']\">\n <span class=\"sample-metric\"><b>128</b><i></i></span><span class=\"sample-metric\"><b>86%</b><i></i></span><span class=\"sample-chart\"><i></i><i></i><i></i><i></i><i></i></span>\n </span>\n <span class=\"choice-title\">{{ theme.label }}@if (themePresetControl.value === theme.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span>\n <span class=\"choice-description\">{{ tx('studio.theme.' + theme.id, theme.label) }}</span>\n <span class=\"choice-meta\">{{ tx('studio.density.' + theme.density, theme.density) }} \u00B7 {{ shellName(theme.shellPreset) }}</span>\n </button>\n }\n </div>\n <p class=\"hint\">{{ tx('studio.samplesHelp', 'Illustrative samples using the preset tokens. Apply to see the result with your content.') }}</p>\n <details class=\"shell-options\"><summary>{{ tx('studio.shellTitle', 'Customize widget surfaces') }} \u00B7 {{ shellPresetEntries.length }}</summary>\n <p>{{ tx('studio.shellHelp', 'Override the page theme\u2019s default widget surface. Per-widget appearance settings keep their priority.') }}</p>\n <button mat-stroked-button type=\"button\" [attr.aria-pressed]=\"!shellPresetControl.value\" (click)=\"shellPresetControl.setValue('')\">{{ tx('editor.layout.inheritShell', 'Inherit appearance') }}</button>\n <div class=\"studio-gallery shell-gallery\">@for (shell of shellPresetEntries; track shell.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"shellPresetControl.value === shell.id\" [attr.aria-pressed]=\"shellPresetControl.value === shell.id\" (click)=\"shellPresetControl.setValue(shell.id)\">\n <span class=\"shell-sample\" aria-hidden=\"true\" [style.background]=\"shell.appearance.card?.background\" [style.border-color]=\"shell.appearance.card?.borderColor\" [style.border-radius]=\"shell.appearance.card?.borderRadius\" [style.box-shadow]=\"shell.appearance.card?.shadow\"><span [style.background]=\"shell.appearance.header?.background\" [style.border-color]=\"shell.appearance.header?.borderColor\"><mat-icon>analytics</mat-icon><i></i></span><span [style.background]=\"shell.appearance.body?.background\"><i></i><i></i></span></span>\n <span class=\"choice-title\">{{ shellPresetLabel(shell) }}@if (shellPresetControl.value === shell.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span><span class=\"choice-description\">{{ shellDescription(shell) }}</span>\n </button>\n }</div>\n </details>\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.layoutTab', 'Layout templates')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"studio-layout\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.layoutEyebrow', 'Composition model') }}</span><h2>{{ tx('studio.layoutTitle', 'Choose the structure for your use case') }}</h2><p>{{ tx('studio.layoutHelp', 'Compare the roles and default theme of each model. To redistribute the widgets already on this canvas, open Arrange.') }}</p></header>\n @if (duplicateFilterOwners().length) {\n <section class=\"card filter-owner-banner\" role=\"region\" [attr.aria-label]=\"tx('studio.filterOwnerBannerTitle', 'Duplicate filter region detected')\" data-testid=\"duplicate-filter-owner-banner\">\n <div class=\"head\">\n <strong>{{ tx('studio.filterOwnerBannerTitle', 'Single filter region recommendation') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('studio.filterOwnerBannerHelp', 'A table has both its integrated filter bar and an external filter link active. Choose a single query owner to avoid competing filter regions:') }}</p>\n @for (item of duplicateFilterOwners(); track item.tableKey) {\n <div class=\"filter-owner-item\" [attr.data-testid]=\"'duplicate-filter-owner-' + item.tableKey\">\n <span><strong>{{ item.tableTitle }}</strong> <code>({{ item.tableKey }})</code> \u2190 {{ item.externalSourceLabels.join(', ') }}</span>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'consolidate-filter-table-toolbar-' + item.tableKey\" (click)=\"consolidateFilterOwnership(item.tableKey, 'keep-table-toolbar')\">\n {{ tx('studio.keepTableToolbarFilter', 'Use integrated table filter') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'consolidate-filter-external-link-' + item.tableKey\" (click)=\"consolidateFilterOwnership(item.tableKey, 'keep-external-link')\">\n {{ tx('studio.keepExternalFilterLink', 'Use external filter bar') }}\n </button>\n </div>\n </div>\n }\n </section>\n }\n <details class=\"card composer-card\" data-testid=\"collection-screen-composer\">\n <summary>{{ tx('studio.composer.title', 'Guided collection screen composer (5-minute flow)') }}</summary>\n <p class=\"hint\">{{ tx('studio.composer.help', 'Assemble a complete collection screen with header, KPI indicators, single filter region, primary collection, and optional detail surface in the draft.') }}</p>\n <div class=\"grid\">\n <praxis-resource-quick-connect\n [formControl]=\"composerResourcePathControl\"\n intent=\"collection\"\n [label]=\"tx('studio.composer.resourcePath', 'Resource path')\"\n (resourceSelected)=\"onComposerResourceSelected($event)\"\n data-testid=\"composer-resource-path\"\n />\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.screenTitle', 'Screen title') }}</mat-label>\n <input matInput [formControl]=\"composerTitleControl\" data-testid=\"composer-title\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.groupField', 'KPI group-by field') }}</mat-label>\n <input\n matInput\n [formControl]=\"composerGroupFieldControl\"\n [matAutocomplete]=\"composerGroupFieldPicker\"\n data-testid=\"composer-group-field\"\n />\n <mat-autocomplete\n #composerGroupFieldPicker=\"matAutocomplete\"\n (optionSelected)=\"composerGroupFieldControl.markAsPristine()\"\n >\n @for (field of filteredComposerGroupFieldOptions(); track field.name) {\n <mat-option [value]=\"field.name\" [attr.data-testid]=\"'composer-group-field-option-' + field.name\">\n <div class=\"composer-rich-field-option\">\n <div class=\"composer-rich-field-option__header\">\n <span class=\"composer-rich-field-option__label\">{{ field.label || field.name }}</span>\n @if (field.label && field.label !== field.name) {\n <code class=\"composer-rich-field-option__code\">{{ field.name }}</code>\n }\n @if (getComposerFieldTypeBadge(field); as badge) {\n <span class=\"composer-rich-field-option__badge\">{{ badge }}</span>\n }\n </div>\n @if (getComposerFieldDescription(field); as desc) {\n <span class=\"composer-rich-field-option__desc\">{{ desc }}</span>\n }\n </div>\n </mat-option>\n }\n </mat-autocomplete>\n @if (composerSelectedResource()?.capabilities?.stats === false) {\n <mat-hint data-testid=\"composer-stats-unsupported-hint\">{{ tx('studio.composer.statsUnsupportedHint', 'This resource does not publish stats/group-by aggregation; summary indicators were disabled automatically.') }}</mat-hint>\n } @else {\n <mat-hint>{{ tx('studio.composer.groupFieldHint', 'Choose the boolean or categorical field used in summary indicators.') }}</mat-hint>\n }\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.filterMode', 'Query filter owner') }}</mat-label>\n <mat-select [formControl]=\"composerFilterModeControl\" data-testid=\"composer-filter-mode\">\n <mat-option value=\"table-integrated\">{{ tx('studio.composer.filterIntegrated', 'Integrated table toolbar') }}</mat-option>\n <mat-option value=\"external-filter-bar\">{{ tx('studio.composer.filterExternal', 'Dedicated filter bar widget') }}</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.collectionKind', 'Primary collection') }}</mat-label>\n <mat-select [formControl]=\"composerCollectionKindControl\" data-testid=\"composer-collection-kind\">\n <mat-option value=\"table\">praxis-Table</mat-option>\n <mat-option value=\"list\">praxis-List</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.detailSurface', 'Secondary detail surface') }}</mat-label>\n <mat-select [formControl]=\"composerDetailSurfaceControl\" data-testid=\"composer-detail-surface\">\n <mat-option value=\"drawer\">Drawer</mat-option>\n <mat-option value=\"modal\">Modal</mat-option>\n <mat-option value=\"none\">{{ tx('studio.composer.noSurface', 'None') }}</mat-option>\n </mat-select>\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-flat-button type=\"button\" data-testid=\"composer-generate-btn\" (click)=\"composeCollectionScreen()\">\n <mat-icon aria-hidden=\"true\">auto_awesome</mat-icon>{{ tx('studio.composer.generate', 'Generate collection screen in draft') }}\n </button>\n </div>\n @if (composerMessage()) {\n <p class=\"studio-status\" role=\"status\" data-testid=\"composer-status-message\">{{ composerMessage() }}</p>\n }\n </details>\n <div class=\"studio-actions\"><button mat-stroked-button type=\"button\" (click)=\"layoutPresetControl.setValue('')\">{{ tx('studio.inheritLayout', 'Use inherited model') }}</button><button mat-button type=\"button\" (click)=\"workspaceTab.set(2)\">{{ tx('studio.goArrange', 'Arrange existing widgets') }}<mat-icon iconPositionEnd>arrow_forward</mat-icon></button></div>\n <praxis-page-preset-comparison [expanded]=\"true\" [layoutId]=\"layoutPresetControl.value\" [layoutPresetOptions]=\"layoutPresetOptions\" [themeId]=\"themePresetControl.value\" [canvas]=\"true\" (choose)=\"layoutPresetControl.setValue($event)\" />\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.arrangeTab', 'Arrange')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"canvas-arrangement\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.arrangeEyebrow', 'Existing widgets') }} \u00B7 {{ canvasColumnsControl.value }} {{ tx('editor.layout.cols', 'Columns') }}</span><h2>{{ tx('studio.arrangeTitle', 'Find the right balance of space') }}</h2><p>{{ tx('studio.arrangeHelp', 'Choose a distribution, compare the real positions, then use it in the draft. Widgets keep their reading order and content.') }}</p></header>\n <div #arrangementFeedback tabindex=\"-1\" class=\"arrangement-feedback\">\n @if (arrangementPreview(); as preview) {\n <section class=\"arrangement-review\" aria-live=\"polite\" data-testid=\"arrangement-preview\">\n <div class=\"review-heading\"><h3>{{ tx('studio.reviewTitle', 'Compare before using') }}</h3><span>{{ preview.keys.length }} {{ tx('studio.changedWidgets', 'widgets change') }}</span></div>\n <div class=\"canvas-comparison\">\n @for (side of ['before', 'after']; track side) {\n <figure><figcaption>{{ side === 'before' ? tx('studio.before', 'Before') : tx('studio.after', 'After') }}</figcaption>\n <div class=\"canvas-map\" [style.grid-template-columns]=\"'repeat(' + preview.canvas.columns + ', minmax(0, 1fr))'\" [style.grid-template-rows]=\"'repeat(' + previewRowCount(preview.before, preview.canvas) + ', minmax(0, 1fr))'\" role=\"img\" [attr.aria-label]=\"side === 'before' ? tx('studio.before', 'Before') : tx('studio.after', 'After')\">\n @for (item of previewItems(side === 'before' ? preview.before : preview.canvas); track item.key) {\n <span [style.grid-column]=\"item.col + ' / span ' + item.colSpan\" [style.grid-row]=\"item.row + ' / span ' + item.rowSpan\" [class.changed]=\"preview.keys.includes(item.key)\" [matTooltip]=\"item.label\">{{ item.number }}</span>\n }\n </div>\n </figure>\n }\n </div>\n <p class=\"hint\">{{ tx('studio.mapHelp', 'Numbered map of your widgets. Positions and spans are to scale; content height is evaluated on the page.') }}</p>\n <details><summary>{{ tx('studio.widgetDetails', 'Widget positions and sizes') }}</summary><ol class=\"widget-changes\">@for (item of previewItems(preview.canvas); track item.key) {<li><strong>{{ item.label }}</strong><span>{{ tx('studio.before', 'Before') }}: {{ tx('editor.arrange.position', 'Column / row') }} {{ preview.before.items[item.key].col }} / {{ preview.before.items[item.key].row }} \u00B7 {{ tx('studio.gridSize', 'Size in columns \u00D7 rows') }} {{ preview.before.items[item.key].colSpan }} \u00D7 {{ preview.before.items[item.key].rowSpan }}</span><span>{{ tx('studio.after', 'After') }}: {{ tx('editor.arrange.position', 'Column / row') }} {{ item.col }} / {{ item.row }} \u00B7 {{ tx('studio.gridSize', 'Size in columns \u00D7 rows') }} {{ item.colSpan }} \u00D7 {{ item.rowSpan }}</span></li>}</ol></details>\n <button mat-flat-button type=\"button\" data-testid=\"arrangement-use\" (click)=\"useArrangement()\">{{ tx('editor.arrange.use', 'Use arrangement in draft') }}<mat-icon iconPositionEnd>arrow_forward</mat-icon></button>\n </section>\n }\n @if (arrangementMessage()) {<p class=\"studio-status\" role=\"status\">{{ arrangementMessage() }}</p>}\n @if (arrangementUndoAvailable()) {<button mat-stroked-button type=\"button\" data-testid=\"arrangement-undo\" (click)=\"undoArrangement()\"><mat-icon>undo</mat-icon>{{ tx('editor.arrange.undo', 'Undo arrangement') }}</button>}\n </div>\n <div class=\"studio-gallery arrangement-gallery\">\n @for (choice of arrangementChoices; track choice.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"arrangementSelection() === choice.id\" [attr.aria-pressed]=\"arrangementSelection() === choice.id\" [disabled]=\"!hasCanvasWidgets || !arrangementFits(choice.id)\" [attr.data-testid]=\"'arrange-' + choice.id\" (click)=\"prepareArrangement(choice.id)\">\n <span class=\"distribution-sample\" aria-hidden=\"true\" [style.grid-template-columns]=\"(arrangementFits(choice.id) ? arrangementWidths(choice.id) : choice.weights).join('fr ') + 'fr'\">@for (weight of choice.weights; track $index) {<i>{{ arrangementWidths(choice.id)[$index] }}</i>}@for (weight of choice.weights; track $index) {<i></i>}</span>\n <span class=\"choice-title\">{{ tx('editor.arrange.' + choice.id, choice.id) }}@if (arrangementSelection() === choice.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span>\n <span class=\"choice-description\">{{ tx('studio.arrange.' + choice.id, choice.id) }}</span>\n <span class=\"choice-meta\">{{ arrangementFits(choice.id) ? arrangementWidths(choice.id).join(' + ') : tx('studio.moreColumns', 'Needs more grid columns') }}</span>\n </button>\n }\n </div>\n @if (hasCanvasWidgets) {\n <details class=\"card quick-geometry-card\" data-testid=\"quick-widget-geometry-list\">\n <summary>{{ tx('studio.quickGeometryTitle', 'Quick widget spans (columns \u00D7 rows)') }}</summary>\n <div class=\"quick-geometry-rows\">\n @for (w of quickCanvasWidgets(); track w.key) {\n <div class=\"quick-geometry-row\" [attr.data-testid]=\"'quick-geometry-row-' + w.key\">\n <span class=\"quick-geometry-label\"><mat-icon aria-hidden=\"true\">{{ w.icon }}</mat-icon><strong>{{ w.label }}</strong> <small>({{ w.colSpan }}\u00D7{{ w.rowSpan }})</small></span>\n <div class=\"studio-actions\">\n @for (span of [3, 4, 6, 12]; track span) {\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'quick-colspan-' + w.key + '-' + span\" (click)=\"setWidgetCanvasSpan(w.key, span)\">{{ span }}c</button>\n }\n @for (rSpan of [1, 2, 4]; track rSpan) {\n <button mat-button type=\"button\" [attr.data-testid]=\"'quick-rowspan-' + w.key + '-' + rSpan\" (click)=\"setWidgetCanvasSpan(w.key, w.colSpan, rSpan)\">{{ rSpan }}r</button>\n }\n </div>\n </div>\n }\n </div>\n </details>\n }\n @if (!hasCanvasWidgets) {<p role=\"status\">{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>}\n <details class=\"arrangement-rules\"><summary>{{ tx('studio.rules', 'Scope, devices and locked widgets') }}</summary><p>{{ tx('editor.arrange.help', 'Base canvas, in current reading order. Inheriting devices follow the base; explicit device positions are preserved. Locked positions stay in place.') }}</p><button mat-button type=\"button\" (click)=\"onSelectedTabIndexChange(5)\">{{ tx('studio.goLimits', 'Review widget limits') }}</button></details>\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.settingsTab', 'Adjustments')\">\n <div class=\"studio-panel\"><mat-tab-group class=\"editor-tabs\" [mat-stretch-tabs]=\"false\" [selectedIndex]=\"selectedTabIndex\" (selectedIndexChange)=\"selectDetailTab($event)\">\n <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 class=\"page-inset-field\" appearance=\"outline\"><mat-label>{{ tx('editor.layout.paddingInline', 'Page side inset') }}</mat-label><input matInput [formControl]=\"paddingInlineControl\" placeholder=\"24px\" /><button praxisIconButton=\"help_outline\" size=\"dense\" matSuffix type=\"button\" [matTooltip]=\"tx('editor.hints.paddingInline', 'Shared space beside all page widgets. Use a CSS length or theme token; 0px removes it. Leave empty to inherit the host theme.')\" [attr.aria-label]=\"tx('editor.layout.paddingInline', 'Page side inset')\"></button></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></div></mat-tab>\n <mat-tab [label]=\"tx('editor.tabs.grouping', 'Grouping')\"><div class=\"panel\">\n <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 {@if (bindablePageWidgets().length > 0) {<mat-form-field appearance=\"outline\" class=\"wide\"><mat-label>{{ tx('editor.grouping.widgets', 'Widgets in group') }}</mat-label><mat-select multiple [value]=\"groupingSelectedWidgetKeys($index)\" (selectionChange)=\"onGroupingWidgetSelectionChange($index, $event.value)\" [attr.data-testid]=\"'grouping-widget-select-' + $index\" panelClass=\"praxis-binder-widget-select-panel\"><mat-select-trigger><span class=\"binder-widget-trigger\"><mat-icon aria-hidden=\"true\" class=\"binder-widget-trigger-icon\">{{ groupingSelectedWidgetIcon($index) }}</mat-icon><span class=\"binder-widget-trigger-text\">{{ groupingSelectedWidgetsLabel($index) }}</span></span></mat-select-trigger>@for (w of bindablePageWidgets(); track w.key) {<mat-option [value]=\"w.key\" [attr.data-widget-key]=\"w.key\" class=\"binder-widget-option\"><span class=\"binder-widget-option-row\"><mat-icon class=\"binder-widget-option-icon\" aria-hidden=\"true\">{{ w.icon }}</mat-icon><span class=\"binder-widget-option-copy\"><strong class=\"binder-widget-option-title\">{{ w.title }}</strong>@if (w.description) {<small class=\"binder-widget-option-desc\">{{ w.description }}</small>}</span><span class=\"binder-widget-option-sr-key\" aria-hidden=\"true\">{{ w.key }}</span></span></mat-option>}</mat-select></mat-form-field>}<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>\n <mat-tab [label]=\"tx('editor.tabs.devices', 'Device Layouts')\">\n <div class=\"panel\">\n <mat-button-toggle-group [value]=\"activeDevice()\" (change)=\"activeDevice.set($event.value)\" [attr.aria-label]=\"tx('editor.devices.target', 'Device to configure')\">\n @for (device of deviceKeys; track device) {\n <mat-button-toggle [value]=\"device\">{{ deviceLabels[device] }}</mat-button-toggle>\n }\n </mat-button-toggle-group>\n @for (device of deviceKeys; track device) {\n @if (activeDevice() === device) {\n <div class=\"card\">\n <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>\n <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>\n @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> }\n <div class=\"grid\">\n <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>\n <mat-form-field class=\"page-inset-field\" appearance=\"outline\"><mat-label>{{ tx('editor.layout.paddingInline', 'Page side inset') }}</mat-label><input matInput [formControl]=\"deviceForms[device].controls.paddingInline\" placeholder=\"16px\" /></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>\n <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>\n </div>\n <details><summary>{{ tx('editor.devices.advanced', 'Advanced overrides') }}</summary><div class=\"grid\">\n <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>\n <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>\n <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-hint>{{ tx('editor.devices.widgetOverridesHelp', 'Supported per-widget properties on this device: className, span, order, and hidden. Does not override internal widget definition or inputs.') }}</mat-hint></mat-form-field>\n </div></details>\n @if (!stackedDevices.has(device)) { <div class=\"hint\">{{ tx('editor.preview.deviceCanvasHint', 'Existing canvas item overrides are preserved.') }}</div> }\n </div>\n }\n }\n </div>\n </mat-tab>\n <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>\n <mat-tab [label]=\"tx('editor.tabs.state', 'Page State')\">\n <div class=\"panel\">\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.stateDefinitionJson', 'Page state (values, schema, derived \u2014 JSON)') }}</mat-label>\n <textarea matInput rows=\"10\" [formControl]=\"stateControl\" data-testid=\"page-state-json\"></textarea>\n </mat-form-field>\n\n <section class=\"panel\" data-testid=\"page-datasources-guided\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.dataSourcesTitle', 'Data Sources') }}</strong>\n <button mat-stroked-button type=\"button\" data-testid=\"page-datasources-add\" (click)=\"addDataSourceNode()\">\n <mat-icon>add</mat-icon>{{ tx('editor.state.addDataSource', 'Add data source') }}\n </button>\n </div>\n <p class=\"hint\">{{ tx('editor.state.dataSourcesCopy', 'Bind HTTP or resource endpoints directly into page state paths with reactive parameters.') }}</p>\n @if (!dataSourceNodes.controls.length) {\n <p class=\"hint\" data-testid=\"page-datasources-empty\">{{ tx('editor.state.emptyDataSources', 'No declarative data sources configured.') }}</p>\n }\n @for (ds of dataSourceNodes.controls; track $index) {\n <div class=\"card\" [formGroup]=\"ds\" [attr.data-testid]=\"'page-datasource-card-' + $index\">\n <div class=\"head\">\n <span>{{ dataSourceLabel($index) }}</span>\n <button\n praxisIconButton=\"delete\"\n size=\"compact\"\n type=\"button\"\n [attr.data-testid]=\"'page-datasource-remove-' + $index\"\n [attr.aria-label]=\"tx('editor.state.removeDataSourceAria', 'Remove data source')\"\n (click)=\"removeDataSourceNode($index)\"\n ></button>\n </div>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.dataSourceId', 'ID') }}</mat-label>\n <input matInput formControlName=\"id\" [attr.data-testid]=\"'page-datasource-id-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.dataSourceKind', 'Kind') }}</mat-label>\n <mat-select formControlName=\"kind\" [attr.data-testid]=\"'page-datasource-kind-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"remote-resource\">remote-resource</mat-option>\n <mat-option value=\"stats-query\">stats-query</mat-option>\n <mat-option value=\"raw-http\">raw-http</mat-option>\n </mat-select>\n </mat-form-field>\n <praxis-resource-quick-connect\n formControlName=\"resourcePath\"\n [intent]=\"ds.controls.kind.value === 'stats-query' ? 'stats' : (ds.controls.kind.value === 'raw-http' ? 'any' : 'collection')\"\n [endpointKey]=\"resolveEndpointKey(ds.controls.endpointKey.value)\"\n [label]=\"tx('editor.state.resourcePath', 'Resource path')\"\n [attr.data-testid]=\"'page-datasource-resourcePath-' + $index\"\n />\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.endpointKey', 'Endpoint key') }}</mat-label>\n <input matInput formControlName=\"endpointKey\" [attr.data-testid]=\"'page-datasource-endpointKey-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.method', 'HTTP method') }}</mat-label>\n <mat-select formControlName=\"method\" [attr.data-testid]=\"'page-datasource-method-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.auto', 'Auto') }}</mat-option>\n <mat-option value=\"GET\">GET</mat-option>\n <mat-option value=\"POST\">POST</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.targetStatePath', 'Target state path') }}</mat-label>\n <input matInput formControlName=\"targetStatePath\" [attr.data-testid]=\"'page-datasource-targetStatePath-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.errorPolicy', 'Error policy') }}</mat-label>\n <mat-select formControlName=\"errorPolicy\" [attr.data-testid]=\"'page-datasource-errorPolicy-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"propagate\">propagate</mat-option>\n <mat-option value=\"silent\">silent</mat-option>\n <mat-option value=\"fallback\">fallback</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.refreshIntervalMs', 'Refresh interval (ms)') }}</mat-label>\n <input matInput type=\"number\" min=\"0\" formControlName=\"refreshIntervalMs\" [attr.data-testid]=\"'page-datasource-refreshIntervalMs-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.autoFetch', 'Auto fetch') }}</mat-label>\n <mat-select formControlName=\"autoFetch\" [attr.data-testid]=\"'page-datasource-autoFetch-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"true\">true</mat-option>\n <mat-option value=\"false\">false</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.description', 'Description') }}</mat-label>\n <input matInput formControlName=\"description\" [attr.data-testid]=\"'page-datasource-description-' + $index\" />\n </mat-form-field>\n <div class=\"wide guided-presets-bar\" [attr.data-testid]=\"'page-datasource-guided-presets-' + $index\">\n <span class=\"hint\">{{ tx('editor.state.guidedPresets', 'Guided query & transform templates:') }}</span>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-groupby-' + $index\" (click)=\"applyGuidedDataSourceQuery($index, { mode: 'group-by', groupByField: 'ativo', metricField: 'id', metricOp: 'count' }); applyGuidedDataSourceTransform($index, { kind: 'group-by-map', totalField: 'total', mappingsText: 'true:active, false:inactive', ratioTargetField: 'activeRatePct', ratioNumeratorField: 'active', ratioDenominatorField: 'total', ratioScale: 100, ratioDecimals: 1 })\">\n {{ tx('editor.state.presetGroupByKpi', 'Group-by + Total + Ratio (%)') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-weighted-' + $index\" (click)=\"applyGuidedDataSourceTransform($index, { kind: 'group-by-map', totalField: 'total', weightedAverageField: 'weightedAvg', weightedAverageDecimals: 2, mappingsText: '1:low, 5:high' })\">\n {{ tx('editor.state.presetWeightedAvg', 'Weighted average') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-timeseries-' + $index\" (click)=\"applyGuidedDataSourceQuery($index, { mode: 'timeseries', groupByField: 'createdAt', period: 'month', metricField: 'id', metricOp: 'count' }); applyGuidedDataSourceTransform($index, { kind: 'timeseries-last', mappingsText: 'count:latestCount, avg:latestAvg' })\">\n {{ tx('editor.state.presetTimeseriesLast', 'Timeseries latest point') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-path-' + $index\" (click)=\"applyGuidedDataSourceTransform($index, { kind: 'path', path: 'totalElements' })\">\n {{ tx('editor.state.presetPathExtract', 'Extract path (totalElements)') }}\n </button>\n </div>\n </div>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.queryJson', 'Query (JSON)') }}</mat-label>\n <textarea matInput rows=\"3\" formControlName=\"queryJson\" [attr.data-testid]=\"'page-datasource-queryJson-' + $index\"></textarea>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.transformJson', 'Transform (JSON)') }}</mat-label>\n <textarea matInput rows=\"3\" formControlName=\"transformJson\" [attr.data-testid]=\"'page-datasource-transformJson-' + $index\"></textarea>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.fallbackJson', 'Fallback value (JSON)') }}</mat-label>\n <textarea matInput rows=\"2\" formControlName=\"fallbackJson\" [attr.data-testid]=\"'page-datasource-fallbackJson-' + $index\"></textarea>\n </mat-form-field>\n </div>\n @let preview = getDataSourcePreview($index);\n <div class=\"datasource-preview-box\" [attr.data-testid]=\"'page-datasource-preview-' + $index\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.previewTitle', 'Transform & State Preview') }}</strong>\n <span class=\"previous-height-chip\">{{ preview.sourceKind === 'live' ? tx('editor.state.previewLive', 'Live response') : tx('editor.state.previewSample', 'Simulated sample') }}</span>\n </div>\n @if (preview.status === 'error') {\n <p class=\"limits-error\" role=\"alert\" [attr.data-testid]=\"'page-datasource-preview-error-' + $index\">{{ preview.error }}</p>\n } @else {\n <pre class=\"datasource-preview-json\" [attr.data-testid]=\"'page-datasource-preview-json-' + $index\">{{ preview.formattedJson }}</pre>\n @if (preview.tokens.length) {\n <div class=\"studio-actions\" [attr.data-testid]=\"'page-datasource-preview-tokens-' + $index\">\n <span class=\"hint\">{{ tx('editor.state.availableTokens', 'Click a token to prepare widget binding:') }}</span>\n @for (token of preview.tokens; track token) {\n <button mat-button type=\"button\" (click)=\"binderTokenControl.setValue(token)\"><code>{{ token }}</code></button>\n }\n </div>\n }\n }\n </div>\n </div>\n }\n </section>\n\n <section class=\"card\" data-testid=\"page-derived-measures-guided\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.derivedMeasuresTitle', 'Guided Derived Measures') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.state.derivedMeasuresCopy', 'Create percentages, ratios, weighted averages, or formatted measures from page state paths without writing raw JsonLogic.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedTargetPath', 'Measure key') }}</mat-label>\n <input matInput [formControl]=\"derivedPathControl\" placeholder=\"activeRatePct\" data-testid=\"derived-target-path\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedKind', 'Calculation') }}</mat-label>\n <mat-select [formControl]=\"derivedKindControl\" data-testid=\"derived-kind\">\n <mat-option value=\"ratio-percent\">{{ tx('editor.state.derivedRatio', 'Ratio / Percentage') }}</mat-option>\n <mat-option value=\"weighted-average\">{{ tx('editor.state.derivedWeighted', 'Weighted average') }}</mat-option>\n <mat-option value=\"format-value\">{{ tx('editor.state.derivedFormat', 'Formatted value') }}</mat-option>\n <mat-option value=\"template\">{{ tx('editor.state.derivedTemplate', 'State reference') }}</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedNumerator', 'Numerator / Source path') }}</mat-label>\n <input matInput [formControl]=\"derivedNumeratorControl\" placeholder=\"kpis.active\" data-testid=\"derived-numerator\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedDenominator', 'Denominator path') }}</mat-label>\n <input matInput [formControl]=\"derivedDenominatorControl\" placeholder=\"kpis.total\" data-testid=\"derived-denominator\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedFormatKind', 'Format') }}</mat-label>\n <mat-select [formControl]=\"derivedFormatControl\" data-testid=\"derived-format\">\n <mat-option value=\"percent\">percent (%)</mat-option>\n <mat-option value=\"number\">number</mat-option>\n <mat-option value=\"integer\">integer</mat-option>\n <mat-option value=\"currency\">currency (R$)</mat-option>\n <mat-option value=\"boolean\">boolean</mat-option>\n </mat-select>\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" data-testid=\"derived-add-btn\" (click)=\"addGuidedDerivedMeasure()\">\n <mat-icon aria-hidden=\"true\">functions</mat-icon>{{ tx('editor.state.addDerivedAction', 'Add measure to page state') }}\n </button>\n </div>\n @let derivedPreview = getDerivedStatePreview();\n @if (derivedPreview.entries.length || derivedPreview.diagnostics.length) {\n <div class=\"datasource-preview-box\" data-testid=\"page-derived-preview\">\n @for (diag of derivedPreview.diagnostics; track diag) {\n <p class=\"limits-error\" role=\"alert\">{{ diag }}</p>\n }\n @if (derivedPreview.entries.length) {\n <div class=\"studio-actions\">\n @for (entry of derivedPreview.entries; track entry.key) {\n <button mat-button type=\"button\" (click)=\"binderTokenControl.setValue(entry.token)\">\n <strong>{{ entry.key }}</strong>: {{ entry.formattedValue }} <code>({{ entry.token }})</code>\n </button>\n }\n </div>\n }\n </div>\n }\n </section>\n\n <section class=\"card\" data-testid=\"page-state-component-binder\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.binderTitle', 'Connect State or Measure to Widget') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.state.binderCopy', 'Bind any state path or derived measure directly to a widget input in the draft.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderToken', 'State token') }}</mat-label>\n <input matInput [formControl]=\"binderTokenControl\" placeholder=\"{{ '{{state.kpis.total}}' }}\" data-testid=\"binder-token\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderWidget', 'Target widget') }}</mat-label>\n <mat-select [formControl]=\"binderWidgetControl\" data-testid=\"binder-widget\" panelClass=\"praxis-binder-widget-select-panel\">\n @if (selectedBinderWidget(); as selectedWidget) {\n <mat-select-trigger>\n <span class=\"binder-widget-trigger\">\n <mat-icon aria-hidden=\"true\" class=\"binder-widget-trigger-icon\">{{ selectedWidget.icon }}</mat-icon>\n <span class=\"binder-widget-trigger-text\">\n <strong>{{ selectedWidget.title }}</strong>\n @if (selectedWidget.description) {\n <small> \u00B7 {{ selectedWidget.description }}</small>\n }\n </span>\n </span>\n </mat-select-trigger>\n }\n @for (w of bindablePageWidgets(); track w.key) {\n <mat-option [value]=\"w.key\" [attr.data-widget-key]=\"w.key\" class=\"binder-widget-option\">\n <span class=\"binder-widget-option-row\">\n <mat-icon aria-hidden=\"true\" class=\"binder-widget-option-icon\">{{ w.icon }}</mat-icon>\n <span class=\"binder-widget-option-copy\">\n <strong class=\"binder-widget-option-title\">{{ w.title }}</strong>\n @if (w.description) {\n <small class=\"binder-widget-option-desc\">{{ w.description }}</small>\n }\n </span>\n <span class=\"binder-widget-option-sr-key\" aria-hidden=\"true\">{{ w.key }}</span>\n </span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderInputPath', 'Widget input path') }}</mat-label>\n <input matInput [formControl]=\"binderInputPathControl\" placeholder=\"value\" data-testid=\"binder-input-path\" />\n </mat-form-field>\n </div>\n @if (availableStateBindingTokens().length) {\n <div class=\"binder-input-suggestions\" data-testid=\"binder-token-suggestions\">\n <span class=\"hint\">{{ tx('editor.state.binderTokenSuggestions', 'Available tokens:') }}</span>\n @for (availableToken of availableStateBindingTokens(); track availableToken) {\n <button\n type=\"button\"\n class=\"binder-input-suggestion-chip\"\n [class.active]=\"binderTokenControl.value === availableToken\"\n [attr.data-testid]=\"'binder-token-suggestion-' + availableToken\"\n (click)=\"selectBinderTokenSuggestion(availableToken)\"\n >\n <code>{{ availableToken }}</code>\n </button>\n }\n </div>\n }\n <div class=\"binder-input-suggestions\" data-testid=\"binder-pipe-suggestions\">\n <span class=\"hint\">{{ tx('editor.state.binderPipeSuggestions', 'Formatadores CSP-safe (pipes):') }}</span>\n @for (pipeSegment of binderDeclarativePipes; track pipeSegment) {\n <button\n type=\"button\"\n class=\"binder-input-suggestion-chip\"\n [attr.data-testid]=\"'binder-pipe-chip-' + pipeSegment\"\n (click)=\"appendBinderTokenPipe(pipeSegment)\"\n >\n <code>{{ pipeSegment }}</code>\n </button>\n }\n </div>\n @if (binderTokenPipeError(); as pipeErr) {\n <p class=\"studio-status binder-unbound-warning\" role=\"alert\" data-testid=\"binder-pipe-error\">{{ pipeErr }}</p>\n } @else if (binderTokenLivePreview(); as livePreviewVal) {\n <p class=\"hint\" data-testid=\"binder-live-preview\">\n {{ tx('editor.state.binderLivePreview', 'Preview simulado:') }} <strong>{{ livePreviewVal }}</strong>\n </p>\n }\n @if (selectedBinderWidgetInputSuggestions().length) {\n <div class=\"binder-input-suggestions\" data-testid=\"binder-input-suggestions\">\n <span class=\"hint\">{{ tx('editor.state.binderInputSuggestions', 'Suggested paths:') }}</span>\n @for (suggestedPath of selectedBinderWidgetInputSuggestions(); track suggestedPath) {\n <button\n type=\"button\"\n class=\"binder-input-suggestion-chip\"\n [class.active]=\"binderInputPathControl.value === suggestedPath\"\n [attr.data-testid]=\"'binder-input-suggestion-' + suggestedPath\"\n (click)=\"selectBinderInputPathSuggestion(suggestedPath)\"\n >\n <code>{{ suggestedPath }}</code>\n </button>\n }\n </div>\n }\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" data-testid=\"binder-bind-btn\" (click)=\"bindStateTokenToWidget()\">\n <mat-icon aria-hidden=\"true\">link</mat-icon>{{ tx('editor.state.bindAction', 'Bind token to widget in draft') }}\n </button>\n <button mat-stroked-button type=\"button\" data-testid=\"binder-auto-bind-btn\" (click)=\"autoBindWidgetStatePlaceholders()\">\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>{{ tx('editor.state.autoBindAction', 'Auto-bind ${...} widget placeholders') }}\n </button>\n </div>\n @if (binderMessage()) {\n <p class=\"studio-status\" role=\"status\" data-testid=\"binder-status-message\">{{ binderMessage() }}</p>\n }\n @if (invalidWidgetStateBindingPipes().length) {\n <div class=\"binder-unbound-warning\" role=\"alert\" data-testid=\"binder-invalid-pipes-warning\">\n <mat-icon aria-hidden=\"true\">error_outline</mat-icon>\n <div class=\"binder-unbound-warning__body\">\n <span>{{ tx('editor.state.invalidWidgetPipesWarning', 'Formatadores (pipes) inv\u00E1lidos detectados:') }}</span>\n <div class=\"binder-unbound-warning__chips\">\n @for (invalidBinding of invalidWidgetStateBindingPipes(); track invalidBinding.widgetKey + ':' + invalidBinding.inputPath) {\n <button\n type=\"button\"\n class=\"binder-unbound-chip\"\n [attr.data-testid]=\"'binder-invalid-pipe-chip-' + invalidBinding.widgetKey + '-' + invalidBinding.inputPath\"\n (click)=\"loadStateBindingIntoForm(invalidBinding)\"\n >\n <strong>{{ invalidBinding.widgetTitle }}</strong>.<code>{{ invalidBinding.inputPath }}</code> (<code>{{ invalidBinding.expression }}</code>)\n </button>\n }\n </div>\n </div>\n </div>\n }\n @if (unboundWidgetStatePlaceholders().length; as unboundCount) {\n <div class=\"binder-unbound-warning\" role=\"status\" data-testid=\"binder-unbound-placeholders-warning\">\n <mat-icon aria-hidden=\"true\">warning_amber</mat-icon>\n <div class=\"binder-unbound-warning__body\">\n <span>{{ tx('editor.state.unboundPlaceholdersWarning', 'Placeholders sem v\u00EDnculo detectados:') }}</span>\n <div class=\"binder-unbound-warning__chips\">\n @for (item of unboundWidgetStatePlaceholders(); track item.widgetKey + ':' + item.placeholder) {\n <button\n type=\"button\"\n class=\"binder-unbound-chip\"\n [attr.data-testid]=\"'binder-unbound-chip-' + item.widgetKey + '-' + item.placeholder\"\n [attr.title]=\"tx('editor.state.selectUnboundPlaceholderHint', 'Clique para preencher widget e caminho no formul\u00E1rio')\"\n (click)=\"selectUnboundPlaceholder(item)\"\n >\n <strong>{{ item.widgetTitle }}</strong> (<code>{{ '${' + item.placeholder + '}' }}</code>)\n </button>\n }\n </div>\n </div>\n </div>\n }\n @if (currentWidgetStateBindings().length) {\n <ul class=\"blocked-widgets-list binder-active-list\" data-testid=\"active-widget-state-bindings\">\n @for (binding of currentWidgetStateBindings(); track binding.widgetKey + ':' + binding.inputPath) {\n @let previewVal = resolveStateTokenPreview(binding.token);\n <li class=\"binder-active-item\">\n <mat-icon aria-hidden=\"true\" class=\"binder-active-icon\">{{ widgetIconForKey(binding.widgetKey) }}</mat-icon>\n <button\n type=\"button\"\n class=\"binder-active-copy binder-active-select-btn\"\n [attr.data-testid]=\"'binder-select-' + binding.widgetKey + '-' + binding.inputPath\"\n [attr.title]=\"tx('editor.state.editBindingHint', 'Click to load binding into form')\"\n (click)=\"loadStateBindingIntoForm(binding)\"\n >\n <strong>{{ binding.widgetTitle }}</strong>.<code>{{ binding.inputPath }}</code> \u2190 <code>{{ binding.token }}</code>\n @if (previewVal !== null) {\n <small class=\"binder-preview-badge\" [attr.data-testid]=\"'binder-preview-' + binding.widgetKey + '-' + binding.inputPath\">= {{ previewVal }}</small>\n }\n </button>\n <button mat-icon-button type=\"button\" [attr.data-testid]=\"'binder-unbind-' + binding.widgetKey + '-' + binding.inputPath\"\n [attr.aria-label]=\"tx('editor.state.unbindAction', 'Remove binding')\"\n (click)=\"unbindStateTokenFromWidget(binding.widgetKey, binding.inputPath)\">\n <mat-icon aria-hidden=\"true\">link_off</mat-icon>\n </button>\n </li>\n }\n </ul>\n }\n </section>\n\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.dataSourcesJson', 'page.dataSources (JSON)') }}</mat-label>\n <textarea matInput rows=\"8\" [formControl]=\"dataSourcesControl\" [placeholder]=\"tx('editor.hints.dataSourcesExample', '')\" data-testid=\"page-datasources-json\"></textarea>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab [label]=\"tx('editor.limits.tab', 'Widget limits')\">\n <section class=\"limits-panel\" data-testid=\"canvas-limits-editor\">\n @if (workspaceTab() === 3 && activeTab() === 'limits' && canvasLimits.targets.length) {\n <div class=\"selection-identity\" tabindex=\"-1\" data-testid=\"canvas-limits-identity\">\n <mat-icon aria-hidden=\"true\">{{ limitsTargetIcon(limitsTargetControl.value) }}</mat-icon>\n <strong>{{ limitsTargetLabel(limitsTargetControl.value) }}</strong>\n </div>\n }\n\n <p class=\"hint\" id=\"canvas-limits-help\">{{ tx('editor.limits.help', 'Limits use grid columns and rows, not pixels. Leave blank for defaults, or to inherit the base limits on a device.') }}</p>\n @if (canvasLimits.targets.length) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.target', 'Widget and layout') }}</mat-label>\n <mat-select [formControl]=\"limitsTargetControl\" data-testid=\"canvas-limits-target\">\n <mat-select-trigger>\n <span class=\"binder-widget-trigger\">\n <mat-icon aria-hidden=\"true\" class=\"binder-widget-trigger-icon\">{{ limitsTargetIcon(limitsTargetControl.value) }}</mat-icon>\n <span class=\"binder-widget-trigger-text\">{{ limitsTargetLabel(limitsTargetControl.value) }}</span>\n </span>\n </mat-select-trigger>\n @for (target of canvasLimits.targets; track target.scope + ':' + target.key; let index = $index) {\n <mat-option [value]=\"index\">\n <mat-icon aria-hidden=\"true\">{{ limitsTargetIcon(index) }}</mat-icon>\n <span>{{ limitsTargetLabel(index) }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <p class=\"hint\">{{ limitsGeometryLabel() }}</p>\n @if (canvasLimits.form.controls[limitsTargetControl.value]; as limitsForm) {\n <div class=\"limits-fields\" aria-describedby=\"canvas-limits-help\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.minColSpan', 'Minimum width (columns)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.minColSpan\" data-testid=\"canvas-limit-minColSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minColSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.maxColSpan', 'Maximum width (columns)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.maxColSpan\" data-testid=\"canvas-limit-maxColSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxColSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.minRowSpan', 'Minimum height (rows)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.minRowSpan\" data-testid=\"canvas-limit-minRowSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minRowSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.maxRowSpan', 'Maximum height (rows)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.maxRowSpan\" data-testid=\"canvas-limit-maxRowSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxRowSpan) }}</mat-error>\n </mat-form-field>\n </div>\n @if (limitsUseContentHeight()) {\n <p class=\"limits-notice\"><mat-icon aria-hidden=\"true\">height</mat-icon><span>{{ tx('editor.limits.autoHeight', 'Choose natural height or an adjustable height with internal scrolling.') }}</span></p>\n <mat-button-toggle-group class=\"height-modes\" [formControl]=\"limitsForm.controls.heightMode\" [attr.aria-label]=\"tx('editor.height.mode', 'Widget height')\" data-testid=\"canvas-height-mode\">\n <mat-button-toggle value=\"inherit\">{{ tx('editor.height.inherit', 'Default') }}</mat-button-toggle>\n <mat-button-toggle value=\"auto\">{{ tx('editor.height.auto', 'Automatic') }}</mat-button-toggle>\n <mat-button-toggle value=\"fixed\">{{ tx('editor.height.fixed', 'Adjustable') }}</mat-button-toggle>\n </mat-button-toggle-group>\n @if (limitsForm.controls.heightMode.value === 'fixed') {\n <div class=\"limits-fields\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.value', 'Height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.heightPx\" data-testid=\"canvas-height-px\">\n <mat-error>{{ limitFieldError(limitsForm.controls.heightPx) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.top', 'Top inset (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"0\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.offsetTopPx\" data-testid=\"canvas-height-top\">\n <mat-error>{{ limitFieldError(limitsForm.controls.offsetTopPx) }}</mat-error>\n </mat-form-field>\n </div>\n }\n <div class=\"limits-fields\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.min', 'Minimum height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.minHeightPx\" data-testid=\"canvas-limit-minHeightPx\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minHeightPx) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.max', 'Maximum height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.maxHeightPx\" data-testid=\"canvas-limit-maxHeightPx\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxHeightPx) }}</mat-error>\n </mat-form-field>\n </div>\n }\n }\n @if (limitsSummaryError) { <p class=\"limits-error\" role=\"alert\">{{ limitsSummaryError }}</p> }\n } @else {\n <p>{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>\n }\n </section>\n </mat-tab>\n </mat-tab-group></div>\n </mat-tab>\n </mat-tab-group>\n <details class=\"summary-details\">\n <summary>{{ tx('editor.summaryAria', 'Configuration summary') }}</summary>\n <div class=\"summary\">\n @if (!isHostedInSettingsPanel) {\n <span>{{ validationStateLabel }}</span>\n <span>{{ dirtyStateLabel }}</span>\n }\n <span>{{ selectedLayoutPresetLabel }}</span>\n <span>{{ selectedThemePresetLabel }}</span>\n <span>{{ canvasSummaryLabel }}</span>\n </div>\n </details>\n </div>\n", styles: [".page-inset-field{grid-column:1/-1;min-width:0}: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{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{align-items:stretch}.head>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)}.limits-blocking-banner{border-color:var(--md-sys-color-error, #b3261e);gap:8px}.blocked-widgets-list{margin:0;padding-inline-start:20px;display:grid;gap:4px}.previous-heights-summary{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font:var(--mat-sys-body-small, inherit);color:var(--md-sys-color-on-surface-variant, currentColor)}.previous-height-chip{padding:2px 8px;border-radius:999px;background:var(--md-sys-color-surface-container-highest, rgba(128, 128, 128, .14))}.wide{grid-column:1/-1}.hint{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px}.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}:host{container-type:inline-size}.studio-panel{display:grid;gap:var(--p-space-4, 16px);padding-block:var(--p-space-5, 20px);min-width:0}.studio-heading h2{font-size:var(--p-font-size-lg, 20px);line-height:1.25;margin:var(--p-space-2, 8px) 0;font-weight:600}.studio-heading p{margin:0;color:var(--md-sys-color-on-surface-variant);line-height:1.5}.studio-panel h3{font-size:var(--p-font-size-md, 16px);margin:0}.studio-panel>button{justify-self:start}.studio-gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--p-space-3, 12px)}.studio-choice{display:flex;flex-direction:column;gap:var(--p-space-2, 8px);min-width:0;padding:var(--p-space-3, 12px);text-align:start;border:1px solid var(--md-sys-color-outline-variant);border-radius:var(--p-radius-lg, 12px);background:var(--md-sys-color-surface);color:var(--md-sys-color-on-surface);font:inherit;cursor:pointer;transition:border-color var(--md-sys-motion-duration-short2, .1s),background-color var(--md-sys-motion-duration-short2, .1s)}.studio-choice:hover:not(:disabled){border-color:var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low)}.studio-choice.selected{border-color:var(--md-sys-color-primary);box-shadow:inset 0 0 0 1px var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low)}.studio-choice:focus-visible,.studio-panel summary:focus-visible{outline:3px solid var(--md-sys-color-primary);outline-offset:3px}.studio-choice:disabled{opacity:var(--p-disabled-opacity, .5);cursor:not-allowed}.choice-title{display:flex;align-items:center;justify-content:space-between;gap:var(--p-space-2, 8px);width:100%;font-weight:600;line-height:1.35}.choice-title mat-icon{color:var(--md-sys-color-primary);flex-shrink:0;font-size:20px;height:20px;width:20px}.choice-description{font-size:var(--p-font-size-sm, 13px);line-height:1.5;color:var(--md-sys-color-on-surface-variant)}.choice-meta{font-size:var(--p-font-size-xs, 12px);color:var(--md-sys-color-on-surface-variant);margin-top:auto;padding-top:var(--p-space-1, 4px)}.theme-sample{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));padding:var(--p-space-3, 12px);border-radius:var(--p-radius-md, 8px);min-height:128px;align-self:stretch;margin-bottom:var(--p-space-1, 4px)}.sample-metric,.sample-chart{background:var(--sample-surface);border-radius:var(--sample-radius);box-shadow:var(--sample-shadow);padding:var(--p-space-2, 8px)}.sample-metric b{display:block;font-size:var(--p-font-size-md, 16px)}.sample-metric i{display:block;height:3px;width:55%;margin-top:var(--p-space-1, 4px);background:var(--sample-accent)}.sample-chart{grid-column:1/-1;display:flex;height:40px;align-items:flex-end;gap:var(--p-space-2, 8px)}.sample-chart i{flex:1;background:var(--sample-accent);height:50%;border-radius:var(--p-radius-xs, 2px)}.sample-chart i:nth-child(2){height:85%}.sample-chart i:nth-child(3){height:65%}.sample-chart i:nth-child(4){height:100%}.sample-chart i:nth-child(5){height:75%}.shell-options,.arrangement-rules{border-top:1px solid var(--md-sys-color-outline-variant);padding-top:var(--p-space-3, 12px)}.studio-panel summary{cursor:pointer;font-weight:500;line-height:1.5;padding-block:var(--p-space-2, 8px)}.shell-gallery{margin-top:var(--p-space-4, 16px)}.shell-sample{display:block;align-self:stretch;border:1px solid;overflow:hidden;margin:var(--p-space-2, 8px)}.shell-sample>span{display:flex;gap:var(--p-space-2, 8px);align-items:center;padding:var(--p-space-2, 8px);border-bottom:1px solid transparent}.shell-sample i{height:4px;width:60%;background:var(--md-sys-color-on-surface-variant);opacity:.4}.shell-sample>span:last-child{min-height:30px;flex-direction:column;align-items:flex-start}.shell-sample>span:last-child i:last-child{width:40%}.selection-line{margin:0;font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-on-surface-variant)}.studio-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--p-space-2, 8px)}.distribution-sample{display:grid;grid-template-rows:repeat(2,1fr);gap:var(--p-space-1, 4px);align-self:stretch;height:76px;padding:var(--p-space-2, 8px);background:var(--md-sys-color-surface-container);border-radius:var(--p-radius-md, 8px)}.distribution-sample i{display:grid;place-items:center;background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container);border-radius:var(--p-radius-sm, 4px);font-size:var(--p-font-size-xs, 12px);font-style:normal}.distribution-sample i:nth-child(n+4){opacity:.65}.selected .distribution-sample i{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.arrangement-review{display:grid;gap:var(--p-space-3, 12px);padding-block:var(--p-space-4, 16px);border-top:2px solid var(--md-sys-color-primary)}.review-heading{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:var(--p-space-2, 8px)}.review-heading>span{font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-primary)}.canvas-comparison{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--p-space-3, 12px)}.canvas-comparison figure{margin:0;min-width:0}.canvas-comparison figcaption{font-weight:500;margin-bottom:var(--p-space-2, 8px)}.canvas-map{display:grid;gap:2px;height:220px;padding:var(--p-space-2, 8px);border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container-low);border-radius:var(--p-radius-md, 8px)}.canvas-map span{display:grid;place-items:center;min-height:0;min-width:0;overflow:hidden;font-size:var(--p-font-size-xs, 12px);color:var(--md-sys-color-on-secondary-container);background:var(--md-sys-color-secondary-container);border-radius:var(--p-radius-xs, 2px)}.canvas-map .changed{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.widget-changes{padding-inline-start:var(--p-space-5, 20px);margin:0}.widget-changes li{padding-block:var(--p-space-2, 8px);overflow-wrap:anywhere}.widget-changes span{display:block;font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-on-surface-variant)}.studio-status{border-inline-start:3px solid var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low);padding:var(--p-space-3, 12px);margin:0;line-height:1.5}@container (max-width:420px){.studio-gallery{grid-template-columns:minmax(0,1fr)}.canvas-map{height:180px}}@media(prefers-reduced-motion:reduce){.studio-choice{transition:none}}.arrangement-feedback:empty{display:none}.arrangement-feedback:focus{outline:none}.arrangement-feedback:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:3px}@container (min-width:620px){.arrangement-gallery{grid-template-columns:repeat(3,minmax(0,1fr))}}:host ::ng-deep .studio-tabs>.mat-mdc-tab-header{position:sticky;top:0;z-index:var(--praxis-layer-sticky-local, 100);background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface))}:host ::ng-deep .studio-tabs>.mat-mdc-tab-header .mdc-tab{flex:1 1 0;padding-inline:var(--p-space-1, 4px)}.arrangement-feedback{scroll-margin-block-start:var(--p-space-16, 64px)}.filter-owner-banner{border-color:var(--md-sys-color-tertiary, var(--md-sys-color-primary))}.filter-owner-item{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px}.quick-geometry-rows{display:grid;gap:8px}.quick-geometry-row{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px;padding-block:4px;border-bottom:1px solid var(--md-sys-color-outline-variant, rgba(128, 128, 128, .2))}.quick-geometry-label{display:inline-flex;align-items:center;gap:6px;min-width:0}.quick-geometry-label mat-icon{font-size:18px;width:18px;height:18px;flex-shrink:0;color:var(--md-sys-color-primary, currentColor)}.binder-widget-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0}.binder-widget-trigger-icon{font-size:18px;width:18px;height:18px;flex-shrink:0;color:var(--md-sys-color-primary, currentColor)}.binder-widget-trigger-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.binder-widget-trigger-text small{color:var(--md-sys-color-on-surface-variant, currentColor);font-weight:400}.binder-widget-option-row{position:relative;display:flex;align-items:flex-start;gap:10px;padding-block:4px;min-width:0}.binder-widget-option-icon{font-size:20px;width:20px;height:20px;margin-top:2px;flex-shrink:0;color:var(--md-sys-color-primary, currentColor)}.binder-widget-option-copy{display:flex;flex-direction:column;gap:2px;min-width:0;line-height:1.3}.binder-widget-option-title{font-weight:600;color:var(--md-sys-color-on-surface, currentColor);overflow-wrap:anywhere}.binder-widget-option-desc{font-size:12px;color:var(--md-sys-color-on-surface-variant, currentColor);overflow-wrap:anywhere}.binder-widget-option-sr-key{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;opacity:0;pointer-events:none}.binder-input-suggestions{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.binder-input-suggestion-chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;border:1px solid var(--md-sys-color-outline-variant, rgba(128, 128, 128, .3));background:var(--md-sys-color-surface, transparent);color:var(--md-sys-color-on-surface, currentColor);font-size:12px;cursor:pointer;transition:border-color .1s,background-color .1s}.binder-input-suggestion-chip:hover{border-color:var(--md-sys-color-primary, currentColor);background:var(--md-sys-color-surface-container-low, rgba(128, 128, 128, .08))}.binder-input-suggestion-chip.active{border-color:var(--md-sys-color-primary, currentColor);background:var(--md-sys-color-primary-container, rgba(128, 128, 128, .16));color:var(--md-sys-color-on-primary-container, currentColor)}.binder-active-list{list-style:none;padding-inline-start:0;gap:6px}.binder-active-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:6px;background:var(--md-sys-color-surface, rgba(255, 255, 255, .03))}.binder-active-icon{font-size:18px;width:18px;height:18px;flex-shrink:0;color:var(--md-sys-color-primary, currentColor)}.binder-active-copy{flex:1 1 auto;min-width:0;overflow-wrap:anywhere}.binder-active-select-btn{appearance:none;border:none;background:transparent;color:inherit;font:inherit;text-align:start;padding:2px 4px;border-radius:4px;cursor:pointer}.binder-active-select-btn:hover{background:var(--md-sys-color-surface-container-low, rgba(128, 128, 128, .08))}.binder-active-select-btn:focus-visible{outline:2px solid var(--md-sys-color-primary, currentColor);outline-offset:2px}.binder-preview-badge{display:inline-flex;align-items:center;margin-inline-start:6px;padding:1px 6px;border-radius:999px;font-size:11px;font-weight:600;color:var(--md-sys-color-on-secondary-container, #1d192b);background:var(--md-sys-color-secondary-container, rgba(103, 80, 164, .14))}.binder-unbound-warning{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border-radius:6px;font-size:12px;border:1px solid var(--md-sys-color-tertiary, rgba(180, 110, 0, .35));background:color-mix(in srgb,var(--md-sys-color-tertiary-container, #ffdea6) 45%,transparent);color:var(--md-sys-color-on-surface, inherit)}.binder-unbound-warning__body{display:grid;gap:6px}.binder-unbound-warning__chips{display:flex;flex-wrap:wrap;gap:6px}.binder-unbound-chip{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;border:1px solid var(--md-sys-color-outline-variant, rgba(128, 128, 128, .35));background:var(--md-sys-color-surface, rgba(255, 255, 255, .65));color:inherit;font:inherit;font-size:11px;cursor:pointer}.binder-unbound-chip:hover{border-color:var(--md-sys-color-primary, currentColor)}.binder-unbound-chip:focus-visible{outline:2px solid var(--md-sys-color-primary, currentColor);outline-offset:2px}.guided-presets-bar{display:grid;gap:6px}.datasource-preview-box{display:grid;gap:8px;padding:12px;border-radius:6px;background:var(--md-sys-color-surface-container, rgba(128, 128, 128, .08))}.datasource-preview-json{margin:0;padding:8px;border-radius:4px;font-size:12px;overflow-x:auto;background:var(--md-sys-color-surface, rgba(0, 0, 0, .04))}.composer-rich-field-option{display:flex;flex-direction:column;gap:2px;padding:2px 0;line-height:1.3;min-width:0}.composer-rich-field-option__header{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-width:0}.composer-rich-field-option__label{font-weight:500;color:var(--md-sys-color-on-surface, currentColor);overflow:hidden;text-overflow:ellipsis}.composer-rich-field-option__code{font-family:var(--mat-sys-mono-font, monospace);font-size:11px;padding:1px 6px;border-radius:999px;background:var(--md-sys-color-surface-container-highest, rgba(128, 128, 128, .14));color:var(--md-sys-color-on-surface-variant, currentColor)}.composer-rich-field-option__badge{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--md-sys-color-primary, #4f46e5) 14%,transparent);color:var(--md-sys-color-primary, currentColor)}.composer-rich-field-option__desc{font-size:11px;color:var(--md-sys-color-on-surface-variant, currentColor);white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}\n"], dependencies: [{ kind: "component", type: PagePresetComparisonComponent, selector: "praxis-page-preset-comparison", inputs: ["expanded", "layoutId", "themeId", "layoutPresetOptions", "canvas"], outputs: ["choose"] }, { 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: MatAutocompleteModule }, { kind: "component", type: i2$3.MatAutocomplete, selector: "mat-autocomplete", inputs: ["aria-label", "aria-labelledby", "displayWith", "autoActiveFirstOption", "autoSelectActiveOption", "requireSelection", "panelWidth", "disableRipple", "class", "hideSingleSelectionIndicator"], outputs: ["optionSelected", "opened", "closed", "optionActivated"], exportAs: ["matAutocomplete"] }, { kind: "component", type: i3$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: i2$3.MatAutocompleteTrigger, selector: "input[matAutocomplete], textarea[matAutocomplete]", inputs: ["matAutocomplete", "matAutocompletePosition", "matAutocompleteConnectedTo", "autocomplete", "matAutocompleteDisabled"], exportAs: ["matAutocompleteTrigger"] }, { 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: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatButtonToggleModule }, { kind: "directive", type: i5.MatButtonToggleGroup, selector: "mat-button-toggle-group", inputs: ["appearance", "name", "vertical", "value", "multiple", "disabled", "disabledInteractive", "hideSingleSelectionIndicator", "hideMultipleSelectionIndicator"], outputs: ["valueChange", "change"], exportAs: ["matButtonToggleGroup"] }, { kind: "component", type: i5.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: i6.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i6.MatLabel, selector: "mat-label" }, { kind: "directive", type: i6.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: i6.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i6.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { 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: i8.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: i3$1.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: "directive", type: i3$1.MatSelectTrigger, selector: "mat-select-trigger" }, { kind: "ngmodule", type: MatTabsModule }, { kind: "directive", type: i9.MatTabContent, selector: "[matTabContent]" }, { kind: "component", type: i9.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i9.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: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }, { kind: "component", type: ResourceQuickConnectComponent, selector: "praxis-resource-quick-connect", inputs: ["resourcePath", "endpointKey", "apiUrlEntry", "intent", "resources", "label", "placeholder", "hint", "disabled"], outputs: ["resourcePathChange", "resourceSelected"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
16384
17648
|
}
|
|
16385
17649
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicPageConfigEditorComponent, decorators: [{
|
|
16386
17650
|
type: Component,
|
|
16387
|
-
args: [{ selector: 'praxis-dynamic-page-config-editor', standalone: true, imports: [PagePresetComparisonComponent, ReactiveFormsModule, MatButtonModule, MatButtonToggleModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, MatTabsModule, MatTooltipModule, PraxisIconButtonComponent], providers: [providePraxisPageBuilderI18n()], changeDetection: ChangeDetectionStrategy.OnPush, host: { '(window:resize)': 'refreshRenderedValidation()' }, template: "<div class=\"editor\">\n @if (!isHostedInSettingsPanel) { <div class=\"head\">\n <div>{{ tx('editor.title', 'Page settings') }}</div>\n <button mat-stroked-button type=\"button\" (click)=\"reset()\">{{ tx('editor.reload', 'Reload') }}</button>\n </div> }\n\n @if (nonLimitError) { <p class=\"limits-error\" role=\"alert\">{{ nonLimitError }}</p> }\n @if (limitsError() || previousAdjustableHeights().length) {\n <section class=\"card limits-blocking-banner\" role=\"region\" [attr.aria-label]=\"tx('editor.limits.blockedBannerTitle', 'Height geometry requires review in Widget limits')\" data-testid=\"preset-geometry-blocking-banner\">\n @if (limitsError()) {\n <div class=\"head\">\n <strong>{{ tx('editor.limits.blockedBannerTitle', 'Height geometry requires review in Widget limits') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.limits.blockedBannerHelp', 'One or more widgets use an adjustable pixel height that exceeds the current usable area or requires review before applying the page or template:') }}</p>\n <ul class=\"blocked-widgets-list\">\n @for (item of blockedGeometryWidgets; track item.scope + ':' + item.key) {\n <li>\n <strong>{{ item.title }}</strong>\n @if (item.title !== item.key) { <code>({{ item.key }})</code> }\n <span> \u00B7 {{ item.scopeLabel }}</span>\n @if (item.heightPx !== null) {\n <span> \u00B7 {{ tx('editor.height.fixed', 'Adjustable') }}: <strong>{{ item.heightPx }}px</strong></span>\n }\n </li>\n }\n </ul>\n <p class=\"limits-error\" role=\"alert\">{{ limitsError() }}</p>\n <div class=\"head\">\n <button mat-stroked-button type=\"button\" data-testid=\"goto-blocked-widget-limits\" (click)=\"navigateToBlockedLimit()\">\n <mat-icon aria-hidden=\"true\">tune</mat-icon>{{ tx('editor.limits.gotoLimitsAction', 'Go to Adjustments \u2192 Widget limits') }}\n </button>\n <button mat-flat-button type=\"button\" data-testid=\"switch-blocked-widgets-auto-height\" (click)=\"switchBlockedWidgetsToAutoHeight()\">\n <mat-icon aria-hidden=\"true\">height</mat-icon>{{ tx('editor.limits.switchToAutoHeightAction', 'Switch affected widgets to Automatic height') }}\n </button>\n </div>\n }\n @if (previousAdjustableHeights().length) {\n <div class=\"previous-heights-summary\" data-testid=\"previous-adjustable-heights-summary\">\n <span>{{ tx('editor.limits.previousHeightsSummary', 'Previous adjustable heights preserved for reference:') }}</span>\n @for (prev of previousAdjustableHeights(); track prev.scope + ':' + prev.key) {\n <span class=\"previous-height-chip\">{{ prev.title }} ({{ prev.scopeLabel }}): <strong>{{ prev.heightPx }}px</strong></span>\n }\n </div>\n }\n </section>\n }\n <mat-tab-group class=\"editor-tabs studio-tabs\" [mat-stretch-tabs]=\"false\" [selectedIndex]=\"workspaceTab()\" (selectedIndexChange)=\"workspaceTab.set($event)\">\n <mat-tab [label]=\"tx('studio.visualTab', 'Visual templates')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"studio-visual\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.visualEyebrow', 'Appearance') }}</span><h2>{{ tx('studio.visualTitle', 'Give the page a visual language') }}</h2><p>{{ tx('studio.visualHelp', 'Choose surfaces, density and chart styling together. Individual widget overrides still take precedence.') }}</p></header>\n <button mat-stroked-button type=\"button\" [attr.aria-pressed]=\"!themePresetControl.value\" (click)=\"themePresetControl.setValue('')\">{{ tx('editor.layout.layoutDefault', 'Layout default') }}</button>\n <p class=\"selection-line\">{{ tx('presets.effectiveTheme', 'Effective page theme') }}: <strong>{{ selectedThemePresetEntry?.label || themePresetControl.value || recommendedThemePresetEntry?.label || tx('presets.hostTheme', 'Host theme') }}</strong></p>\n <div class=\"studio-gallery\">\n @for (theme of themePresetEntries; track theme.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"themePresetControl.value === theme.id\" [attr.aria-pressed]=\"themePresetControl.value === theme.id\" [attr.data-theme-preset]=\"theme.id\" (click)=\"themePresetControl.setValue(theme.id)\">\n <span class=\"theme-sample\" aria-hidden=\"true\" [style.background]=\"themeTokens(theme.id)['--pdx-page-bg']\" [style.--sample-accent]=\"themeTokens(theme.id)['--pdx-page-accent']\" [style.--sample-surface]=\"themeTokens(theme.id)['--pdx-page-surface']\" [style.--sample-radius]=\"themeTokens(theme.id)['--pdx-page-card-radius']\" [style.--sample-shadow]=\"themeTokens(theme.id)['--pdx-page-card-shadow']\" [style.gap]=\"themeTokens(theme.id)['--pdx-page-gap']\">\n <span class=\"sample-metric\"><b>128</b><i></i></span><span class=\"sample-metric\"><b>86%</b><i></i></span><span class=\"sample-chart\"><i></i><i></i><i></i><i></i><i></i></span>\n </span>\n <span class=\"choice-title\">{{ theme.label }}@if (themePresetControl.value === theme.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span>\n <span class=\"choice-description\">{{ tx('studio.theme.' + theme.id, theme.label) }}</span>\n <span class=\"choice-meta\">{{ tx('studio.density.' + theme.density, theme.density) }} \u00B7 {{ shellName(theme.shellPreset) }}</span>\n </button>\n }\n </div>\n <p class=\"hint\">{{ tx('studio.samplesHelp', 'Illustrative samples using the preset tokens. Apply to see the result with your content.') }}</p>\n <details class=\"shell-options\"><summary>{{ tx('studio.shellTitle', 'Customize widget surfaces') }} \u00B7 {{ shellPresetEntries.length }}</summary>\n <p>{{ tx('studio.shellHelp', 'Override the page theme\u2019s default widget surface. Per-widget appearance settings keep their priority.') }}</p>\n <button mat-stroked-button type=\"button\" [attr.aria-pressed]=\"!shellPresetControl.value\" (click)=\"shellPresetControl.setValue('')\">{{ tx('editor.layout.inheritShell', 'Inherit appearance') }}</button>\n <div class=\"studio-gallery shell-gallery\">@for (shell of shellPresetEntries; track shell.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"shellPresetControl.value === shell.id\" [attr.aria-pressed]=\"shellPresetControl.value === shell.id\" (click)=\"shellPresetControl.setValue(shell.id)\">\n <span class=\"shell-sample\" aria-hidden=\"true\" [style.background]=\"shell.appearance.card?.background\" [style.border-color]=\"shell.appearance.card?.borderColor\" [style.border-radius]=\"shell.appearance.card?.borderRadius\" [style.box-shadow]=\"shell.appearance.card?.shadow\"><span [style.background]=\"shell.appearance.header?.background\" [style.border-color]=\"shell.appearance.header?.borderColor\"><mat-icon>analytics</mat-icon><i></i></span><span [style.background]=\"shell.appearance.body?.background\"><i></i><i></i></span></span>\n <span class=\"choice-title\">{{ shellPresetLabel(shell) }}@if (shellPresetControl.value === shell.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span><span class=\"choice-description\">{{ shellDescription(shell) }}</span>\n </button>\n }</div>\n </details>\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.layoutTab', 'Layout templates')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"studio-layout\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.layoutEyebrow', 'Composition model') }}</span><h2>{{ tx('studio.layoutTitle', 'Choose the structure for your use case') }}</h2><p>{{ tx('studio.layoutHelp', 'Compare the roles and default theme of each model. To redistribute the widgets already on this canvas, open Arrange.') }}</p></header>\n @if (duplicateFilterOwners().length) {\n <section class=\"card filter-owner-banner\" role=\"region\" [attr.aria-label]=\"tx('studio.filterOwnerBannerTitle', 'Duplicate filter region detected')\" data-testid=\"duplicate-filter-owner-banner\">\n <div class=\"head\">\n <strong>{{ tx('studio.filterOwnerBannerTitle', 'Single filter region recommendation') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('studio.filterOwnerBannerHelp', 'A table has both its integrated filter bar and an external filter link active. Choose a single query owner to avoid competing filter regions:') }}</p>\n @for (item of duplicateFilterOwners(); track item.tableKey) {\n <div class=\"filter-owner-item\" [attr.data-testid]=\"'duplicate-filter-owner-' + item.tableKey\">\n <span><strong>{{ item.tableTitle }}</strong> <code>({{ item.tableKey }})</code> \u2190 {{ item.externalSourceKeys.join(', ') }}</span>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'consolidate-filter-table-toolbar-' + item.tableKey\" (click)=\"consolidateFilterOwnership(item.tableKey, 'keep-table-toolbar')\">\n {{ tx('studio.keepTableToolbarFilter', 'Use integrated table filter') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'consolidate-filter-external-link-' + item.tableKey\" (click)=\"consolidateFilterOwnership(item.tableKey, 'keep-external-link')\">\n {{ tx('studio.keepExternalFilterLink', 'Use external filter bar') }}\n </button>\n </div>\n </div>\n }\n </section>\n }\n <details class=\"card composer-card\" data-testid=\"collection-screen-composer\">\n <summary>{{ tx('studio.composer.title', 'Guided collection screen composer (5-minute flow)') }}</summary>\n <p class=\"hint\">{{ tx('studio.composer.help', 'Assemble a complete collection screen with header, KPI indicators, single filter region, primary collection, and optional detail surface in the draft.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.resourcePath', 'Resource path') }}</mat-label>\n <input matInput [formControl]=\"composerResourcePathControl\" data-testid=\"composer-resource-path\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.screenTitle', 'Screen title') }}</mat-label>\n <input matInput [formControl]=\"composerTitleControl\" data-testid=\"composer-title\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.groupField', 'KPI group-by field') }}</mat-label>\n <input matInput [formControl]=\"composerGroupFieldControl\" data-testid=\"composer-group-field\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.filterMode', 'Query filter owner') }}</mat-label>\n <mat-select [formControl]=\"composerFilterModeControl\" data-testid=\"composer-filter-mode\">\n <mat-option value=\"table-integrated\">{{ tx('studio.composer.filterIntegrated', 'Integrated table toolbar') }}</mat-option>\n <mat-option value=\"external-filter-bar\">{{ tx('studio.composer.filterExternal', 'Dedicated filter bar widget') }}</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.collectionKind', 'Primary collection') }}</mat-label>\n <mat-select [formControl]=\"composerCollectionKindControl\" data-testid=\"composer-collection-kind\">\n <mat-option value=\"table\">praxis-Table</mat-option>\n <mat-option value=\"list\">praxis-List</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.detailSurface', 'Secondary detail surface') }}</mat-label>\n <mat-select [formControl]=\"composerDetailSurfaceControl\" data-testid=\"composer-detail-surface\">\n <mat-option value=\"drawer\">Drawer</mat-option>\n <mat-option value=\"modal\">Modal</mat-option>\n <mat-option value=\"none\">{{ tx('studio.composer.noSurface', 'None') }}</mat-option>\n </mat-select>\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-flat-button type=\"button\" data-testid=\"composer-generate-btn\" (click)=\"composeCollectionScreen()\">\n <mat-icon aria-hidden=\"true\">auto_awesome</mat-icon>{{ tx('studio.composer.generate', 'Generate collection screen in draft') }}\n </button>\n </div>\n @if (composerMessage()) {\n <p class=\"studio-status\" role=\"status\" data-testid=\"composer-status-message\">{{ composerMessage() }}</p>\n }\n </details>\n <div class=\"studio-actions\"><button mat-stroked-button type=\"button\" (click)=\"layoutPresetControl.setValue('')\">{{ tx('studio.inheritLayout', 'Use inherited model') }}</button><button mat-button type=\"button\" (click)=\"workspaceTab.set(2)\">{{ tx('studio.goArrange', 'Arrange existing widgets') }}<mat-icon iconPositionEnd>arrow_forward</mat-icon></button></div>\n <praxis-page-preset-comparison [expanded]=\"true\" [layoutId]=\"layoutPresetControl.value\" [layoutPresetOptions]=\"layoutPresetOptions\" [themeId]=\"themePresetControl.value\" [canvas]=\"true\" (choose)=\"layoutPresetControl.setValue($event)\" />\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.arrangeTab', 'Arrange')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"canvas-arrangement\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.arrangeEyebrow', 'Existing widgets') }} \u00B7 {{ canvasColumnsControl.value }} {{ tx('editor.layout.cols', 'Columns') }}</span><h2>{{ tx('studio.arrangeTitle', 'Find the right balance of space') }}</h2><p>{{ tx('studio.arrangeHelp', 'Choose a distribution, compare the real positions, then use it in the draft. Widgets keep their reading order and content.') }}</p></header>\n <div #arrangementFeedback tabindex=\"-1\" class=\"arrangement-feedback\">\n @if (arrangementPreview(); as preview) {\n <section class=\"arrangement-review\" aria-live=\"polite\" data-testid=\"arrangement-preview\">\n <div class=\"review-heading\"><h3>{{ tx('studio.reviewTitle', 'Compare before using') }}</h3><span>{{ preview.keys.length }} {{ tx('studio.changedWidgets', 'widgets change') }}</span></div>\n <div class=\"canvas-comparison\">\n @for (side of ['before', 'after']; track side) {\n <figure><figcaption>{{ side === 'before' ? tx('studio.before', 'Before') : tx('studio.after', 'After') }}</figcaption>\n <div class=\"canvas-map\" [style.grid-template-columns]=\"'repeat(' + preview.canvas.columns + ', minmax(0, 1fr))'\" [style.grid-template-rows]=\"'repeat(' + previewRowCount(preview.before, preview.canvas) + ', minmax(0, 1fr))'\" role=\"img\" [attr.aria-label]=\"side === 'before' ? tx('studio.before', 'Before') : tx('studio.after', 'After')\">\n @for (item of previewItems(side === 'before' ? preview.before : preview.canvas); track item.key) {\n <span [style.grid-column]=\"item.col + ' / span ' + item.colSpan\" [style.grid-row]=\"item.row + ' / span ' + item.rowSpan\" [class.changed]=\"preview.keys.includes(item.key)\" [matTooltip]=\"item.label\">{{ item.number }}</span>\n }\n </div>\n </figure>\n }\n </div>\n <p class=\"hint\">{{ tx('studio.mapHelp', 'Numbered map of your widgets. Positions and spans are to scale; content height is evaluated on the page.') }}</p>\n <details><summary>{{ tx('studio.widgetDetails', 'Widget positions and sizes') }}</summary><ol class=\"widget-changes\">@for (item of previewItems(preview.canvas); track item.key) {<li><strong>{{ item.label }}</strong><span>{{ tx('studio.before', 'Before') }}: {{ tx('editor.arrange.position', 'Column / row') }} {{ preview.before.items[item.key].col }} / {{ preview.before.items[item.key].row }} \u00B7 {{ tx('studio.gridSize', 'Size in columns \u00D7 rows') }} {{ preview.before.items[item.key].colSpan }} \u00D7 {{ preview.before.items[item.key].rowSpan }}</span><span>{{ tx('studio.after', 'After') }}: {{ tx('editor.arrange.position', 'Column / row') }} {{ item.col }} / {{ item.row }} \u00B7 {{ tx('studio.gridSize', 'Size in columns \u00D7 rows') }} {{ item.colSpan }} \u00D7 {{ item.rowSpan }}</span></li>}</ol></details>\n <button mat-flat-button type=\"button\" data-testid=\"arrangement-use\" (click)=\"useArrangement()\">{{ tx('editor.arrange.use', 'Use arrangement in draft') }}<mat-icon iconPositionEnd>arrow_forward</mat-icon></button>\n </section>\n }\n @if (arrangementMessage()) {<p class=\"studio-status\" role=\"status\">{{ arrangementMessage() }}</p>}\n @if (arrangementUndoAvailable()) {<button mat-stroked-button type=\"button\" data-testid=\"arrangement-undo\" (click)=\"undoArrangement()\"><mat-icon>undo</mat-icon>{{ tx('editor.arrange.undo', 'Undo arrangement') }}</button>}\n </div>\n <div class=\"studio-gallery arrangement-gallery\">\n @for (choice of arrangementChoices; track choice.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"arrangementSelection() === choice.id\" [attr.aria-pressed]=\"arrangementSelection() === choice.id\" [disabled]=\"!hasCanvasWidgets || !arrangementFits(choice.id)\" [attr.data-testid]=\"'arrange-' + choice.id\" (click)=\"prepareArrangement(choice.id)\">\n <span class=\"distribution-sample\" aria-hidden=\"true\" [style.grid-template-columns]=\"(arrangementFits(choice.id) ? arrangementWidths(choice.id) : choice.weights).join('fr ') + 'fr'\">@for (weight of choice.weights; track $index) {<i>{{ arrangementWidths(choice.id)[$index] }}</i>}@for (weight of choice.weights; track $index) {<i></i>}</span>\n <span class=\"choice-title\">{{ tx('editor.arrange.' + choice.id, choice.id) }}@if (arrangementSelection() === choice.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span>\n <span class=\"choice-description\">{{ tx('studio.arrange.' + choice.id, choice.id) }}</span>\n <span class=\"choice-meta\">{{ arrangementFits(choice.id) ? arrangementWidths(choice.id).join(' + ') : tx('studio.moreColumns', 'Needs more grid columns') }}</span>\n </button>\n }\n </div>\n @if (hasCanvasWidgets) {\n <details class=\"card quick-geometry-card\" data-testid=\"quick-widget-geometry-list\">\n <summary>{{ tx('studio.quickGeometryTitle', 'Quick widget spans (columns \u00D7 rows)') }}</summary>\n <div class=\"quick-geometry-rows\">\n @for (w of quickCanvasWidgets(); track w.key) {\n <div class=\"quick-geometry-row\" [attr.data-testid]=\"'quick-geometry-row-' + w.key\">\n <span class=\"quick-geometry-label\"><strong>{{ w.label }}</strong> <small>({{ w.colSpan }}\u00D7{{ w.rowSpan }})</small></span>\n <div class=\"studio-actions\">\n @for (span of [3, 4, 6, 12]; track span) {\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'quick-colspan-' + w.key + '-' + span\" (click)=\"setWidgetCanvasSpan(w.key, span)\">{{ span }}c</button>\n }\n @for (rSpan of [1, 2, 4]; track rSpan) {\n <button mat-button type=\"button\" [attr.data-testid]=\"'quick-rowspan-' + w.key + '-' + rSpan\" (click)=\"setWidgetCanvasSpan(w.key, w.colSpan, rSpan)\">{{ rSpan }}r</button>\n }\n </div>\n </div>\n }\n </div>\n </details>\n }\n @if (!hasCanvasWidgets) {<p role=\"status\">{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>}\n <details class=\"arrangement-rules\"><summary>{{ tx('studio.rules', 'Scope, devices and locked widgets') }}</summary><p>{{ tx('editor.arrange.help', 'Base canvas, in current reading order. Inheriting devices follow the base; explicit device positions are preserved. Locked positions stay in place.') }}</p><button mat-button type=\"button\" (click)=\"onSelectedTabIndexChange(5)\">{{ tx('studio.goLimits', 'Review widget limits') }}</button></details>\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.settingsTab', 'Adjustments')\">\n <div class=\"studio-panel\"><mat-tab-group class=\"editor-tabs\" [mat-stretch-tabs]=\"false\" [selectedIndex]=\"selectedTabIndex\" (selectedIndexChange)=\"selectDetailTab($event)\">\n <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 class=\"page-inset-field\" appearance=\"outline\"><mat-label>{{ tx('editor.layout.paddingInline', 'Page side inset') }}</mat-label><input matInput [formControl]=\"paddingInlineControl\" placeholder=\"24px\" /><button praxisIconButton=\"help_outline\" size=\"dense\" matSuffix type=\"button\" [matTooltip]=\"tx('editor.hints.paddingInline', 'Shared space beside all page widgets. Use a CSS length or theme token; 0px removes it. Leave empty to inherit the host theme.')\" [attr.aria-label]=\"tx('editor.layout.paddingInline', 'Page side inset')\"></button></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></div></mat-tab>\n <mat-tab [label]=\"tx('editor.tabs.grouping', 'Grouping')\"><div class=\"panel\">\n <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>\n <mat-tab [label]=\"tx('editor.tabs.devices', 'Device Layouts')\">\n <div class=\"panel\">\n <mat-button-toggle-group [value]=\"activeDevice()\" (change)=\"activeDevice.set($event.value)\" [attr.aria-label]=\"tx('editor.devices.target', 'Device to configure')\">\n @for (device of deviceKeys; track device) {\n <mat-button-toggle [value]=\"device\">{{ deviceLabels[device] }}</mat-button-toggle>\n }\n </mat-button-toggle-group>\n @for (device of deviceKeys; track device) {\n @if (activeDevice() === device) {\n <div class=\"card\">\n <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>\n <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>\n @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> }\n <div class=\"grid\">\n <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>\n <mat-form-field class=\"page-inset-field\" appearance=\"outline\"><mat-label>{{ tx('editor.layout.paddingInline', 'Page side inset') }}</mat-label><input matInput [formControl]=\"deviceForms[device].controls.paddingInline\" placeholder=\"16px\" /></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>\n <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>\n </div>\n <details><summary>{{ tx('editor.devices.advanced', 'Advanced overrides') }}</summary><div class=\"grid\">\n <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>\n <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>\n <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-hint>{{ tx('editor.devices.widgetOverridesHelp', 'Supported per-widget properties on this device: className, span, order, and hidden. Does not override internal widget definition or inputs.') }}</mat-hint></mat-form-field>\n </div></details>\n @if (!stackedDevices.has(device)) { <div class=\"hint\">{{ tx('editor.preview.deviceCanvasHint', 'Existing canvas item overrides are preserved.') }}</div> }\n </div>\n }\n }\n </div>\n </mat-tab>\n <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>\n <mat-tab [label]=\"tx('editor.tabs.state', 'Page State')\">\n <div class=\"panel\">\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.stateDefinitionJson', 'Page state (values, schema, derived \u2014 JSON)') }}</mat-label>\n <textarea matInput rows=\"10\" [formControl]=\"stateControl\" data-testid=\"page-state-json\"></textarea>\n </mat-form-field>\n\n <section class=\"panel\" data-testid=\"page-datasources-guided\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.dataSourcesTitle', 'Data Sources') }}</strong>\n <button mat-stroked-button type=\"button\" data-testid=\"page-datasources-add\" (click)=\"addDataSourceNode()\">\n <mat-icon>add</mat-icon>{{ tx('editor.state.addDataSource', 'Add data source') }}\n </button>\n </div>\n <p class=\"hint\">{{ tx('editor.state.dataSourcesCopy', 'Bind HTTP or resource endpoints directly into page state paths with reactive parameters.') }}</p>\n @if (!dataSourceNodes.controls.length) {\n <p class=\"hint\" data-testid=\"page-datasources-empty\">{{ tx('editor.state.emptyDataSources', 'No declarative data sources configured.') }}</p>\n }\n @for (ds of dataSourceNodes.controls; track $index) {\n <div class=\"card\" [formGroup]=\"ds\" [attr.data-testid]=\"'page-datasource-card-' + $index\">\n <div class=\"head\">\n <span>{{ dataSourceLabel($index) }}</span>\n <button\n praxisIconButton=\"delete\"\n size=\"compact\"\n type=\"button\"\n [attr.data-testid]=\"'page-datasource-remove-' + $index\"\n [attr.aria-label]=\"tx('editor.state.removeDataSourceAria', 'Remove data source')\"\n (click)=\"removeDataSourceNode($index)\"\n ></button>\n </div>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.dataSourceId', 'ID') }}</mat-label>\n <input matInput formControlName=\"id\" [attr.data-testid]=\"'page-datasource-id-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.dataSourceKind', 'Kind') }}</mat-label>\n <mat-select formControlName=\"kind\" [attr.data-testid]=\"'page-datasource-kind-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"remote-resource\">remote-resource</mat-option>\n <mat-option value=\"stats-query\">stats-query</mat-option>\n <mat-option value=\"raw-http\">raw-http</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.resourcePath', 'Resource path') }}</mat-label>\n <input matInput formControlName=\"resourcePath\" [attr.data-testid]=\"'page-datasource-resourcePath-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.endpointKey', 'Endpoint key') }}</mat-label>\n <input matInput formControlName=\"endpointKey\" [attr.data-testid]=\"'page-datasource-endpointKey-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.method', 'HTTP method') }}</mat-label>\n <mat-select formControlName=\"method\" [attr.data-testid]=\"'page-datasource-method-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.auto', 'Auto') }}</mat-option>\n <mat-option value=\"GET\">GET</mat-option>\n <mat-option value=\"POST\">POST</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.targetStatePath', 'Target state path') }}</mat-label>\n <input matInput formControlName=\"targetStatePath\" [attr.data-testid]=\"'page-datasource-targetStatePath-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.errorPolicy', 'Error policy') }}</mat-label>\n <mat-select formControlName=\"errorPolicy\" [attr.data-testid]=\"'page-datasource-errorPolicy-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"propagate\">propagate</mat-option>\n <mat-option value=\"silent\">silent</mat-option>\n <mat-option value=\"fallback\">fallback</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.refreshIntervalMs', 'Refresh interval (ms)') }}</mat-label>\n <input matInput type=\"number\" min=\"0\" formControlName=\"refreshIntervalMs\" [attr.data-testid]=\"'page-datasource-refreshIntervalMs-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.autoFetch', 'Auto fetch') }}</mat-label>\n <mat-select formControlName=\"autoFetch\" [attr.data-testid]=\"'page-datasource-autoFetch-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"true\">true</mat-option>\n <mat-option value=\"false\">false</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.description', 'Description') }}</mat-label>\n <input matInput formControlName=\"description\" [attr.data-testid]=\"'page-datasource-description-' + $index\" />\n </mat-form-field>\n <div class=\"wide guided-presets-bar\" [attr.data-testid]=\"'page-datasource-guided-presets-' + $index\">\n <span class=\"hint\">{{ tx('editor.state.guidedPresets', 'Guided query & transform templates:') }}</span>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-groupby-' + $index\" (click)=\"applyGuidedDataSourceQuery($index, { mode: 'group-by', groupByField: 'ativo', metricField: 'id', metricOp: 'count' }); applyGuidedDataSourceTransform($index, { kind: 'group-by-map', totalField: 'total', mappingsText: 'true:active, false:inactive', ratioTargetField: 'activeRatePct', ratioNumeratorField: 'active', ratioDenominatorField: 'total', ratioScale: 100, ratioDecimals: 1 })\">\n {{ tx('editor.state.presetGroupByKpi', 'Group-by + Total + Ratio (%)') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-weighted-' + $index\" (click)=\"applyGuidedDataSourceTransform($index, { kind: 'group-by-map', totalField: 'total', weightedAverageField: 'weightedAvg', weightedAverageDecimals: 2, mappingsText: '1:low, 5:high' })\">\n {{ tx('editor.state.presetWeightedAvg', 'Weighted average') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-timeseries-' + $index\" (click)=\"applyGuidedDataSourceQuery($index, { mode: 'timeseries', groupByField: 'createdAt', period: 'month', metricField: 'id', metricOp: 'count' }); applyGuidedDataSourceTransform($index, { kind: 'timeseries-last', mappingsText: 'count:latestCount, avg:latestAvg' })\">\n {{ tx('editor.state.presetTimeseriesLast', 'Timeseries latest point') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-path-' + $index\" (click)=\"applyGuidedDataSourceTransform($index, { kind: 'path', path: 'totalElements' })\">\n {{ tx('editor.state.presetPathExtract', 'Extract path (totalElements)') }}\n </button>\n </div>\n </div>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.queryJson', 'Query (JSON)') }}</mat-label>\n <textarea matInput rows=\"3\" formControlName=\"queryJson\" [attr.data-testid]=\"'page-datasource-queryJson-' + $index\"></textarea>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.transformJson', 'Transform (JSON)') }}</mat-label>\n <textarea matInput rows=\"3\" formControlName=\"transformJson\" [attr.data-testid]=\"'page-datasource-transformJson-' + $index\"></textarea>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.fallbackJson', 'Fallback value (JSON)') }}</mat-label>\n <textarea matInput rows=\"2\" formControlName=\"fallbackJson\" [attr.data-testid]=\"'page-datasource-fallbackJson-' + $index\"></textarea>\n </mat-form-field>\n </div>\n @let preview = getDataSourcePreview($index);\n <div class=\"datasource-preview-box\" [attr.data-testid]=\"'page-datasource-preview-' + $index\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.previewTitle', 'Transform & State Preview') }}</strong>\n <span class=\"previous-height-chip\">{{ preview.sourceKind === 'live' ? tx('editor.state.previewLive', 'Live response') : tx('editor.state.previewSample', 'Simulated sample') }}</span>\n </div>\n @if (preview.status === 'error') {\n <p class=\"limits-error\" role=\"alert\" [attr.data-testid]=\"'page-datasource-preview-error-' + $index\">{{ preview.error }}</p>\n } @else {\n <pre class=\"datasource-preview-json\" [attr.data-testid]=\"'page-datasource-preview-json-' + $index\">{{ preview.formattedJson }}</pre>\n @if (preview.tokens.length) {\n <div class=\"studio-actions\" [attr.data-testid]=\"'page-datasource-preview-tokens-' + $index\">\n <span class=\"hint\">{{ tx('editor.state.availableTokens', 'Click a token to prepare widget binding:') }}</span>\n @for (token of preview.tokens; track token) {\n <button mat-button type=\"button\" (click)=\"binderTokenControl.setValue(token)\"><code>{{ token }}</code></button>\n }\n </div>\n }\n }\n </div>\n </div>\n }\n </section>\n\n <section class=\"card\" data-testid=\"page-derived-measures-guided\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.derivedMeasuresTitle', 'Guided Derived Measures') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.state.derivedMeasuresCopy', 'Create percentages, ratios, weighted averages, or formatted measures from page state paths without writing raw JsonLogic.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedTargetPath', 'Measure key') }}</mat-label>\n <input matInput [formControl]=\"derivedPathControl\" placeholder=\"activeRatePct\" data-testid=\"derived-target-path\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedKind', 'Calculation') }}</mat-label>\n <mat-select [formControl]=\"derivedKindControl\" data-testid=\"derived-kind\">\n <mat-option value=\"ratio-percent\">{{ tx('editor.state.derivedRatio', 'Ratio / Percentage') }}</mat-option>\n <mat-option value=\"weighted-average\">{{ tx('editor.state.derivedWeighted', 'Weighted average') }}</mat-option>\n <mat-option value=\"format-value\">{{ tx('editor.state.derivedFormat', 'Formatted value') }}</mat-option>\n <mat-option value=\"template\">{{ tx('editor.state.derivedTemplate', 'State reference') }}</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedNumerator', 'Numerator / Source path') }}</mat-label>\n <input matInput [formControl]=\"derivedNumeratorControl\" placeholder=\"kpis.active\" data-testid=\"derived-numerator\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedDenominator', 'Denominator path') }}</mat-label>\n <input matInput [formControl]=\"derivedDenominatorControl\" placeholder=\"kpis.total\" data-testid=\"derived-denominator\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedFormatKind', 'Format') }}</mat-label>\n <mat-select [formControl]=\"derivedFormatControl\" data-testid=\"derived-format\">\n <mat-option value=\"percent\">percent (%)</mat-option>\n <mat-option value=\"number\">number</mat-option>\n <mat-option value=\"integer\">integer</mat-option>\n <mat-option value=\"currency\">currency (R$)</mat-option>\n <mat-option value=\"boolean\">boolean</mat-option>\n </mat-select>\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" data-testid=\"derived-add-btn\" (click)=\"addGuidedDerivedMeasure()\">\n <mat-icon aria-hidden=\"true\">functions</mat-icon>{{ tx('editor.state.addDerivedAction', 'Add measure to page state') }}\n </button>\n </div>\n @let derivedPreview = getDerivedStatePreview();\n @if (derivedPreview.entries.length || derivedPreview.diagnostics.length) {\n <div class=\"datasource-preview-box\" data-testid=\"page-derived-preview\">\n @for (diag of derivedPreview.diagnostics; track diag) {\n <p class=\"limits-error\" role=\"alert\">{{ diag }}</p>\n }\n @if (derivedPreview.entries.length) {\n <div class=\"studio-actions\">\n @for (entry of derivedPreview.entries; track entry.key) {\n <button mat-button type=\"button\" (click)=\"binderTokenControl.setValue(entry.token)\">\n <strong>{{ entry.key }}</strong>: {{ entry.formattedValue }} <code>({{ entry.token }})</code>\n </button>\n }\n </div>\n }\n </div>\n }\n </section>\n\n <section class=\"card\" data-testid=\"page-state-component-binder\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.binderTitle', 'Connect State or Measure to Widget') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.state.binderCopy', 'Bind any state path or derived measure directly to a widget input in the draft.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderToken', 'State token') }}</mat-label>\n <input matInput [formControl]=\"binderTokenControl\" placeholder=\"{{ '{{state.kpis.total}}' }}\" data-testid=\"binder-token\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderWidget', 'Target widget') }}</mat-label>\n <mat-select [formControl]=\"binderWidgetControl\" data-testid=\"binder-widget\">\n @for (w of bindablePageWidgets(); track w.key) {\n <mat-option [value]=\"w.key\">{{ w.title }} ({{ w.key }})</mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderInputPath', 'Widget input path') }}</mat-label>\n <input matInput [formControl]=\"binderInputPathControl\" placeholder=\"value\" data-testid=\"binder-input-path\" />\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" data-testid=\"binder-bind-btn\" (click)=\"bindStateTokenToWidget()\">\n <mat-icon aria-hidden=\"true\">link</mat-icon>{{ tx('editor.state.bindAction', 'Bind token to widget in draft') }}\n </button>\n <button mat-stroked-button type=\"button\" data-testid=\"binder-auto-bind-btn\" (click)=\"autoBindWidgetStatePlaceholders()\">\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>{{ tx('editor.state.autoBindAction', 'Auto-bind ${...} widget placeholders') }}\n </button>\n </div>\n @if (binderMessage()) {\n <p class=\"studio-status\" role=\"status\" data-testid=\"binder-status-message\">{{ binderMessage() }}</p>\n }\n @if (currentWidgetStateBindings().length) {\n <ul class=\"blocked-widgets-list\" data-testid=\"active-widget-state-bindings\">\n @for (binding of currentWidgetStateBindings(); track binding.widgetKey + ':' + binding.inputPath) {\n <li>\n <strong>{{ binding.widgetTitle }}</strong>.<code>{{ binding.inputPath }}</code> \u2190 <code>{{ binding.token }}</code>\n <button mat-icon-button type=\"button\" [attr.data-testid]=\"'binder-unbind-' + binding.widgetKey + '-' + binding.inputPath\"\n [attr.aria-label]=\"tx('editor.state.unbindAction', 'Remove binding')\"\n (click)=\"unbindStateTokenFromWidget(binding.widgetKey, binding.inputPath)\">\n <mat-icon aria-hidden=\"true\">link_off</mat-icon>\n </button>\n </li>\n }\n </ul>\n }\n </section>\n\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.dataSourcesJson', 'page.dataSources (JSON)') }}</mat-label>\n <textarea matInput rows=\"8\" [formControl]=\"dataSourcesControl\" [placeholder]=\"tx('editor.hints.dataSourcesExample', '')\" data-testid=\"page-datasources-json\"></textarea>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab [label]=\"tx('editor.limits.tab', 'Widget limits')\">\n <section class=\"limits-panel\" data-testid=\"canvas-limits-editor\">\n @if (workspaceTab() === 3 && activeTab() === 'limits' && canvasLimits.targets.length) {\n <div class=\"selection-identity\" tabindex=\"-1\" data-testid=\"canvas-limits-identity\">\n <mat-icon aria-hidden=\"true\">dashboard_customize</mat-icon>\n <strong>{{ limitsTargetLabel(limitsTargetControl.value) }}</strong>\n </div>\n }\n\n <p class=\"hint\" id=\"canvas-limits-help\">{{ tx('editor.limits.help', 'Limits use grid columns and rows, not pixels. Leave blank for defaults, or to inherit the base limits on a device.') }}</p>\n @if (canvasLimits.targets.length) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.target', 'Widget and layout') }}</mat-label>\n <mat-select [formControl]=\"limitsTargetControl\" data-testid=\"canvas-limits-target\">\n @for (target of canvasLimits.targets; track target.scope + ':' + target.key; let index = $index) {\n <mat-option [value]=\"index\">{{ limitsTargetLabel(index) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n <p class=\"hint\">{{ limitsGeometryLabel() }}</p>\n @if (canvasLimits.form.controls[limitsTargetControl.value]; as limitsForm) {\n <div class=\"limits-fields\" aria-describedby=\"canvas-limits-help\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.minColSpan', 'Minimum width (columns)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.minColSpan\" data-testid=\"canvas-limit-minColSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minColSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.maxColSpan', 'Maximum width (columns)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.maxColSpan\" data-testid=\"canvas-limit-maxColSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxColSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.minRowSpan', 'Minimum height (rows)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.minRowSpan\" data-testid=\"canvas-limit-minRowSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minRowSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.maxRowSpan', 'Maximum height (rows)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.maxRowSpan\" data-testid=\"canvas-limit-maxRowSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxRowSpan) }}</mat-error>\n </mat-form-field>\n </div>\n @if (limitsUseContentHeight()) {\n <p class=\"limits-notice\"><mat-icon aria-hidden=\"true\">height</mat-icon><span>{{ tx('editor.limits.autoHeight', 'Choose natural height or an adjustable height with internal scrolling.') }}</span></p>\n <mat-button-toggle-group class=\"height-modes\" [formControl]=\"limitsForm.controls.heightMode\" [attr.aria-label]=\"tx('editor.height.mode', 'Widget height')\" data-testid=\"canvas-height-mode\">\n <mat-button-toggle value=\"inherit\">{{ tx('editor.height.inherit', 'Default') }}</mat-button-toggle>\n <mat-button-toggle value=\"auto\">{{ tx('editor.height.auto', 'Automatic') }}</mat-button-toggle>\n <mat-button-toggle value=\"fixed\">{{ tx('editor.height.fixed', 'Adjustable') }}</mat-button-toggle>\n </mat-button-toggle-group>\n @if (limitsForm.controls.heightMode.value === 'fixed') {\n <div class=\"limits-fields\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.value', 'Height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.heightPx\" data-testid=\"canvas-height-px\">\n <mat-error>{{ limitFieldError(limitsForm.controls.heightPx) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.top', 'Top inset (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"0\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.offsetTopPx\" data-testid=\"canvas-height-top\">\n <mat-error>{{ limitFieldError(limitsForm.controls.offsetTopPx) }}</mat-error>\n </mat-form-field>\n </div>\n }\n <div class=\"limits-fields\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.min', 'Minimum height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.minHeightPx\" data-testid=\"canvas-limit-minHeightPx\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minHeightPx) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.max', 'Maximum height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.maxHeightPx\" data-testid=\"canvas-limit-maxHeightPx\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxHeightPx) }}</mat-error>\n </mat-form-field>\n </div>\n }\n }\n @if (limitsSummaryError) { <p class=\"limits-error\" role=\"alert\">{{ limitsSummaryError }}</p> }\n } @else {\n <p>{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>\n }\n </section>\n </mat-tab>\n </mat-tab-group></div>\n </mat-tab>\n </mat-tab-group>\n <details class=\"summary-details\">\n <summary>{{ tx('editor.summaryAria', 'Configuration summary') }}</summary>\n <div class=\"summary\">\n @if (!isHostedInSettingsPanel) {\n <span>{{ validationStateLabel }}</span>\n <span>{{ dirtyStateLabel }}</span>\n }\n <span>{{ selectedLayoutPresetLabel }}</span>\n <span>{{ selectedThemePresetLabel }}</span>\n <span>{{ canvasSummaryLabel }}</span>\n </div>\n </details>\n </div>\n", styles: [".page-inset-field{grid-column:1/-1;min-width:0}: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{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{align-items:stretch}.head>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)}.limits-blocking-banner{border-color:var(--md-sys-color-error, #b3261e);gap:8px}.blocked-widgets-list{margin:0;padding-inline-start:20px;display:grid;gap:4px}.previous-heights-summary{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font:var(--mat-sys-body-small, inherit);color:var(--md-sys-color-on-surface-variant, currentColor)}.previous-height-chip{padding:2px 8px;border-radius:999px;background:var(--md-sys-color-surface-container-highest, rgba(128, 128, 128, .14))}.wide{grid-column:1/-1}.hint{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px}.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}:host{container-type:inline-size}.studio-panel{display:grid;gap:var(--p-space-4, 16px);padding-block:var(--p-space-5, 20px);min-width:0}.studio-heading h2{font-size:var(--p-font-size-lg, 20px);line-height:1.25;margin:var(--p-space-2, 8px) 0;font-weight:600}.studio-heading p{margin:0;color:var(--md-sys-color-on-surface-variant);line-height:1.5}.studio-panel h3{font-size:var(--p-font-size-md, 16px);margin:0}.studio-panel>button{justify-self:start}.studio-gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--p-space-3, 12px)}.studio-choice{display:flex;flex-direction:column;gap:var(--p-space-2, 8px);min-width:0;padding:var(--p-space-3, 12px);text-align:start;border:1px solid var(--md-sys-color-outline-variant);border-radius:var(--p-radius-lg, 12px);background:var(--md-sys-color-surface);color:var(--md-sys-color-on-surface);font:inherit;cursor:pointer;transition:border-color var(--md-sys-motion-duration-short2, .1s),background-color var(--md-sys-motion-duration-short2, .1s)}.studio-choice:hover:not(:disabled){border-color:var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low)}.studio-choice.selected{border-color:var(--md-sys-color-primary);box-shadow:inset 0 0 0 1px var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low)}.studio-choice:focus-visible,.studio-panel summary:focus-visible{outline:3px solid var(--md-sys-color-primary);outline-offset:3px}.studio-choice:disabled{opacity:var(--p-disabled-opacity, .5);cursor:not-allowed}.choice-title{display:flex;align-items:center;justify-content:space-between;gap:var(--p-space-2, 8px);width:100%;font-weight:600;line-height:1.35}.choice-title mat-icon{color:var(--md-sys-color-primary);flex-shrink:0;font-size:20px;height:20px;width:20px}.choice-description{font-size:var(--p-font-size-sm, 13px);line-height:1.5;color:var(--md-sys-color-on-surface-variant)}.choice-meta{font-size:var(--p-font-size-xs, 12px);color:var(--md-sys-color-on-surface-variant);margin-top:auto;padding-top:var(--p-space-1, 4px)}.theme-sample{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));padding:var(--p-space-3, 12px);border-radius:var(--p-radius-md, 8px);min-height:128px;align-self:stretch;margin-bottom:var(--p-space-1, 4px)}.sample-metric,.sample-chart{background:var(--sample-surface);border-radius:var(--sample-radius);box-shadow:var(--sample-shadow);padding:var(--p-space-2, 8px)}.sample-metric b{display:block;font-size:var(--p-font-size-md, 16px)}.sample-metric i{display:block;height:3px;width:55%;margin-top:var(--p-space-1, 4px);background:var(--sample-accent)}.sample-chart{grid-column:1/-1;display:flex;height:40px;align-items:flex-end;gap:var(--p-space-2, 8px)}.sample-chart i{flex:1;background:var(--sample-accent);height:50%;border-radius:var(--p-radius-xs, 2px)}.sample-chart i:nth-child(2){height:85%}.sample-chart i:nth-child(3){height:65%}.sample-chart i:nth-child(4){height:100%}.sample-chart i:nth-child(5){height:75%}.shell-options,.arrangement-rules{border-top:1px solid var(--md-sys-color-outline-variant);padding-top:var(--p-space-3, 12px)}.studio-panel summary{cursor:pointer;font-weight:500;line-height:1.5;padding-block:var(--p-space-2, 8px)}.shell-gallery{margin-top:var(--p-space-4, 16px)}.shell-sample{display:block;align-self:stretch;border:1px solid;overflow:hidden;margin:var(--p-space-2, 8px)}.shell-sample>span{display:flex;gap:var(--p-space-2, 8px);align-items:center;padding:var(--p-space-2, 8px);border-bottom:1px solid transparent}.shell-sample i{height:4px;width:60%;background:var(--md-sys-color-on-surface-variant);opacity:.4}.shell-sample>span:last-child{min-height:30px;flex-direction:column;align-items:flex-start}.shell-sample>span:last-child i:last-child{width:40%}.selection-line{margin:0;font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-on-surface-variant)}.studio-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--p-space-2, 8px)}.distribution-sample{display:grid;grid-template-rows:repeat(2,1fr);gap:var(--p-space-1, 4px);align-self:stretch;height:76px;padding:var(--p-space-2, 8px);background:var(--md-sys-color-surface-container);border-radius:var(--p-radius-md, 8px)}.distribution-sample i{display:grid;place-items:center;background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container);border-radius:var(--p-radius-sm, 4px);font-size:var(--p-font-size-xs, 12px);font-style:normal}.distribution-sample i:nth-child(n+4){opacity:.65}.selected .distribution-sample i{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.arrangement-review{display:grid;gap:var(--p-space-3, 12px);padding-block:var(--p-space-4, 16px);border-top:2px solid var(--md-sys-color-primary)}.review-heading{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:var(--p-space-2, 8px)}.review-heading>span{font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-primary)}.canvas-comparison{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--p-space-3, 12px)}.canvas-comparison figure{margin:0;min-width:0}.canvas-comparison figcaption{font-weight:500;margin-bottom:var(--p-space-2, 8px)}.canvas-map{display:grid;gap:2px;height:220px;padding:var(--p-space-2, 8px);border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container-low);border-radius:var(--p-radius-md, 8px)}.canvas-map span{display:grid;place-items:center;min-height:0;min-width:0;overflow:hidden;font-size:var(--p-font-size-xs, 12px);color:var(--md-sys-color-on-secondary-container);background:var(--md-sys-color-secondary-container);border-radius:var(--p-radius-xs, 2px)}.canvas-map .changed{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.widget-changes{padding-inline-start:var(--p-space-5, 20px);margin:0}.widget-changes li{padding-block:var(--p-space-2, 8px);overflow-wrap:anywhere}.widget-changes span{display:block;font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-on-surface-variant)}.studio-status{border-inline-start:3px solid var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low);padding:var(--p-space-3, 12px);margin:0;line-height:1.5}@container (max-width:420px){.studio-gallery{grid-template-columns:minmax(0,1fr)}.canvas-map{height:180px}}@media(prefers-reduced-motion:reduce){.studio-choice{transition:none}}.arrangement-feedback:empty{display:none}.arrangement-feedback:focus{outline:none}.arrangement-feedback:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:3px}@container (min-width:620px){.arrangement-gallery{grid-template-columns:repeat(3,minmax(0,1fr))}}:host ::ng-deep .studio-tabs>.mat-mdc-tab-header{position:sticky;top:0;z-index:var(--praxis-layer-sticky-local, 100);background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface))}:host ::ng-deep .studio-tabs>.mat-mdc-tab-header .mdc-tab{flex:1 1 0;padding-inline:var(--p-space-1, 4px)}.arrangement-feedback{scroll-margin-block-start:var(--p-space-16, 64px)}.filter-owner-banner{border-color:var(--md-sys-color-tertiary, var(--md-sys-color-primary))}.filter-owner-item{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px}.quick-geometry-rows{display:grid;gap:8px}.quick-geometry-row{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px;padding-block:4px;border-bottom:1px solid var(--md-sys-color-outline-variant, rgba(128, 128, 128, .2))}.guided-presets-bar{display:grid;gap:6px}.datasource-preview-box{display:grid;gap:8px;padding:12px;border-radius:6px;background:var(--md-sys-color-surface-container, rgba(128, 128, 128, .08))}.datasource-preview-json{margin:0;padding:8px;border-radius:4px;font-size:12px;overflow-x:auto;background:var(--md-sys-color-surface, rgba(0, 0, 0, .04))}\n"] }]
|
|
17651
|
+
args: [{ selector: 'praxis-dynamic-page-config-editor', standalone: true, imports: [PagePresetComparisonComponent, ReactiveFormsModule, MatAutocompleteModule, MatButtonModule, MatButtonToggleModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, MatTabsModule, MatTooltipModule, PraxisIconButtonComponent, ResourceQuickConnectComponent], providers: [providePraxisPageBuilderI18n()], changeDetection: ChangeDetectionStrategy.OnPush, host: { '(window:resize)': 'refreshRenderedValidation()' }, template: "<div class=\"editor\">\n @if (!isHostedInSettingsPanel) { <div class=\"head\">\n <div>{{ tx('editor.title', 'Page settings') }}</div>\n <button mat-stroked-button type=\"button\" (click)=\"reset()\">{{ tx('editor.reload', 'Reload') }}</button>\n </div> }\n\n @if (nonLimitError) { <p class=\"limits-error\" role=\"alert\">{{ nonLimitError }}</p> }\n @if (limitsError() || previousAdjustableHeights().length) {\n <section class=\"card limits-blocking-banner\" role=\"region\" [attr.aria-label]=\"tx('editor.limits.blockedBannerTitle', 'Height geometry requires review in Widget limits')\" data-testid=\"preset-geometry-blocking-banner\">\n @if (limitsError()) {\n <div class=\"head\">\n <strong>{{ tx('editor.limits.blockedBannerTitle', 'Height geometry requires review in Widget limits') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.limits.blockedBannerHelp', 'One or more widgets use an adjustable pixel height that exceeds the current usable area or requires review before applying the page or template:') }}</p>\n <ul class=\"blocked-widgets-list\">\n @for (item of blockedGeometryWidgets; track item.scope + ':' + item.key) {\n <li>\n <strong>{{ item.title }}</strong>\n @if (item.title !== item.key && !item.title.endsWith('(' + item.key + ')')) { <code>({{ item.key }})</code> }\n <span> \u00B7 {{ item.scopeLabel }}</span>\n @if (item.heightPx !== null) {\n <span> \u00B7 {{ tx('editor.height.fixed', 'Adjustable') }}: <strong>{{ item.heightPx }}px</strong></span>\n }\n </li>\n }\n </ul>\n <p class=\"limits-error\" role=\"alert\">{{ limitsError() }}</p>\n <div class=\"head\">\n <button mat-stroked-button type=\"button\" data-testid=\"goto-blocked-widget-limits\" (click)=\"navigateToBlockedLimit()\">\n <mat-icon aria-hidden=\"true\">tune</mat-icon>{{ tx('editor.limits.gotoLimitsAction', 'Go to Adjustments \u2192 Widget limits') }}\n </button>\n <button mat-flat-button type=\"button\" data-testid=\"switch-blocked-widgets-auto-height\" (click)=\"switchBlockedWidgetsToAutoHeight()\">\n <mat-icon aria-hidden=\"true\">height</mat-icon>{{ tx('editor.limits.switchToAutoHeightAction', 'Switch affected widgets to Automatic height') }}\n </button>\n </div>\n }\n @if (previousAdjustableHeights().length) {\n <div class=\"previous-heights-summary\" data-testid=\"previous-adjustable-heights-summary\">\n <span>{{ tx('editor.limits.previousHeightsSummary', 'Previous adjustable heights preserved for reference:') }}</span>\n @for (prev of previousAdjustableHeights(); track prev.scope + ':' + prev.key) {\n <span class=\"previous-height-chip\">{{ prev.title }} ({{ prev.scopeLabel }}): <strong>{{ prev.heightPx }}px</strong></span>\n }\n </div>\n }\n </section>\n }\n <mat-tab-group class=\"editor-tabs studio-tabs\" [mat-stretch-tabs]=\"false\" [selectedIndex]=\"workspaceTab()\" (selectedIndexChange)=\"workspaceTab.set($event)\">\n <mat-tab [label]=\"tx('studio.visualTab', 'Visual templates')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"studio-visual\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.visualEyebrow', 'Appearance') }}</span><h2>{{ tx('studio.visualTitle', 'Give the page a visual language') }}</h2><p>{{ tx('studio.visualHelp', 'Choose surfaces, density and chart styling together. Individual widget overrides still take precedence.') }}</p></header>\n <button mat-stroked-button type=\"button\" [attr.aria-pressed]=\"!themePresetControl.value\" (click)=\"themePresetControl.setValue('')\">{{ tx('editor.layout.layoutDefault', 'Layout default') }}</button>\n <p class=\"selection-line\">{{ tx('presets.effectiveTheme', 'Effective page theme') }}: <strong>{{ selectedThemePresetEntry?.label || themePresetControl.value || recommendedThemePresetEntry?.label || tx('presets.hostTheme', 'Host theme') }}</strong></p>\n <div class=\"studio-gallery\">\n @for (theme of themePresetEntries; track theme.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"themePresetControl.value === theme.id\" [attr.aria-pressed]=\"themePresetControl.value === theme.id\" [attr.data-theme-preset]=\"theme.id\" (click)=\"themePresetControl.setValue(theme.id)\">\n <span class=\"theme-sample\" aria-hidden=\"true\" [style.background]=\"themeTokens(theme.id)['--pdx-page-bg']\" [style.--sample-accent]=\"themeTokens(theme.id)['--pdx-page-accent']\" [style.--sample-surface]=\"themeTokens(theme.id)['--pdx-page-surface']\" [style.--sample-radius]=\"themeTokens(theme.id)['--pdx-page-card-radius']\" [style.--sample-shadow]=\"themeTokens(theme.id)['--pdx-page-card-shadow']\" [style.gap]=\"themeTokens(theme.id)['--pdx-page-gap']\">\n <span class=\"sample-metric\"><b>128</b><i></i></span><span class=\"sample-metric\"><b>86%</b><i></i></span><span class=\"sample-chart\"><i></i><i></i><i></i><i></i><i></i></span>\n </span>\n <span class=\"choice-title\">{{ theme.label }}@if (themePresetControl.value === theme.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span>\n <span class=\"choice-description\">{{ tx('studio.theme.' + theme.id, theme.label) }}</span>\n <span class=\"choice-meta\">{{ tx('studio.density.' + theme.density, theme.density) }} \u00B7 {{ shellName(theme.shellPreset) }}</span>\n </button>\n }\n </div>\n <p class=\"hint\">{{ tx('studio.samplesHelp', 'Illustrative samples using the preset tokens. Apply to see the result with your content.') }}</p>\n <details class=\"shell-options\"><summary>{{ tx('studio.shellTitle', 'Customize widget surfaces') }} \u00B7 {{ shellPresetEntries.length }}</summary>\n <p>{{ tx('studio.shellHelp', 'Override the page theme\u2019s default widget surface. Per-widget appearance settings keep their priority.') }}</p>\n <button mat-stroked-button type=\"button\" [attr.aria-pressed]=\"!shellPresetControl.value\" (click)=\"shellPresetControl.setValue('')\">{{ tx('editor.layout.inheritShell', 'Inherit appearance') }}</button>\n <div class=\"studio-gallery shell-gallery\">@for (shell of shellPresetEntries; track shell.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"shellPresetControl.value === shell.id\" [attr.aria-pressed]=\"shellPresetControl.value === shell.id\" (click)=\"shellPresetControl.setValue(shell.id)\">\n <span class=\"shell-sample\" aria-hidden=\"true\" [style.background]=\"shell.appearance.card?.background\" [style.border-color]=\"shell.appearance.card?.borderColor\" [style.border-radius]=\"shell.appearance.card?.borderRadius\" [style.box-shadow]=\"shell.appearance.card?.shadow\"><span [style.background]=\"shell.appearance.header?.background\" [style.border-color]=\"shell.appearance.header?.borderColor\"><mat-icon>analytics</mat-icon><i></i></span><span [style.background]=\"shell.appearance.body?.background\"><i></i><i></i></span></span>\n <span class=\"choice-title\">{{ shellPresetLabel(shell) }}@if (shellPresetControl.value === shell.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span><span class=\"choice-description\">{{ shellDescription(shell) }}</span>\n </button>\n }</div>\n </details>\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.layoutTab', 'Layout templates')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"studio-layout\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.layoutEyebrow', 'Composition model') }}</span><h2>{{ tx('studio.layoutTitle', 'Choose the structure for your use case') }}</h2><p>{{ tx('studio.layoutHelp', 'Compare the roles and default theme of each model. To redistribute the widgets already on this canvas, open Arrange.') }}</p></header>\n @if (duplicateFilterOwners().length) {\n <section class=\"card filter-owner-banner\" role=\"region\" [attr.aria-label]=\"tx('studio.filterOwnerBannerTitle', 'Duplicate filter region detected')\" data-testid=\"duplicate-filter-owner-banner\">\n <div class=\"head\">\n <strong>{{ tx('studio.filterOwnerBannerTitle', 'Single filter region recommendation') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('studio.filterOwnerBannerHelp', 'A table has both its integrated filter bar and an external filter link active. Choose a single query owner to avoid competing filter regions:') }}</p>\n @for (item of duplicateFilterOwners(); track item.tableKey) {\n <div class=\"filter-owner-item\" [attr.data-testid]=\"'duplicate-filter-owner-' + item.tableKey\">\n <span><strong>{{ item.tableTitle }}</strong> <code>({{ item.tableKey }})</code> \u2190 {{ item.externalSourceLabels.join(', ') }}</span>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'consolidate-filter-table-toolbar-' + item.tableKey\" (click)=\"consolidateFilterOwnership(item.tableKey, 'keep-table-toolbar')\">\n {{ tx('studio.keepTableToolbarFilter', 'Use integrated table filter') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'consolidate-filter-external-link-' + item.tableKey\" (click)=\"consolidateFilterOwnership(item.tableKey, 'keep-external-link')\">\n {{ tx('studio.keepExternalFilterLink', 'Use external filter bar') }}\n </button>\n </div>\n </div>\n }\n </section>\n }\n <details class=\"card composer-card\" data-testid=\"collection-screen-composer\">\n <summary>{{ tx('studio.composer.title', 'Guided collection screen composer (5-minute flow)') }}</summary>\n <p class=\"hint\">{{ tx('studio.composer.help', 'Assemble a complete collection screen with header, KPI indicators, single filter region, primary collection, and optional detail surface in the draft.') }}</p>\n <div class=\"grid\">\n <praxis-resource-quick-connect\n [formControl]=\"composerResourcePathControl\"\n intent=\"collection\"\n [label]=\"tx('studio.composer.resourcePath', 'Resource path')\"\n (resourceSelected)=\"onComposerResourceSelected($event)\"\n data-testid=\"composer-resource-path\"\n />\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.screenTitle', 'Screen title') }}</mat-label>\n <input matInput [formControl]=\"composerTitleControl\" data-testid=\"composer-title\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.groupField', 'KPI group-by field') }}</mat-label>\n <input\n matInput\n [formControl]=\"composerGroupFieldControl\"\n [matAutocomplete]=\"composerGroupFieldPicker\"\n data-testid=\"composer-group-field\"\n />\n <mat-autocomplete\n #composerGroupFieldPicker=\"matAutocomplete\"\n (optionSelected)=\"composerGroupFieldControl.markAsPristine()\"\n >\n @for (field of filteredComposerGroupFieldOptions(); track field.name) {\n <mat-option [value]=\"field.name\" [attr.data-testid]=\"'composer-group-field-option-' + field.name\">\n <div class=\"composer-rich-field-option\">\n <div class=\"composer-rich-field-option__header\">\n <span class=\"composer-rich-field-option__label\">{{ field.label || field.name }}</span>\n @if (field.label && field.label !== field.name) {\n <code class=\"composer-rich-field-option__code\">{{ field.name }}</code>\n }\n @if (getComposerFieldTypeBadge(field); as badge) {\n <span class=\"composer-rich-field-option__badge\">{{ badge }}</span>\n }\n </div>\n @if (getComposerFieldDescription(field); as desc) {\n <span class=\"composer-rich-field-option__desc\">{{ desc }}</span>\n }\n </div>\n </mat-option>\n }\n </mat-autocomplete>\n @if (composerSelectedResource()?.capabilities?.stats === false) {\n <mat-hint data-testid=\"composer-stats-unsupported-hint\">{{ tx('studio.composer.statsUnsupportedHint', 'This resource does not publish stats/group-by aggregation; summary indicators were disabled automatically.') }}</mat-hint>\n } @else {\n <mat-hint>{{ tx('studio.composer.groupFieldHint', 'Choose the boolean or categorical field used in summary indicators.') }}</mat-hint>\n }\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.filterMode', 'Query filter owner') }}</mat-label>\n <mat-select [formControl]=\"composerFilterModeControl\" data-testid=\"composer-filter-mode\">\n <mat-option value=\"table-integrated\">{{ tx('studio.composer.filterIntegrated', 'Integrated table toolbar') }}</mat-option>\n <mat-option value=\"external-filter-bar\">{{ tx('studio.composer.filterExternal', 'Dedicated filter bar widget') }}</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.collectionKind', 'Primary collection') }}</mat-label>\n <mat-select [formControl]=\"composerCollectionKindControl\" data-testid=\"composer-collection-kind\">\n <mat-option value=\"table\">praxis-Table</mat-option>\n <mat-option value=\"list\">praxis-List</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('studio.composer.detailSurface', 'Secondary detail surface') }}</mat-label>\n <mat-select [formControl]=\"composerDetailSurfaceControl\" data-testid=\"composer-detail-surface\">\n <mat-option value=\"drawer\">Drawer</mat-option>\n <mat-option value=\"modal\">Modal</mat-option>\n <mat-option value=\"none\">{{ tx('studio.composer.noSurface', 'None') }}</mat-option>\n </mat-select>\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-flat-button type=\"button\" data-testid=\"composer-generate-btn\" (click)=\"composeCollectionScreen()\">\n <mat-icon aria-hidden=\"true\">auto_awesome</mat-icon>{{ tx('studio.composer.generate', 'Generate collection screen in draft') }}\n </button>\n </div>\n @if (composerMessage()) {\n <p class=\"studio-status\" role=\"status\" data-testid=\"composer-status-message\">{{ composerMessage() }}</p>\n }\n </details>\n <div class=\"studio-actions\"><button mat-stroked-button type=\"button\" (click)=\"layoutPresetControl.setValue('')\">{{ tx('studio.inheritLayout', 'Use inherited model') }}</button><button mat-button type=\"button\" (click)=\"workspaceTab.set(2)\">{{ tx('studio.goArrange', 'Arrange existing widgets') }}<mat-icon iconPositionEnd>arrow_forward</mat-icon></button></div>\n <praxis-page-preset-comparison [expanded]=\"true\" [layoutId]=\"layoutPresetControl.value\" [layoutPresetOptions]=\"layoutPresetOptions\" [themeId]=\"themePresetControl.value\" [canvas]=\"true\" (choose)=\"layoutPresetControl.setValue($event)\" />\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.arrangeTab', 'Arrange')\"><ng-template matTabContent>\n <section class=\"studio-panel\" data-testid=\"canvas-arrangement\">\n <header class=\"studio-heading\"><span class=\"eyebrow\">{{ tx('studio.arrangeEyebrow', 'Existing widgets') }} \u00B7 {{ canvasColumnsControl.value }} {{ tx('editor.layout.cols', 'Columns') }}</span><h2>{{ tx('studio.arrangeTitle', 'Find the right balance of space') }}</h2><p>{{ tx('studio.arrangeHelp', 'Choose a distribution, compare the real positions, then use it in the draft. Widgets keep their reading order and content.') }}</p></header>\n <div #arrangementFeedback tabindex=\"-1\" class=\"arrangement-feedback\">\n @if (arrangementPreview(); as preview) {\n <section class=\"arrangement-review\" aria-live=\"polite\" data-testid=\"arrangement-preview\">\n <div class=\"review-heading\"><h3>{{ tx('studio.reviewTitle', 'Compare before using') }}</h3><span>{{ preview.keys.length }} {{ tx('studio.changedWidgets', 'widgets change') }}</span></div>\n <div class=\"canvas-comparison\">\n @for (side of ['before', 'after']; track side) {\n <figure><figcaption>{{ side === 'before' ? tx('studio.before', 'Before') : tx('studio.after', 'After') }}</figcaption>\n <div class=\"canvas-map\" [style.grid-template-columns]=\"'repeat(' + preview.canvas.columns + ', minmax(0, 1fr))'\" [style.grid-template-rows]=\"'repeat(' + previewRowCount(preview.before, preview.canvas) + ', minmax(0, 1fr))'\" role=\"img\" [attr.aria-label]=\"side === 'before' ? tx('studio.before', 'Before') : tx('studio.after', 'After')\">\n @for (item of previewItems(side === 'before' ? preview.before : preview.canvas); track item.key) {\n <span [style.grid-column]=\"item.col + ' / span ' + item.colSpan\" [style.grid-row]=\"item.row + ' / span ' + item.rowSpan\" [class.changed]=\"preview.keys.includes(item.key)\" [matTooltip]=\"item.label\">{{ item.number }}</span>\n }\n </div>\n </figure>\n }\n </div>\n <p class=\"hint\">{{ tx('studio.mapHelp', 'Numbered map of your widgets. Positions and spans are to scale; content height is evaluated on the page.') }}</p>\n <details><summary>{{ tx('studio.widgetDetails', 'Widget positions and sizes') }}</summary><ol class=\"widget-changes\">@for (item of previewItems(preview.canvas); track item.key) {<li><strong>{{ item.label }}</strong><span>{{ tx('studio.before', 'Before') }}: {{ tx('editor.arrange.position', 'Column / row') }} {{ preview.before.items[item.key].col }} / {{ preview.before.items[item.key].row }} \u00B7 {{ tx('studio.gridSize', 'Size in columns \u00D7 rows') }} {{ preview.before.items[item.key].colSpan }} \u00D7 {{ preview.before.items[item.key].rowSpan }}</span><span>{{ tx('studio.after', 'After') }}: {{ tx('editor.arrange.position', 'Column / row') }} {{ item.col }} / {{ item.row }} \u00B7 {{ tx('studio.gridSize', 'Size in columns \u00D7 rows') }} {{ item.colSpan }} \u00D7 {{ item.rowSpan }}</span></li>}</ol></details>\n <button mat-flat-button type=\"button\" data-testid=\"arrangement-use\" (click)=\"useArrangement()\">{{ tx('editor.arrange.use', 'Use arrangement in draft') }}<mat-icon iconPositionEnd>arrow_forward</mat-icon></button>\n </section>\n }\n @if (arrangementMessage()) {<p class=\"studio-status\" role=\"status\">{{ arrangementMessage() }}</p>}\n @if (arrangementUndoAvailable()) {<button mat-stroked-button type=\"button\" data-testid=\"arrangement-undo\" (click)=\"undoArrangement()\"><mat-icon>undo</mat-icon>{{ tx('editor.arrange.undo', 'Undo arrangement') }}</button>}\n </div>\n <div class=\"studio-gallery arrangement-gallery\">\n @for (choice of arrangementChoices; track choice.id) {\n <button type=\"button\" class=\"studio-choice\" [class.selected]=\"arrangementSelection() === choice.id\" [attr.aria-pressed]=\"arrangementSelection() === choice.id\" [disabled]=\"!hasCanvasWidgets || !arrangementFits(choice.id)\" [attr.data-testid]=\"'arrange-' + choice.id\" (click)=\"prepareArrangement(choice.id)\">\n <span class=\"distribution-sample\" aria-hidden=\"true\" [style.grid-template-columns]=\"(arrangementFits(choice.id) ? arrangementWidths(choice.id) : choice.weights).join('fr ') + 'fr'\">@for (weight of choice.weights; track $index) {<i>{{ arrangementWidths(choice.id)[$index] }}</i>}@for (weight of choice.weights; track $index) {<i></i>}</span>\n <span class=\"choice-title\">{{ tx('editor.arrange.' + choice.id, choice.id) }}@if (arrangementSelection() === choice.id) {<mat-icon aria-hidden=\"true\">check_circle</mat-icon>}</span>\n <span class=\"choice-description\">{{ tx('studio.arrange.' + choice.id, choice.id) }}</span>\n <span class=\"choice-meta\">{{ arrangementFits(choice.id) ? arrangementWidths(choice.id).join(' + ') : tx('studio.moreColumns', 'Needs more grid columns') }}</span>\n </button>\n }\n </div>\n @if (hasCanvasWidgets) {\n <details class=\"card quick-geometry-card\" data-testid=\"quick-widget-geometry-list\">\n <summary>{{ tx('studio.quickGeometryTitle', 'Quick widget spans (columns \u00D7 rows)') }}</summary>\n <div class=\"quick-geometry-rows\">\n @for (w of quickCanvasWidgets(); track w.key) {\n <div class=\"quick-geometry-row\" [attr.data-testid]=\"'quick-geometry-row-' + w.key\">\n <span class=\"quick-geometry-label\"><mat-icon aria-hidden=\"true\">{{ w.icon }}</mat-icon><strong>{{ w.label }}</strong> <small>({{ w.colSpan }}\u00D7{{ w.rowSpan }})</small></span>\n <div class=\"studio-actions\">\n @for (span of [3, 4, 6, 12]; track span) {\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'quick-colspan-' + w.key + '-' + span\" (click)=\"setWidgetCanvasSpan(w.key, span)\">{{ span }}c</button>\n }\n @for (rSpan of [1, 2, 4]; track rSpan) {\n <button mat-button type=\"button\" [attr.data-testid]=\"'quick-rowspan-' + w.key + '-' + rSpan\" (click)=\"setWidgetCanvasSpan(w.key, w.colSpan, rSpan)\">{{ rSpan }}r</button>\n }\n </div>\n </div>\n }\n </div>\n </details>\n }\n @if (!hasCanvasWidgets) {<p role=\"status\">{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>}\n <details class=\"arrangement-rules\"><summary>{{ tx('studio.rules', 'Scope, devices and locked widgets') }}</summary><p>{{ tx('editor.arrange.help', 'Base canvas, in current reading order. Inheriting devices follow the base; explicit device positions are preserved. Locked positions stay in place.') }}</p><button mat-button type=\"button\" (click)=\"onSelectedTabIndexChange(5)\">{{ tx('studio.goLimits', 'Review widget limits') }}</button></details>\n </section>\n </ng-template></mat-tab>\n <mat-tab [label]=\"tx('studio.settingsTab', 'Adjustments')\">\n <div class=\"studio-panel\"><mat-tab-group class=\"editor-tabs\" [mat-stretch-tabs]=\"false\" [selectedIndex]=\"selectedTabIndex\" (selectedIndexChange)=\"selectDetailTab($event)\">\n <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 class=\"page-inset-field\" appearance=\"outline\"><mat-label>{{ tx('editor.layout.paddingInline', 'Page side inset') }}</mat-label><input matInput [formControl]=\"paddingInlineControl\" placeholder=\"24px\" /><button praxisIconButton=\"help_outline\" size=\"dense\" matSuffix type=\"button\" [matTooltip]=\"tx('editor.hints.paddingInline', 'Shared space beside all page widgets. Use a CSS length or theme token; 0px removes it. Leave empty to inherit the host theme.')\" [attr.aria-label]=\"tx('editor.layout.paddingInline', 'Page side inset')\"></button></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></div></mat-tab>\n <mat-tab [label]=\"tx('editor.tabs.grouping', 'Grouping')\"><div class=\"panel\">\n <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 {@if (bindablePageWidgets().length > 0) {<mat-form-field appearance=\"outline\" class=\"wide\"><mat-label>{{ tx('editor.grouping.widgets', 'Widgets in group') }}</mat-label><mat-select multiple [value]=\"groupingSelectedWidgetKeys($index)\" (selectionChange)=\"onGroupingWidgetSelectionChange($index, $event.value)\" [attr.data-testid]=\"'grouping-widget-select-' + $index\" panelClass=\"praxis-binder-widget-select-panel\"><mat-select-trigger><span class=\"binder-widget-trigger\"><mat-icon aria-hidden=\"true\" class=\"binder-widget-trigger-icon\">{{ groupingSelectedWidgetIcon($index) }}</mat-icon><span class=\"binder-widget-trigger-text\">{{ groupingSelectedWidgetsLabel($index) }}</span></span></mat-select-trigger>@for (w of bindablePageWidgets(); track w.key) {<mat-option [value]=\"w.key\" [attr.data-widget-key]=\"w.key\" class=\"binder-widget-option\"><span class=\"binder-widget-option-row\"><mat-icon class=\"binder-widget-option-icon\" aria-hidden=\"true\">{{ w.icon }}</mat-icon><span class=\"binder-widget-option-copy\"><strong class=\"binder-widget-option-title\">{{ w.title }}</strong>@if (w.description) {<small class=\"binder-widget-option-desc\">{{ w.description }}</small>}</span><span class=\"binder-widget-option-sr-key\" aria-hidden=\"true\">{{ w.key }}</span></span></mat-option>}</mat-select></mat-form-field>}<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>\n <mat-tab [label]=\"tx('editor.tabs.devices', 'Device Layouts')\">\n <div class=\"panel\">\n <mat-button-toggle-group [value]=\"activeDevice()\" (change)=\"activeDevice.set($event.value)\" [attr.aria-label]=\"tx('editor.devices.target', 'Device to configure')\">\n @for (device of deviceKeys; track device) {\n <mat-button-toggle [value]=\"device\">{{ deviceLabels[device] }}</mat-button-toggle>\n }\n </mat-button-toggle-group>\n @for (device of deviceKeys; track device) {\n @if (activeDevice() === device) {\n <div class=\"card\">\n <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>\n <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>\n @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> }\n <div class=\"grid\">\n <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>\n <mat-form-field class=\"page-inset-field\" appearance=\"outline\"><mat-label>{{ tx('editor.layout.paddingInline', 'Page side inset') }}</mat-label><input matInput [formControl]=\"deviceForms[device].controls.paddingInline\" placeholder=\"16px\" /></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>\n <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>\n </div>\n <details><summary>{{ tx('editor.devices.advanced', 'Advanced overrides') }}</summary><div class=\"grid\">\n <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>\n <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>\n <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-hint>{{ tx('editor.devices.widgetOverridesHelp', 'Supported per-widget properties on this device: className, span, order, and hidden. Does not override internal widget definition or inputs.') }}</mat-hint></mat-form-field>\n </div></details>\n @if (!stackedDevices.has(device)) { <div class=\"hint\">{{ tx('editor.preview.deviceCanvasHint', 'Existing canvas item overrides are preserved.') }}</div> }\n </div>\n }\n }\n </div>\n </mat-tab>\n <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>\n <mat-tab [label]=\"tx('editor.tabs.state', 'Page State')\">\n <div class=\"panel\">\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.stateDefinitionJson', 'Page state (values, schema, derived \u2014 JSON)') }}</mat-label>\n <textarea matInput rows=\"10\" [formControl]=\"stateControl\" data-testid=\"page-state-json\"></textarea>\n </mat-form-field>\n\n <section class=\"panel\" data-testid=\"page-datasources-guided\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.dataSourcesTitle', 'Data Sources') }}</strong>\n <button mat-stroked-button type=\"button\" data-testid=\"page-datasources-add\" (click)=\"addDataSourceNode()\">\n <mat-icon>add</mat-icon>{{ tx('editor.state.addDataSource', 'Add data source') }}\n </button>\n </div>\n <p class=\"hint\">{{ tx('editor.state.dataSourcesCopy', 'Bind HTTP or resource endpoints directly into page state paths with reactive parameters.') }}</p>\n @if (!dataSourceNodes.controls.length) {\n <p class=\"hint\" data-testid=\"page-datasources-empty\">{{ tx('editor.state.emptyDataSources', 'No declarative data sources configured.') }}</p>\n }\n @for (ds of dataSourceNodes.controls; track $index) {\n <div class=\"card\" [formGroup]=\"ds\" [attr.data-testid]=\"'page-datasource-card-' + $index\">\n <div class=\"head\">\n <span>{{ dataSourceLabel($index) }}</span>\n <button\n praxisIconButton=\"delete\"\n size=\"compact\"\n type=\"button\"\n [attr.data-testid]=\"'page-datasource-remove-' + $index\"\n [attr.aria-label]=\"tx('editor.state.removeDataSourceAria', 'Remove data source')\"\n (click)=\"removeDataSourceNode($index)\"\n ></button>\n </div>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.dataSourceId', 'ID') }}</mat-label>\n <input matInput formControlName=\"id\" [attr.data-testid]=\"'page-datasource-id-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.dataSourceKind', 'Kind') }}</mat-label>\n <mat-select formControlName=\"kind\" [attr.data-testid]=\"'page-datasource-kind-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"remote-resource\">remote-resource</mat-option>\n <mat-option value=\"stats-query\">stats-query</mat-option>\n <mat-option value=\"raw-http\">raw-http</mat-option>\n </mat-select>\n </mat-form-field>\n <praxis-resource-quick-connect\n formControlName=\"resourcePath\"\n [intent]=\"ds.controls.kind.value === 'stats-query' ? 'stats' : (ds.controls.kind.value === 'raw-http' ? 'any' : 'collection')\"\n [endpointKey]=\"resolveEndpointKey(ds.controls.endpointKey.value)\"\n [label]=\"tx('editor.state.resourcePath', 'Resource path')\"\n [attr.data-testid]=\"'page-datasource-resourcePath-' + $index\"\n />\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.endpointKey', 'Endpoint key') }}</mat-label>\n <input matInput formControlName=\"endpointKey\" [attr.data-testid]=\"'page-datasource-endpointKey-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.method', 'HTTP method') }}</mat-label>\n <mat-select formControlName=\"method\" [attr.data-testid]=\"'page-datasource-method-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.auto', 'Auto') }}</mat-option>\n <mat-option value=\"GET\">GET</mat-option>\n <mat-option value=\"POST\">POST</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.targetStatePath', 'Target state path') }}</mat-label>\n <input matInput formControlName=\"targetStatePath\" [attr.data-testid]=\"'page-datasource-targetStatePath-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.errorPolicy', 'Error policy') }}</mat-label>\n <mat-select formControlName=\"errorPolicy\" [attr.data-testid]=\"'page-datasource-errorPolicy-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"propagate\">propagate</mat-option>\n <mat-option value=\"silent\">silent</mat-option>\n <mat-option value=\"fallback\">fallback</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.refreshIntervalMs', 'Refresh interval (ms)') }}</mat-label>\n <input matInput type=\"number\" min=\"0\" formControlName=\"refreshIntervalMs\" [attr.data-testid]=\"'page-datasource-refreshIntervalMs-' + $index\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.autoFetch', 'Auto fetch') }}</mat-label>\n <mat-select formControlName=\"autoFetch\" [attr.data-testid]=\"'page-datasource-autoFetch-' + $index\">\n <mat-option value=\"\">{{ tx('editor.common.default', 'Default') }}</mat-option>\n <mat-option value=\"true\">true</mat-option>\n <mat-option value=\"false\">false</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.description', 'Description') }}</mat-label>\n <input matInput formControlName=\"description\" [attr.data-testid]=\"'page-datasource-description-' + $index\" />\n </mat-form-field>\n <div class=\"wide guided-presets-bar\" [attr.data-testid]=\"'page-datasource-guided-presets-' + $index\">\n <span class=\"hint\">{{ tx('editor.state.guidedPresets', 'Guided query & transform templates:') }}</span>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-groupby-' + $index\" (click)=\"applyGuidedDataSourceQuery($index, { mode: 'group-by', groupByField: 'ativo', metricField: 'id', metricOp: 'count' }); applyGuidedDataSourceTransform($index, { kind: 'group-by-map', totalField: 'total', mappingsText: 'true:active, false:inactive', ratioTargetField: 'activeRatePct', ratioNumeratorField: 'active', ratioDenominatorField: 'total', ratioScale: 100, ratioDecimals: 1 })\">\n {{ tx('editor.state.presetGroupByKpi', 'Group-by + Total + Ratio (%)') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-weighted-' + $index\" (click)=\"applyGuidedDataSourceTransform($index, { kind: 'group-by-map', totalField: 'total', weightedAverageField: 'weightedAvg', weightedAverageDecimals: 2, mappingsText: '1:low, 5:high' })\">\n {{ tx('editor.state.presetWeightedAvg', 'Weighted average') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-timeseries-' + $index\" (click)=\"applyGuidedDataSourceQuery($index, { mode: 'timeseries', groupByField: 'createdAt', period: 'month', metricField: 'id', metricOp: 'count' }); applyGuidedDataSourceTransform($index, { kind: 'timeseries-last', mappingsText: 'count:latestCount, avg:latestAvg' })\">\n {{ tx('editor.state.presetTimeseriesLast', 'Timeseries latest point') }}\n </button>\n <button mat-stroked-button type=\"button\" [attr.data-testid]=\"'page-datasource-preset-path-' + $index\" (click)=\"applyGuidedDataSourceTransform($index, { kind: 'path', path: 'totalElements' })\">\n {{ tx('editor.state.presetPathExtract', 'Extract path (totalElements)') }}\n </button>\n </div>\n </div>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.queryJson', 'Query (JSON)') }}</mat-label>\n <textarea matInput rows=\"3\" formControlName=\"queryJson\" [attr.data-testid]=\"'page-datasource-queryJson-' + $index\"></textarea>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.transformJson', 'Transform (JSON)') }}</mat-label>\n <textarea matInput rows=\"3\" formControlName=\"transformJson\" [attr.data-testid]=\"'page-datasource-transformJson-' + $index\"></textarea>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.fallbackJson', 'Fallback value (JSON)') }}</mat-label>\n <textarea matInput rows=\"2\" formControlName=\"fallbackJson\" [attr.data-testid]=\"'page-datasource-fallbackJson-' + $index\"></textarea>\n </mat-form-field>\n </div>\n @let preview = getDataSourcePreview($index);\n <div class=\"datasource-preview-box\" [attr.data-testid]=\"'page-datasource-preview-' + $index\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.previewTitle', 'Transform & State Preview') }}</strong>\n <span class=\"previous-height-chip\">{{ preview.sourceKind === 'live' ? tx('editor.state.previewLive', 'Live response') : tx('editor.state.previewSample', 'Simulated sample') }}</span>\n </div>\n @if (preview.status === 'error') {\n <p class=\"limits-error\" role=\"alert\" [attr.data-testid]=\"'page-datasource-preview-error-' + $index\">{{ preview.error }}</p>\n } @else {\n <pre class=\"datasource-preview-json\" [attr.data-testid]=\"'page-datasource-preview-json-' + $index\">{{ preview.formattedJson }}</pre>\n @if (preview.tokens.length) {\n <div class=\"studio-actions\" [attr.data-testid]=\"'page-datasource-preview-tokens-' + $index\">\n <span class=\"hint\">{{ tx('editor.state.availableTokens', 'Click a token to prepare widget binding:') }}</span>\n @for (token of preview.tokens; track token) {\n <button mat-button type=\"button\" (click)=\"binderTokenControl.setValue(token)\"><code>{{ token }}</code></button>\n }\n </div>\n }\n }\n </div>\n </div>\n }\n </section>\n\n <section class=\"card\" data-testid=\"page-derived-measures-guided\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.derivedMeasuresTitle', 'Guided Derived Measures') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.state.derivedMeasuresCopy', 'Create percentages, ratios, weighted averages, or formatted measures from page state paths without writing raw JsonLogic.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedTargetPath', 'Measure key') }}</mat-label>\n <input matInput [formControl]=\"derivedPathControl\" placeholder=\"activeRatePct\" data-testid=\"derived-target-path\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedKind', 'Calculation') }}</mat-label>\n <mat-select [formControl]=\"derivedKindControl\" data-testid=\"derived-kind\">\n <mat-option value=\"ratio-percent\">{{ tx('editor.state.derivedRatio', 'Ratio / Percentage') }}</mat-option>\n <mat-option value=\"weighted-average\">{{ tx('editor.state.derivedWeighted', 'Weighted average') }}</mat-option>\n <mat-option value=\"format-value\">{{ tx('editor.state.derivedFormat', 'Formatted value') }}</mat-option>\n <mat-option value=\"template\">{{ tx('editor.state.derivedTemplate', 'State reference') }}</mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedNumerator', 'Numerator / Source path') }}</mat-label>\n <input matInput [formControl]=\"derivedNumeratorControl\" placeholder=\"kpis.active\" data-testid=\"derived-numerator\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedDenominator', 'Denominator path') }}</mat-label>\n <input matInput [formControl]=\"derivedDenominatorControl\" placeholder=\"kpis.total\" data-testid=\"derived-denominator\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.derivedFormatKind', 'Format') }}</mat-label>\n <mat-select [formControl]=\"derivedFormatControl\" data-testid=\"derived-format\">\n <mat-option value=\"percent\">percent (%)</mat-option>\n <mat-option value=\"number\">number</mat-option>\n <mat-option value=\"integer\">integer</mat-option>\n <mat-option value=\"currency\">currency (R$)</mat-option>\n <mat-option value=\"boolean\">boolean</mat-option>\n </mat-select>\n </mat-form-field>\n </div>\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" data-testid=\"derived-add-btn\" (click)=\"addGuidedDerivedMeasure()\">\n <mat-icon aria-hidden=\"true\">functions</mat-icon>{{ tx('editor.state.addDerivedAction', 'Add measure to page state') }}\n </button>\n </div>\n @let derivedPreview = getDerivedStatePreview();\n @if (derivedPreview.entries.length || derivedPreview.diagnostics.length) {\n <div class=\"datasource-preview-box\" data-testid=\"page-derived-preview\">\n @for (diag of derivedPreview.diagnostics; track diag) {\n <p class=\"limits-error\" role=\"alert\">{{ diag }}</p>\n }\n @if (derivedPreview.entries.length) {\n <div class=\"studio-actions\">\n @for (entry of derivedPreview.entries; track entry.key) {\n <button mat-button type=\"button\" (click)=\"binderTokenControl.setValue(entry.token)\">\n <strong>{{ entry.key }}</strong>: {{ entry.formattedValue }} <code>({{ entry.token }})</code>\n </button>\n }\n </div>\n }\n </div>\n }\n </section>\n\n <section class=\"card\" data-testid=\"page-state-component-binder\">\n <div class=\"head\">\n <strong>{{ tx('editor.state.binderTitle', 'Connect State or Measure to Widget') }}</strong>\n </div>\n <p class=\"hint\">{{ tx('editor.state.binderCopy', 'Bind any state path or derived measure directly to a widget input in the draft.') }}</p>\n <div class=\"grid\">\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderToken', 'State token') }}</mat-label>\n <input matInput [formControl]=\"binderTokenControl\" placeholder=\"{{ '{{state.kpis.total}}' }}\" data-testid=\"binder-token\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderWidget', 'Target widget') }}</mat-label>\n <mat-select [formControl]=\"binderWidgetControl\" data-testid=\"binder-widget\" panelClass=\"praxis-binder-widget-select-panel\">\n @if (selectedBinderWidget(); as selectedWidget) {\n <mat-select-trigger>\n <span class=\"binder-widget-trigger\">\n <mat-icon aria-hidden=\"true\" class=\"binder-widget-trigger-icon\">{{ selectedWidget.icon }}</mat-icon>\n <span class=\"binder-widget-trigger-text\">\n <strong>{{ selectedWidget.title }}</strong>\n @if (selectedWidget.description) {\n <small> \u00B7 {{ selectedWidget.description }}</small>\n }\n </span>\n </span>\n </mat-select-trigger>\n }\n @for (w of bindablePageWidgets(); track w.key) {\n <mat-option [value]=\"w.key\" [attr.data-widget-key]=\"w.key\" class=\"binder-widget-option\">\n <span class=\"binder-widget-option-row\">\n <mat-icon aria-hidden=\"true\" class=\"binder-widget-option-icon\">{{ w.icon }}</mat-icon>\n <span class=\"binder-widget-option-copy\">\n <strong class=\"binder-widget-option-title\">{{ w.title }}</strong>\n @if (w.description) {\n <small class=\"binder-widget-option-desc\">{{ w.description }}</small>\n }\n </span>\n <span class=\"binder-widget-option-sr-key\" aria-hidden=\"true\">{{ w.key }}</span>\n </span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ tx('editor.state.binderInputPath', 'Widget input path') }}</mat-label>\n <input matInput [formControl]=\"binderInputPathControl\" placeholder=\"value\" data-testid=\"binder-input-path\" />\n </mat-form-field>\n </div>\n @if (availableStateBindingTokens().length) {\n <div class=\"binder-input-suggestions\" data-testid=\"binder-token-suggestions\">\n <span class=\"hint\">{{ tx('editor.state.binderTokenSuggestions', 'Available tokens:') }}</span>\n @for (availableToken of availableStateBindingTokens(); track availableToken) {\n <button\n type=\"button\"\n class=\"binder-input-suggestion-chip\"\n [class.active]=\"binderTokenControl.value === availableToken\"\n [attr.data-testid]=\"'binder-token-suggestion-' + availableToken\"\n (click)=\"selectBinderTokenSuggestion(availableToken)\"\n >\n <code>{{ availableToken }}</code>\n </button>\n }\n </div>\n }\n <div class=\"binder-input-suggestions\" data-testid=\"binder-pipe-suggestions\">\n <span class=\"hint\">{{ tx('editor.state.binderPipeSuggestions', 'Formatadores CSP-safe (pipes):') }}</span>\n @for (pipeSegment of binderDeclarativePipes; track pipeSegment) {\n <button\n type=\"button\"\n class=\"binder-input-suggestion-chip\"\n [attr.data-testid]=\"'binder-pipe-chip-' + pipeSegment\"\n (click)=\"appendBinderTokenPipe(pipeSegment)\"\n >\n <code>{{ pipeSegment }}</code>\n </button>\n }\n </div>\n @if (binderTokenPipeError(); as pipeErr) {\n <p class=\"studio-status binder-unbound-warning\" role=\"alert\" data-testid=\"binder-pipe-error\">{{ pipeErr }}</p>\n } @else if (binderTokenLivePreview(); as livePreviewVal) {\n <p class=\"hint\" data-testid=\"binder-live-preview\">\n {{ tx('editor.state.binderLivePreview', 'Preview simulado:') }} <strong>{{ livePreviewVal }}</strong>\n </p>\n }\n @if (selectedBinderWidgetInputSuggestions().length) {\n <div class=\"binder-input-suggestions\" data-testid=\"binder-input-suggestions\">\n <span class=\"hint\">{{ tx('editor.state.binderInputSuggestions', 'Suggested paths:') }}</span>\n @for (suggestedPath of selectedBinderWidgetInputSuggestions(); track suggestedPath) {\n <button\n type=\"button\"\n class=\"binder-input-suggestion-chip\"\n [class.active]=\"binderInputPathControl.value === suggestedPath\"\n [attr.data-testid]=\"'binder-input-suggestion-' + suggestedPath\"\n (click)=\"selectBinderInputPathSuggestion(suggestedPath)\"\n >\n <code>{{ suggestedPath }}</code>\n </button>\n }\n </div>\n }\n <div class=\"studio-actions\">\n <button mat-stroked-button type=\"button\" data-testid=\"binder-bind-btn\" (click)=\"bindStateTokenToWidget()\">\n <mat-icon aria-hidden=\"true\">link</mat-icon>{{ tx('editor.state.bindAction', 'Bind token to widget in draft') }}\n </button>\n <button mat-stroked-button type=\"button\" data-testid=\"binder-auto-bind-btn\" (click)=\"autoBindWidgetStatePlaceholders()\">\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>{{ tx('editor.state.autoBindAction', 'Auto-bind ${...} widget placeholders') }}\n </button>\n </div>\n @if (binderMessage()) {\n <p class=\"studio-status\" role=\"status\" data-testid=\"binder-status-message\">{{ binderMessage() }}</p>\n }\n @if (invalidWidgetStateBindingPipes().length) {\n <div class=\"binder-unbound-warning\" role=\"alert\" data-testid=\"binder-invalid-pipes-warning\">\n <mat-icon aria-hidden=\"true\">error_outline</mat-icon>\n <div class=\"binder-unbound-warning__body\">\n <span>{{ tx('editor.state.invalidWidgetPipesWarning', 'Formatadores (pipes) inv\u00E1lidos detectados:') }}</span>\n <div class=\"binder-unbound-warning__chips\">\n @for (invalidBinding of invalidWidgetStateBindingPipes(); track invalidBinding.widgetKey + ':' + invalidBinding.inputPath) {\n <button\n type=\"button\"\n class=\"binder-unbound-chip\"\n [attr.data-testid]=\"'binder-invalid-pipe-chip-' + invalidBinding.widgetKey + '-' + invalidBinding.inputPath\"\n (click)=\"loadStateBindingIntoForm(invalidBinding)\"\n >\n <strong>{{ invalidBinding.widgetTitle }}</strong>.<code>{{ invalidBinding.inputPath }}</code> (<code>{{ invalidBinding.expression }}</code>)\n </button>\n }\n </div>\n </div>\n </div>\n }\n @if (unboundWidgetStatePlaceholders().length; as unboundCount) {\n <div class=\"binder-unbound-warning\" role=\"status\" data-testid=\"binder-unbound-placeholders-warning\">\n <mat-icon aria-hidden=\"true\">warning_amber</mat-icon>\n <div class=\"binder-unbound-warning__body\">\n <span>{{ tx('editor.state.unboundPlaceholdersWarning', 'Placeholders sem v\u00EDnculo detectados:') }}</span>\n <div class=\"binder-unbound-warning__chips\">\n @for (item of unboundWidgetStatePlaceholders(); track item.widgetKey + ':' + item.placeholder) {\n <button\n type=\"button\"\n class=\"binder-unbound-chip\"\n [attr.data-testid]=\"'binder-unbound-chip-' + item.widgetKey + '-' + item.placeholder\"\n [attr.title]=\"tx('editor.state.selectUnboundPlaceholderHint', 'Clique para preencher widget e caminho no formul\u00E1rio')\"\n (click)=\"selectUnboundPlaceholder(item)\"\n >\n <strong>{{ item.widgetTitle }}</strong> (<code>{{ '${' + item.placeholder + '}' }}</code>)\n </button>\n }\n </div>\n </div>\n </div>\n }\n @if (currentWidgetStateBindings().length) {\n <ul class=\"blocked-widgets-list binder-active-list\" data-testid=\"active-widget-state-bindings\">\n @for (binding of currentWidgetStateBindings(); track binding.widgetKey + ':' + binding.inputPath) {\n @let previewVal = resolveStateTokenPreview(binding.token);\n <li class=\"binder-active-item\">\n <mat-icon aria-hidden=\"true\" class=\"binder-active-icon\">{{ widgetIconForKey(binding.widgetKey) }}</mat-icon>\n <button\n type=\"button\"\n class=\"binder-active-copy binder-active-select-btn\"\n [attr.data-testid]=\"'binder-select-' + binding.widgetKey + '-' + binding.inputPath\"\n [attr.title]=\"tx('editor.state.editBindingHint', 'Click to load binding into form')\"\n (click)=\"loadStateBindingIntoForm(binding)\"\n >\n <strong>{{ binding.widgetTitle }}</strong>.<code>{{ binding.inputPath }}</code> \u2190 <code>{{ binding.token }}</code>\n @if (previewVal !== null) {\n <small class=\"binder-preview-badge\" [attr.data-testid]=\"'binder-preview-' + binding.widgetKey + '-' + binding.inputPath\">= {{ previewVal }}</small>\n }\n </button>\n <button mat-icon-button type=\"button\" [attr.data-testid]=\"'binder-unbind-' + binding.widgetKey + '-' + binding.inputPath\"\n [attr.aria-label]=\"tx('editor.state.unbindAction', 'Remove binding')\"\n (click)=\"unbindStateTokenFromWidget(binding.widgetKey, binding.inputPath)\">\n <mat-icon aria-hidden=\"true\">link_off</mat-icon>\n </button>\n </li>\n }\n </ul>\n }\n </section>\n\n <mat-form-field appearance=\"outline\" class=\"wide\">\n <mat-label>{{ tx('editor.state.dataSourcesJson', 'page.dataSources (JSON)') }}</mat-label>\n <textarea matInput rows=\"8\" [formControl]=\"dataSourcesControl\" [placeholder]=\"tx('editor.hints.dataSourcesExample', '')\" data-testid=\"page-datasources-json\"></textarea>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab [label]=\"tx('editor.limits.tab', 'Widget limits')\">\n <section class=\"limits-panel\" data-testid=\"canvas-limits-editor\">\n @if (workspaceTab() === 3 && activeTab() === 'limits' && canvasLimits.targets.length) {\n <div class=\"selection-identity\" tabindex=\"-1\" data-testid=\"canvas-limits-identity\">\n <mat-icon aria-hidden=\"true\">{{ limitsTargetIcon(limitsTargetControl.value) }}</mat-icon>\n <strong>{{ limitsTargetLabel(limitsTargetControl.value) }}</strong>\n </div>\n }\n\n <p class=\"hint\" id=\"canvas-limits-help\">{{ tx('editor.limits.help', 'Limits use grid columns and rows, not pixels. Leave blank for defaults, or to inherit the base limits on a device.') }}</p>\n @if (canvasLimits.targets.length) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.target', 'Widget and layout') }}</mat-label>\n <mat-select [formControl]=\"limitsTargetControl\" data-testid=\"canvas-limits-target\">\n <mat-select-trigger>\n <span class=\"binder-widget-trigger\">\n <mat-icon aria-hidden=\"true\" class=\"binder-widget-trigger-icon\">{{ limitsTargetIcon(limitsTargetControl.value) }}</mat-icon>\n <span class=\"binder-widget-trigger-text\">{{ limitsTargetLabel(limitsTargetControl.value) }}</span>\n </span>\n </mat-select-trigger>\n @for (target of canvasLimits.targets; track target.scope + ':' + target.key; let index = $index) {\n <mat-option [value]=\"index\">\n <mat-icon aria-hidden=\"true\">{{ limitsTargetIcon(index) }}</mat-icon>\n <span>{{ limitsTargetLabel(index) }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <p class=\"hint\">{{ limitsGeometryLabel() }}</p>\n @if (canvasLimits.form.controls[limitsTargetControl.value]; as limitsForm) {\n <div class=\"limits-fields\" aria-describedby=\"canvas-limits-help\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.minColSpan', 'Minimum width (columns)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.minColSpan\" data-testid=\"canvas-limit-minColSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minColSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.maxColSpan', 'Maximum width (columns)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.maxColSpan\" data-testid=\"canvas-limit-maxColSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxColSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.minRowSpan', 'Minimum height (rows)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.minRowSpan\" data-testid=\"canvas-limit-minRowSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minRowSpan) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.limits.maxRowSpan', 'Maximum height (rows)') }}</mat-label>\n <input matInput [errorStateMatcher]=\"limitsErrorMatcher\" type=\"number\" min=\"1\" step=\"1\" [formControl]=\"limitsForm.controls.maxRowSpan\" data-testid=\"canvas-limit-maxRowSpan\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxRowSpan) }}</mat-error>\n </mat-form-field>\n </div>\n @if (limitsUseContentHeight()) {\n <p class=\"limits-notice\"><mat-icon aria-hidden=\"true\">height</mat-icon><span>{{ tx('editor.limits.autoHeight', 'Choose natural height or an adjustable height with internal scrolling.') }}</span></p>\n <mat-button-toggle-group class=\"height-modes\" [formControl]=\"limitsForm.controls.heightMode\" [attr.aria-label]=\"tx('editor.height.mode', 'Widget height')\" data-testid=\"canvas-height-mode\">\n <mat-button-toggle value=\"inherit\">{{ tx('editor.height.inherit', 'Default') }}</mat-button-toggle>\n <mat-button-toggle value=\"auto\">{{ tx('editor.height.auto', 'Automatic') }}</mat-button-toggle>\n <mat-button-toggle value=\"fixed\">{{ tx('editor.height.fixed', 'Adjustable') }}</mat-button-toggle>\n </mat-button-toggle-group>\n @if (limitsForm.controls.heightMode.value === 'fixed') {\n <div class=\"limits-fields\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.value', 'Height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.heightPx\" data-testid=\"canvas-height-px\">\n <mat-error>{{ limitFieldError(limitsForm.controls.heightPx) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.top', 'Top inset (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"0\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.offsetTopPx\" data-testid=\"canvas-height-top\">\n <mat-error>{{ limitFieldError(limitsForm.controls.offsetTopPx) }}</mat-error>\n </mat-form-field>\n </div>\n }\n <div class=\"limits-fields\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.min', 'Minimum height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.minHeightPx\" data-testid=\"canvas-limit-minHeightPx\">\n <mat-error>{{ limitFieldError(limitsForm.controls.minHeightPx) }}</mat-error>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>{{ tx('editor.height.max', 'Maximum height (px)') }}</mat-label>\n <input matInput type=\"number\" min=\"160\" step=\"1\" [errorStateMatcher]=\"limitsErrorMatcher\" [formControl]=\"limitsForm.controls.maxHeightPx\" data-testid=\"canvas-limit-maxHeightPx\">\n <mat-error>{{ limitFieldError(limitsForm.controls.maxHeightPx) }}</mat-error>\n </mat-form-field>\n </div>\n }\n }\n @if (limitsSummaryError) { <p class=\"limits-error\" role=\"alert\">{{ limitsSummaryError }}</p> }\n } @else {\n <p>{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>\n }\n </section>\n </mat-tab>\n </mat-tab-group></div>\n </mat-tab>\n </mat-tab-group>\n <details class=\"summary-details\">\n <summary>{{ tx('editor.summaryAria', 'Configuration summary') }}</summary>\n <div class=\"summary\">\n @if (!isHostedInSettingsPanel) {\n <span>{{ validationStateLabel }}</span>\n <span>{{ dirtyStateLabel }}</span>\n }\n <span>{{ selectedLayoutPresetLabel }}</span>\n <span>{{ selectedThemePresetLabel }}</span>\n <span>{{ canvasSummaryLabel }}</span>\n </div>\n </details>\n </div>\n", styles: [".page-inset-field{grid-column:1/-1;min-width:0}: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{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{align-items:stretch}.head>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)}.limits-blocking-banner{border-color:var(--md-sys-color-error, #b3261e);gap:8px}.blocked-widgets-list{margin:0;padding-inline-start:20px;display:grid;gap:4px}.previous-heights-summary{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font:var(--mat-sys-body-small, inherit);color:var(--md-sys-color-on-surface-variant, currentColor)}.previous-height-chip{padding:2px 8px;border-radius:999px;background:var(--md-sys-color-surface-container-highest, rgba(128, 128, 128, .14))}.wide{grid-column:1/-1}.hint{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px}.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}:host{container-type:inline-size}.studio-panel{display:grid;gap:var(--p-space-4, 16px);padding-block:var(--p-space-5, 20px);min-width:0}.studio-heading h2{font-size:var(--p-font-size-lg, 20px);line-height:1.25;margin:var(--p-space-2, 8px) 0;font-weight:600}.studio-heading p{margin:0;color:var(--md-sys-color-on-surface-variant);line-height:1.5}.studio-panel h3{font-size:var(--p-font-size-md, 16px);margin:0}.studio-panel>button{justify-self:start}.studio-gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--p-space-3, 12px)}.studio-choice{display:flex;flex-direction:column;gap:var(--p-space-2, 8px);min-width:0;padding:var(--p-space-3, 12px);text-align:start;border:1px solid var(--md-sys-color-outline-variant);border-radius:var(--p-radius-lg, 12px);background:var(--md-sys-color-surface);color:var(--md-sys-color-on-surface);font:inherit;cursor:pointer;transition:border-color var(--md-sys-motion-duration-short2, .1s),background-color var(--md-sys-motion-duration-short2, .1s)}.studio-choice:hover:not(:disabled){border-color:var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low)}.studio-choice.selected{border-color:var(--md-sys-color-primary);box-shadow:inset 0 0 0 1px var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low)}.studio-choice:focus-visible,.studio-panel summary:focus-visible{outline:3px solid var(--md-sys-color-primary);outline-offset:3px}.studio-choice:disabled{opacity:var(--p-disabled-opacity, .5);cursor:not-allowed}.choice-title{display:flex;align-items:center;justify-content:space-between;gap:var(--p-space-2, 8px);width:100%;font-weight:600;line-height:1.35}.choice-title mat-icon{color:var(--md-sys-color-primary);flex-shrink:0;font-size:20px;height:20px;width:20px}.choice-description{font-size:var(--p-font-size-sm, 13px);line-height:1.5;color:var(--md-sys-color-on-surface-variant)}.choice-meta{font-size:var(--p-font-size-xs, 12px);color:var(--md-sys-color-on-surface-variant);margin-top:auto;padding-top:var(--p-space-1, 4px)}.theme-sample{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));padding:var(--p-space-3, 12px);border-radius:var(--p-radius-md, 8px);min-height:128px;align-self:stretch;margin-bottom:var(--p-space-1, 4px)}.sample-metric,.sample-chart{background:var(--sample-surface);border-radius:var(--sample-radius);box-shadow:var(--sample-shadow);padding:var(--p-space-2, 8px)}.sample-metric b{display:block;font-size:var(--p-font-size-md, 16px)}.sample-metric i{display:block;height:3px;width:55%;margin-top:var(--p-space-1, 4px);background:var(--sample-accent)}.sample-chart{grid-column:1/-1;display:flex;height:40px;align-items:flex-end;gap:var(--p-space-2, 8px)}.sample-chart i{flex:1;background:var(--sample-accent);height:50%;border-radius:var(--p-radius-xs, 2px)}.sample-chart i:nth-child(2){height:85%}.sample-chart i:nth-child(3){height:65%}.sample-chart i:nth-child(4){height:100%}.sample-chart i:nth-child(5){height:75%}.shell-options,.arrangement-rules{border-top:1px solid var(--md-sys-color-outline-variant);padding-top:var(--p-space-3, 12px)}.studio-panel summary{cursor:pointer;font-weight:500;line-height:1.5;padding-block:var(--p-space-2, 8px)}.shell-gallery{margin-top:var(--p-space-4, 16px)}.shell-sample{display:block;align-self:stretch;border:1px solid;overflow:hidden;margin:var(--p-space-2, 8px)}.shell-sample>span{display:flex;gap:var(--p-space-2, 8px);align-items:center;padding:var(--p-space-2, 8px);border-bottom:1px solid transparent}.shell-sample i{height:4px;width:60%;background:var(--md-sys-color-on-surface-variant);opacity:.4}.shell-sample>span:last-child{min-height:30px;flex-direction:column;align-items:flex-start}.shell-sample>span:last-child i:last-child{width:40%}.selection-line{margin:0;font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-on-surface-variant)}.studio-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--p-space-2, 8px)}.distribution-sample{display:grid;grid-template-rows:repeat(2,1fr);gap:var(--p-space-1, 4px);align-self:stretch;height:76px;padding:var(--p-space-2, 8px);background:var(--md-sys-color-surface-container);border-radius:var(--p-radius-md, 8px)}.distribution-sample i{display:grid;place-items:center;background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container);border-radius:var(--p-radius-sm, 4px);font-size:var(--p-font-size-xs, 12px);font-style:normal}.distribution-sample i:nth-child(n+4){opacity:.65}.selected .distribution-sample i{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.arrangement-review{display:grid;gap:var(--p-space-3, 12px);padding-block:var(--p-space-4, 16px);border-top:2px solid var(--md-sys-color-primary)}.review-heading{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:var(--p-space-2, 8px)}.review-heading>span{font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-primary)}.canvas-comparison{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--p-space-3, 12px)}.canvas-comparison figure{margin:0;min-width:0}.canvas-comparison figcaption{font-weight:500;margin-bottom:var(--p-space-2, 8px)}.canvas-map{display:grid;gap:2px;height:220px;padding:var(--p-space-2, 8px);border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container-low);border-radius:var(--p-radius-md, 8px)}.canvas-map span{display:grid;place-items:center;min-height:0;min-width:0;overflow:hidden;font-size:var(--p-font-size-xs, 12px);color:var(--md-sys-color-on-secondary-container);background:var(--md-sys-color-secondary-container);border-radius:var(--p-radius-xs, 2px)}.canvas-map .changed{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.widget-changes{padding-inline-start:var(--p-space-5, 20px);margin:0}.widget-changes li{padding-block:var(--p-space-2, 8px);overflow-wrap:anywhere}.widget-changes span{display:block;font-size:var(--p-font-size-sm, 13px);color:var(--md-sys-color-on-surface-variant)}.studio-status{border-inline-start:3px solid var(--md-sys-color-primary);background:var(--md-sys-color-surface-container-low);padding:var(--p-space-3, 12px);margin:0;line-height:1.5}@container (max-width:420px){.studio-gallery{grid-template-columns:minmax(0,1fr)}.canvas-map{height:180px}}@media(prefers-reduced-motion:reduce){.studio-choice{transition:none}}.arrangement-feedback:empty{display:none}.arrangement-feedback:focus{outline:none}.arrangement-feedback:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:3px}@container (min-width:620px){.arrangement-gallery{grid-template-columns:repeat(3,minmax(0,1fr))}}:host ::ng-deep .studio-tabs>.mat-mdc-tab-header{position:sticky;top:0;z-index:var(--praxis-layer-sticky-local, 100);background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface))}:host ::ng-deep .studio-tabs>.mat-mdc-tab-header .mdc-tab{flex:1 1 0;padding-inline:var(--p-space-1, 4px)}.arrangement-feedback{scroll-margin-block-start:var(--p-space-16, 64px)}.filter-owner-banner{border-color:var(--md-sys-color-tertiary, var(--md-sys-color-primary))}.filter-owner-item{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px}.quick-geometry-rows{display:grid;gap:8px}.quick-geometry-row{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px;padding-block:4px;border-bottom:1px solid var(--md-sys-color-outline-variant, rgba(128, 128, 128, .2))}.quick-geometry-label{display:inline-flex;align-items:center;gap:6px;min-width:0}.quick-geometry-label mat-icon{font-size:18px;width:18px;height:18px;flex-shrink:0;color:var(--md-sys-color-primary, currentColor)}.binder-widget-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0}.binder-widget-trigger-icon{font-size:18px;width:18px;height:18px;flex-shrink:0;color:var(--md-sys-color-primary, currentColor)}.binder-widget-trigger-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.binder-widget-trigger-text small{color:var(--md-sys-color-on-surface-variant, currentColor);font-weight:400}.binder-widget-option-row{position:relative;display:flex;align-items:flex-start;gap:10px;padding-block:4px;min-width:0}.binder-widget-option-icon{font-size:20px;width:20px;height:20px;margin-top:2px;flex-shrink:0;color:var(--md-sys-color-primary, currentColor)}.binder-widget-option-copy{display:flex;flex-direction:column;gap:2px;min-width:0;line-height:1.3}.binder-widget-option-title{font-weight:600;color:var(--md-sys-color-on-surface, currentColor);overflow-wrap:anywhere}.binder-widget-option-desc{font-size:12px;color:var(--md-sys-color-on-surface-variant, currentColor);overflow-wrap:anywhere}.binder-widget-option-sr-key{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;opacity:0;pointer-events:none}.binder-input-suggestions{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.binder-input-suggestion-chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;border:1px solid var(--md-sys-color-outline-variant, rgba(128, 128, 128, .3));background:var(--md-sys-color-surface, transparent);color:var(--md-sys-color-on-surface, currentColor);font-size:12px;cursor:pointer;transition:border-color .1s,background-color .1s}.binder-input-suggestion-chip:hover{border-color:var(--md-sys-color-primary, currentColor);background:var(--md-sys-color-surface-container-low, rgba(128, 128, 128, .08))}.binder-input-suggestion-chip.active{border-color:var(--md-sys-color-primary, currentColor);background:var(--md-sys-color-primary-container, rgba(128, 128, 128, .16));color:var(--md-sys-color-on-primary-container, currentColor)}.binder-active-list{list-style:none;padding-inline-start:0;gap:6px}.binder-active-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:6px;background:var(--md-sys-color-surface, rgba(255, 255, 255, .03))}.binder-active-icon{font-size:18px;width:18px;height:18px;flex-shrink:0;color:var(--md-sys-color-primary, currentColor)}.binder-active-copy{flex:1 1 auto;min-width:0;overflow-wrap:anywhere}.binder-active-select-btn{appearance:none;border:none;background:transparent;color:inherit;font:inherit;text-align:start;padding:2px 4px;border-radius:4px;cursor:pointer}.binder-active-select-btn:hover{background:var(--md-sys-color-surface-container-low, rgba(128, 128, 128, .08))}.binder-active-select-btn:focus-visible{outline:2px solid var(--md-sys-color-primary, currentColor);outline-offset:2px}.binder-preview-badge{display:inline-flex;align-items:center;margin-inline-start:6px;padding:1px 6px;border-radius:999px;font-size:11px;font-weight:600;color:var(--md-sys-color-on-secondary-container, #1d192b);background:var(--md-sys-color-secondary-container, rgba(103, 80, 164, .14))}.binder-unbound-warning{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border-radius:6px;font-size:12px;border:1px solid var(--md-sys-color-tertiary, rgba(180, 110, 0, .35));background:color-mix(in srgb,var(--md-sys-color-tertiary-container, #ffdea6) 45%,transparent);color:var(--md-sys-color-on-surface, inherit)}.binder-unbound-warning__body{display:grid;gap:6px}.binder-unbound-warning__chips{display:flex;flex-wrap:wrap;gap:6px}.binder-unbound-chip{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;border:1px solid var(--md-sys-color-outline-variant, rgba(128, 128, 128, .35));background:var(--md-sys-color-surface, rgba(255, 255, 255, .65));color:inherit;font:inherit;font-size:11px;cursor:pointer}.binder-unbound-chip:hover{border-color:var(--md-sys-color-primary, currentColor)}.binder-unbound-chip:focus-visible{outline:2px solid var(--md-sys-color-primary, currentColor);outline-offset:2px}.guided-presets-bar{display:grid;gap:6px}.datasource-preview-box{display:grid;gap:8px;padding:12px;border-radius:6px;background:var(--md-sys-color-surface-container, rgba(128, 128, 128, .08))}.datasource-preview-json{margin:0;padding:8px;border-radius:4px;font-size:12px;overflow-x:auto;background:var(--md-sys-color-surface, rgba(0, 0, 0, .04))}.composer-rich-field-option{display:flex;flex-direction:column;gap:2px;padding:2px 0;line-height:1.3;min-width:0}.composer-rich-field-option__header{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-width:0}.composer-rich-field-option__label{font-weight:500;color:var(--md-sys-color-on-surface, currentColor);overflow:hidden;text-overflow:ellipsis}.composer-rich-field-option__code{font-family:var(--mat-sys-mono-font, monospace);font-size:11px;padding:1px 6px;border-radius:999px;background:var(--md-sys-color-surface-container-highest, rgba(128, 128, 128, .14));color:var(--md-sys-color-on-surface-variant, currentColor)}.composer-rich-field-option__badge{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--md-sys-color-primary, #4f46e5) 14%,transparent);color:var(--md-sys-color-primary, currentColor)}.composer-rich-field-option__desc{font-size:11px;color:var(--md-sys-color-on-surface-variant, currentColor);white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}\n"] }]
|
|
16388
17652
|
}], ctorParameters: () => [], propDecorators: { page: [{
|
|
16389
17653
|
type: Input
|
|
16390
17654
|
}], layout: [{
|
|
@@ -18318,7 +19582,7 @@ class PageConfigEditorComponent {
|
|
|
18318
19582
|
</div>
|
|
18319
19583
|
}
|
|
18320
19584
|
</div>
|
|
18321
|
-
`, isInline: true, styles: [".page-inset-field{grid-column:1 / -1;min-width:0}.page-config{display:grid;gap:12px;padding:16px;background:var(--md-sys-color-surface-container-lowest, var(--md-sys-color-surface));border:1px solid var(--md-sys-color-outline-variant);border-radius:12px}.page-head{display:flex;align-items:flex-start;gap:12px}.page-head__copy{display:grid;gap:4px}.page-title{font-weight:600;font-size:18px;line-height:1.2}.page-subtitle{max-width:72ch;color:var(--md-sys-color-on-surface-variant);font-size:13px;line-height:1.45}.spacer{flex:1}.page-summary{display:flex;flex-wrap:wrap;gap:8px}.page-tabs{border-radius:16px;overflow:hidden;background:color-mix(in srgb,var(--md-sys-color-surface-container-low) 78%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant) 82%,transparent)}.page-tabs ::ng-deep .mdc-tab{min-width:120px}.page-tabs ::ng-deep .mdc-tab__text-label{font-size:13px;font-weight:600}.page-tab-panel{display:grid;gap:12px;padding:16px 4px 4px}.page-chip{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-radius:999px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant) 88%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface-container-low) 82%,transparent);color:var(--md-sys-color-on-surface-variant);font-size:12px;font-weight:500}.page-chip mat-icon{width:18px;height:18px;font-size:18px}.page-chip--accent{border-color:color-mix(in srgb,var(--md-sys-color-primary) 35%,var(--md-sys-color-outline-variant));color:var(--md-sys-color-primary);background:color-mix(in srgb,var(--md-sys-color-primary-container) 30%,transparent)}.page-chip--warning{border-color:color-mix(in srgb,var(--md-sys-color-error) 35%,var(--md-sys-color-outline-variant));color:var(--md-sys-color-error);background:color-mix(in srgb,var(--md-sys-color-error-container) 35%,transparent)}.page-identity{display:grid;gap:4px;padding:12px 14px;border-radius:12px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant) 82%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface-container-low) 70%,transparent);font-size:13px;color:var(--md-sys-color-on-surface-variant)}.page-section{font-weight:600;margin-top:8px}.page-section-help{margin-top:-4px;color:var(--md-sys-color-on-surface-variant);font-size:13px;line-height:1.45}.grid-form{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}.state-grid{display:grid;gap:16px}.state-values,.state-group{padding:14px;border:1px solid var(--md-sys-color-outline-variant);border-radius:14px;background:linear-gradient(180deg,color-mix(in srgb,var(--md-sys-color-surface-container-low) 92%,transparent),transparent),var(--md-sys-color-surface-container-lowest, var(--md-sys-color-surface))}.state-group{display:grid;gap:12px}.state-group-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.state-group-title{font-weight:600}.state-group-help{color:var(--md-sys-color-on-surface-variant);font-size:13px}.state-list{display:grid;gap:12px}.state-card{display:grid;gap:12px;padding:12px;border-radius:12px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant) 80%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface-container) 84%,transparent)}.state-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.state-card-title{font-weight:600}.state-card-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}.span-2{grid-column:span 2}.span-3{grid-column:1 / -1}.state-empty{color:var(--md-sys-color-on-surface-variant);font-size:13px}.state-operator-hint{display:flex;align-items:center;gap:8px;min-height:48px;padding:0 12px;border-radius:12px;color:var(--md-sys-color-on-surface-variant);background:color-mix(in srgb,var(--md-sys-color-secondary-container) 35%,transparent)}.state-operator-hint mat-icon{color:var(--md-sys-color-secondary)}.page-actions{display:flex;justify-content:flex-end}.page-error{color:var(--md-sys-color-error);font-size:13px}.page-config .mat-mdc-form-field{width:100%;--mdc-outlined-text-field-container-height: 48px;--mdc-outlined-text-field-outline-color: var(--md-sys-color-outline-variant);--mdc-outlined-text-field-hover-outline-color: var(--md-sys-color-outline);--mdc-outlined-text-field-focus-outline-color: var(--md-sys-color-primary);--mdc-outlined-text-field-error-outline-color: var(--md-sys-color-error);--mdc-outlined-text-field-error-focus-outline-color: var(--md-sys-color-error);--mdc-outlined-text-field-error-hover-outline-color: var(--md-sys-color-error);--mdc-outlined-text-field-label-text-color: var(--md-sys-color-on-surface-variant);--mdc-outlined-text-field-input-text-color: var(--md-sys-color-on-surface);--mdc-outlined-text-field-supporting-text-color: var(--md-sys-color-on-surface-variant)}.help-icon-button{margin-right:-4px}.mat-mdc-form-field-icon-suffix{align-self:center}@media(max-width:720px){.span-2,.span-3{grid-column:auto}.page-head{flex-direction:column}.state-group-head{align-items:stretch;flex-direction:column}}\n"], dependencies: [{ kind: "component", type: PagePresetComparisonComponent, selector: "praxis-page-preset-comparison", inputs: ["expanded", "layoutId", "themeId", "layoutPresetOptions", "canvas"], outputs: ["choose"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { 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: 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.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { 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: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i9.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: i9.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: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
19585
|
+
`, isInline: true, styles: [".page-inset-field{grid-column:1 / -1;min-width:0}.page-config{display:grid;gap:12px;padding:16px;background:var(--md-sys-color-surface-container-lowest, var(--md-sys-color-surface));border:1px solid var(--md-sys-color-outline-variant);border-radius:12px}.page-head{display:flex;align-items:flex-start;gap:12px}.page-head__copy{display:grid;gap:4px}.page-title{font-weight:600;font-size:18px;line-height:1.2}.page-subtitle{max-width:72ch;color:var(--md-sys-color-on-surface-variant);font-size:13px;line-height:1.45}.spacer{flex:1}.page-summary{display:flex;flex-wrap:wrap;gap:8px}.page-tabs{border-radius:16px;overflow:hidden;background:color-mix(in srgb,var(--md-sys-color-surface-container-low) 78%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant) 82%,transparent)}.page-tabs ::ng-deep .mdc-tab{min-width:120px}.page-tabs ::ng-deep .mdc-tab__text-label{font-size:13px;font-weight:600}.page-tab-panel{display:grid;gap:12px;padding:16px 4px 4px}.page-chip{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-radius:999px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant) 88%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface-container-low) 82%,transparent);color:var(--md-sys-color-on-surface-variant);font-size:12px;font-weight:500}.page-chip mat-icon{width:18px;height:18px;font-size:18px}.page-chip--accent{border-color:color-mix(in srgb,var(--md-sys-color-primary) 35%,var(--md-sys-color-outline-variant));color:var(--md-sys-color-primary);background:color-mix(in srgb,var(--md-sys-color-primary-container) 30%,transparent)}.page-chip--warning{border-color:color-mix(in srgb,var(--md-sys-color-error) 35%,var(--md-sys-color-outline-variant));color:var(--md-sys-color-error);background:color-mix(in srgb,var(--md-sys-color-error-container) 35%,transparent)}.page-identity{display:grid;gap:4px;padding:12px 14px;border-radius:12px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant) 82%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface-container-low) 70%,transparent);font-size:13px;color:var(--md-sys-color-on-surface-variant)}.page-section{font-weight:600;margin-top:8px}.page-section-help{margin-top:-4px;color:var(--md-sys-color-on-surface-variant);font-size:13px;line-height:1.45}.grid-form{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}.state-grid{display:grid;gap:16px}.state-values,.state-group{padding:14px;border:1px solid var(--md-sys-color-outline-variant);border-radius:14px;background:linear-gradient(180deg,color-mix(in srgb,var(--md-sys-color-surface-container-low) 92%,transparent),transparent),var(--md-sys-color-surface-container-lowest, var(--md-sys-color-surface))}.state-group{display:grid;gap:12px}.state-group-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.state-group-title{font-weight:600}.state-group-help{color:var(--md-sys-color-on-surface-variant);font-size:13px}.state-list{display:grid;gap:12px}.state-card{display:grid;gap:12px;padding:12px;border-radius:12px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant) 80%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface-container) 84%,transparent)}.state-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.state-card-title{font-weight:600}.state-card-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}.span-2{grid-column:span 2}.span-3{grid-column:1 / -1}.state-empty{color:var(--md-sys-color-on-surface-variant);font-size:13px}.state-operator-hint{display:flex;align-items:center;gap:8px;min-height:48px;padding:0 12px;border-radius:12px;color:var(--md-sys-color-on-surface-variant);background:color-mix(in srgb,var(--md-sys-color-secondary-container) 35%,transparent)}.state-operator-hint mat-icon{color:var(--md-sys-color-secondary)}.page-actions{display:flex;justify-content:flex-end}.page-error{color:var(--md-sys-color-error);font-size:13px}.page-config .mat-mdc-form-field{width:100%;--mdc-outlined-text-field-container-height: 48px;--mdc-outlined-text-field-outline-color: var(--md-sys-color-outline-variant);--mdc-outlined-text-field-hover-outline-color: var(--md-sys-color-outline);--mdc-outlined-text-field-focus-outline-color: var(--md-sys-color-primary);--mdc-outlined-text-field-error-outline-color: var(--md-sys-color-error);--mdc-outlined-text-field-error-focus-outline-color: var(--md-sys-color-error);--mdc-outlined-text-field-error-hover-outline-color: var(--md-sys-color-error);--mdc-outlined-text-field-label-text-color: var(--md-sys-color-on-surface-variant);--mdc-outlined-text-field-input-text-color: var(--md-sys-color-on-surface);--mdc-outlined-text-field-supporting-text-color: var(--md-sys-color-on-surface-variant)}.help-icon-button{margin-right:-4px}.mat-mdc-form-field-icon-suffix{align-self:center}@media(max-width:720px){.span-2,.span-3{grid-column:auto}.page-head{flex-direction:column}.state-group-head{align-items:stretch;flex-direction:column}}\n"], dependencies: [{ kind: "component", type: PagePresetComparisonComponent, selector: "praxis-page-preset-comparison", inputs: ["expanded", "layoutId", "themeId", "layoutPresetOptions", "canvas"], outputs: ["choose"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { 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: MatFormFieldModule }, { kind: "component", type: i6.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i6.MatLabel, selector: "mat-label" }, { kind: "directive", type: i6.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i8.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: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3$1.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: i3$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i9.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i9.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: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
18322
19586
|
}
|
|
18323
19587
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PageConfigEditorComponent, decorators: [{
|
|
18324
19588
|
type: Component,
|
|
@@ -19053,6 +20317,7 @@ const PAGE_BUILDER_AI_CAPABILITIES = {
|
|
|
19053
20317
|
'Payloads legados de layout ainda podem ser ingeridos, mas sao normalizados para canvas e nao devem ser reemitidos.',
|
|
19054
20318
|
'pageIdentity identifica o escopo de persistencia e nao pertence ao objeto page.',
|
|
19055
20319
|
'authoringCapabilities é um grant transitório do host para limitar affordances visíveis; não integra WidgetPageDefinition, não é persistido e não concede autoridade de edição à IA.',
|
|
20320
|
+
'Tokens de interpolacao de estado/contexto em inputs de widgets ({{state.<path> | pipe}} ou ${state.<path> | pipe}) suportam pipes declarativos CSP-safe encadeaveis: default:<fallback>, number, round[:decimals], percent[:decimals], currency[:code], uppercase e lowercase.',
|
|
19056
20321
|
],
|
|
19057
20322
|
capabilities: [
|
|
19058
20323
|
{ path: 'uiCompositionPlan', category: 'page', valueKind: 'object', description: 'Plano declarativo intermediario aceito em preview agentic antes do WidgetPageDefinition.' },
|
|
@@ -20467,6 +21732,7 @@ const PRAXIS_PAGE_BUILDER_AUTHORING_MANIFEST = {
|
|
|
20467
21732
|
'Agentic preview preflights UiCompositionPlan against the target registry and applies the attested WidgetPageDefinition; persistence rebuilds patch.page from that page while the surrounding envelope remains diagnostics/audit data.',
|
|
20468
21733
|
'Save uses pageIdentity and ETag policy from praxis-config-starter; pageIdentity is not persisted inside WidgetPageDefinition.',
|
|
20469
21734
|
'Settings panel apply/save/reset/reopen must preserve page settings and widget shell config without hidden normalization drift.',
|
|
21735
|
+
'Widget inputs and inputs.context may reference page.state.values and page.state.derived using {{state.<path> | pipe}} or ${state.<path> | pipe} with CSP-safe declarative pipes (default/coalesce, round, number, percent, currency, uppercase, lowercase); the Page State Binder detects unbound state tokens across widget inputs and *Expr properties for one-click binding.',
|
|
20470
21736
|
],
|
|
20471
21737
|
examples: [
|
|
20472
21738
|
{
|
|
@@ -32344,11 +33610,17 @@ class DynamicPageBuilderComponent {
|
|
|
32344
33610
|
}
|
|
32345
33611
|
resolveWidgetDisplayName(widget) {
|
|
32346
33612
|
const shellTitle = this.i18n.resolve(widget.shell?.title, '').trim();
|
|
32347
|
-
if (shellTitle)
|
|
33613
|
+
if (shellTitle && !shellTitle.includes('${') && !shellTitle.includes('{{'))
|
|
32348
33614
|
return shellTitle;
|
|
33615
|
+
const semanticTitle = resolveFriendlyWidgetTitle(widget, '', {
|
|
33616
|
+
includeKeySuffix: false,
|
|
33617
|
+
allowResourcePathFallback: true,
|
|
33618
|
+
}).trim();
|
|
33619
|
+
if (semanticTitle && semanticTitle.toLowerCase() !== 'rich content')
|
|
33620
|
+
return semanticTitle;
|
|
32349
33621
|
const componentId = widget.definition?.id || '';
|
|
32350
33622
|
const metadata = componentId ? this.componentMetadata.get(componentId) : undefined;
|
|
32351
|
-
return metadata?.friendlyName || componentId || widget.key;
|
|
33623
|
+
return metadata?.friendlyName || semanticTitle || componentId || widget.key;
|
|
32352
33624
|
}
|
|
32353
33625
|
formatAgenticFailures(failureCodes) {
|
|
32354
33626
|
const codes = (failureCodes ?? []).filter((code) => !!code);
|