@praxisui/page-builder 10.0.0-rc.31 → 10.0.0-rc.32
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.
|
@@ -253,7 +253,7 @@ class FloatingToolbarComponent {
|
|
|
253
253
|
</div>
|
|
254
254
|
</div>
|
|
255
255
|
}
|
|
256
|
-
`, isInline: true, styles: [":host{position:absolute;inset:0;pointer-events:none}.pdx-floating-toolbar{position:absolute;right:16px;bottom:16px;display:flex;gap:8px;align-items:center;pointer-events:auto}.pdx-floating-toolbar--connections{
|
|
256
|
+
`, isInline: true, styles: [":host{position:absolute;inset:0;pointer-events:none}.pdx-floating-toolbar{position:absolute;right:16px;bottom:16px;display:flex;gap:8px;align-items:center;pointer-events:auto}.pdx-floating-toolbar--connections{bottom:16px;top:auto}.pdx-mode-chip{display:inline-flex;align-items:center;min-height:28px;max-width:148px;padding:3px 9px;border:1px solid rgba(103,80,164,.22);border-radius:999px;background:#ffffffeb;color:#3f2d75;font-size:11px;font-weight:700;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;box-shadow:0 8px 22px #0f172a1f}.pdx-actions{display:flex;gap:8px}.pdx-preview-toggle--active{outline:2px solid rgba(103,80,164,.48);outline-offset:2px}@media(max-width:720px){.pdx-actions{gap:6px}.pdx-floating-toolbar{right:12px;bottom:12px}.pdx-floating-toolbar--connections{bottom:12px;top:auto}.pdx-mode-chip{max-width:112px}}\n"], dependencies: [{ kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatMiniFabButton, selector: "button[mat-mini-fab], a[mat-mini-fab], button[matMiniFab], a[matMiniFab]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
257
257
|
}
|
|
258
258
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FloatingToolbarComponent, decorators: [{
|
|
259
259
|
type: Component,
|
|
@@ -309,7 +309,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
|
|
|
309
309
|
</div>
|
|
310
310
|
</div>
|
|
311
311
|
}
|
|
312
|
-
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{position:absolute;inset:0;pointer-events:none}.pdx-floating-toolbar{position:absolute;right:16px;bottom:16px;display:flex;gap:8px;align-items:center;pointer-events:auto}.pdx-floating-toolbar--connections{
|
|
312
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{position:absolute;inset:0;pointer-events:none}.pdx-floating-toolbar{position:absolute;right:16px;bottom:16px;display:flex;gap:8px;align-items:center;pointer-events:auto}.pdx-floating-toolbar--connections{bottom:16px;top:auto}.pdx-mode-chip{display:inline-flex;align-items:center;min-height:28px;max-width:148px;padding:3px 9px;border:1px solid rgba(103,80,164,.22);border-radius:999px;background:#ffffffeb;color:#3f2d75;font-size:11px;font-weight:700;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;box-shadow:0 8px 22px #0f172a1f}.pdx-actions{display:flex;gap:8px}.pdx-preview-toggle--active{outline:2px solid rgba(103,80,164,.48);outline-offset:2px}@media(max-width:720px){.pdx-actions{gap:6px}.pdx-floating-toolbar{right:12px;bottom:12px}.pdx-floating-toolbar--connections{bottom:12px;top:auto}.pdx-mode-chip{max-width:112px}}\n"] }]
|
|
313
313
|
}], propDecorators: { visible: [{
|
|
314
314
|
type: Input
|
|
315
315
|
}], canUndo: [{
|
|
@@ -1479,6 +1479,14 @@ const PRAXIS_PAGE_BUILDER_EN_US = {
|
|
|
1479
1479
|
'praxis.pageBuilder.connections.stateName.apply': "Apply name",
|
|
1480
1480
|
'praxis.pageBuilder.connections.stateName.cancel': "Cancel",
|
|
1481
1481
|
'praxis.pageBuilder.connections.stateName.edit': "Name page information",
|
|
1482
|
+
'praxis.pageBuilder.editor.state.autoBindAction': "Auto-bind placeholders",
|
|
1483
|
+
'praxis.pageBuilder.editor.state.unbindAction': "Remove binding",
|
|
1484
|
+
'praxis.pageBuilder.editor.state.unboundSuccess': "Removed binding {{widget}}.{{input}} from draft.",
|
|
1485
|
+
'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.",
|
|
1487
|
+
'praxis.pageBuilder.palette.initialWidth': "Initial width",
|
|
1488
|
+
'praxis.pageBuilder.palette.keepOpen': "Keep open for multi-insert",
|
|
1489
|
+
'praxis.pageBuilder.palette.done': "Done",
|
|
1482
1490
|
};
|
|
1483
1491
|
|
|
1484
1492
|
const PRAXIS_PAGE_BUILDER_PT_BR = {
|
|
@@ -2600,6 +2608,14 @@ const PRAXIS_PAGE_BUILDER_PT_BR = {
|
|
|
2600
2608
|
'praxis.pageBuilder.connections.stateName.apply': "Aplicar nome",
|
|
2601
2609
|
'praxis.pageBuilder.connections.stateName.cancel': "Cancelar",
|
|
2602
2610
|
'praxis.pageBuilder.connections.stateName.edit': "Nomear informação da página",
|
|
2611
|
+
'praxis.pageBuilder.editor.state.autoBindAction': "Auto-vincular placeholders",
|
|
2612
|
+
'praxis.pageBuilder.editor.state.unbindAction': "Remover vínculo",
|
|
2613
|
+
'praxis.pageBuilder.editor.state.unboundSuccess': "Vínculo {{widget}}.{{input}} removido do rascunho.",
|
|
2614
|
+
'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.",
|
|
2616
|
+
'praxis.pageBuilder.palette.initialWidth': "Largura inicial",
|
|
2617
|
+
'praxis.pageBuilder.palette.keepOpen': "Manter aberto para inserção múltipla",
|
|
2618
|
+
'praxis.pageBuilder.palette.done': "Concluir",
|
|
2603
2619
|
};
|
|
2604
2620
|
|
|
2605
2621
|
function createPraxisPageBuilderI18nConfig(options = {}) {
|
|
@@ -2641,6 +2657,16 @@ class ComponentPaletteDialogComponent {
|
|
|
2641
2657
|
data;
|
|
2642
2658
|
hostElement = inject((ElementRef));
|
|
2643
2659
|
query = signal('', ...(ngDevMode ? [{ debugName: "query" }] : /* istanbul ignore next */ []));
|
|
2660
|
+
selectedColSpan = signal(undefined, ...(ngDevMode ? [{ debugName: "selectedColSpan" }] : /* istanbul ignore next */ []));
|
|
2661
|
+
keepOpen = signal(false, ...(ngDevMode ? [{ debugName: "keepOpen" }] : /* istanbul ignore next */ []));
|
|
2662
|
+
insertedCount = signal(0, ...(ngDevMode ? [{ debugName: "insertedCount" }] : /* istanbul ignore next */ []));
|
|
2663
|
+
spanOptions = [
|
|
2664
|
+
{ label: 'Auto', value: undefined },
|
|
2665
|
+
{ label: '25% (3 col)', value: 3 },
|
|
2666
|
+
{ label: '33% (4 col)', value: 4 },
|
|
2667
|
+
{ label: '50% (6 col)', value: 6 },
|
|
2668
|
+
{ label: '100% (12 col)', value: 12 },
|
|
2669
|
+
];
|
|
2644
2670
|
all = signal([], ...(ngDevMode ? [{ debugName: "all" }] : /* istanbul ignore next */ []));
|
|
2645
2671
|
filtered = computed(() => this.applyFilters(), ...(ngDevMode ? [{ debugName: "filtered" }] : /* istanbul ignore next */ []));
|
|
2646
2672
|
density = computed(() => {
|
|
@@ -2661,12 +2687,20 @@ class ComponentPaletteDialogComponent {
|
|
|
2661
2687
|
this.all.set(this.buildEntries(this.registry.getAll()));
|
|
2662
2688
|
}
|
|
2663
2689
|
select(entry) {
|
|
2664
|
-
this.
|
|
2690
|
+
const colSpan = this.selectedColSpan();
|
|
2691
|
+
const payload = {
|
|
2665
2692
|
componentId: entry.componentId,
|
|
2666
2693
|
...(entry.presetId ? { presetId: entry.presetId } : {}),
|
|
2667
2694
|
...(entry.inputs ? { inputs: JSON.parse(JSON.stringify(entry.inputs)) } : {}),
|
|
2668
2695
|
...(entry.shell ? { shell: JSON.parse(JSON.stringify(entry.shell)) } : {}),
|
|
2669
|
-
|
|
2696
|
+
...(typeof colSpan === 'number' ? { colSpan } : {}),
|
|
2697
|
+
};
|
|
2698
|
+
if (this.keepOpen() && typeof this.data?.onInsert === 'function') {
|
|
2699
|
+
this.data.onInsert(payload);
|
|
2700
|
+
this.insertedCount.update((n) => n + 1);
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
this.dialogRef.close(payload);
|
|
2670
2704
|
}
|
|
2671
2705
|
handleSearchKeydown(event) {
|
|
2672
2706
|
if (event.key !== 'ArrowDown')
|
|
@@ -2879,6 +2913,39 @@ class ComponentPaletteDialogComponent {
|
|
|
2879
2913
|
data-testid="component-palette-search"
|
|
2880
2914
|
/>
|
|
2881
2915
|
</mat-form-field>
|
|
2916
|
+
<div class="pdx-palette-toolbar" data-testid="component-palette-toolbar">
|
|
2917
|
+
<div class="pdx-palette-span-group" role="group" [attr.aria-label]="tx('palette.initialWidth', 'Largura inicial')">
|
|
2918
|
+
<span class="pdx-palette-toolbar-label">{{ tx('palette.initialWidth', 'Largura inicial') }}:</span>
|
|
2919
|
+
@for (opt of spanOptions; track opt.value) {
|
|
2920
|
+
<button
|
|
2921
|
+
mat-stroked-button
|
|
2922
|
+
type="button"
|
|
2923
|
+
class="pdx-span-chip"
|
|
2924
|
+
[class.selected]="selectedColSpan() === opt.value"
|
|
2925
|
+
[attr.aria-pressed]="selectedColSpan() === opt.value"
|
|
2926
|
+
[attr.data-testid]="'palette-colspan-' + (opt.value ?? 'auto')"
|
|
2927
|
+
(click)="selectedColSpan.set(opt.value)"
|
|
2928
|
+
>
|
|
2929
|
+
{{ opt.label }}
|
|
2930
|
+
</button>
|
|
2931
|
+
}
|
|
2932
|
+
</div>
|
|
2933
|
+
<button
|
|
2934
|
+
mat-stroked-button
|
|
2935
|
+
type="button"
|
|
2936
|
+
class="pdx-keep-open-chip"
|
|
2937
|
+
[class.selected]="keepOpen()"
|
|
2938
|
+
[attr.aria-pressed]="keepOpen()"
|
|
2939
|
+
data-testid="palette-keep-open-toggle"
|
|
2940
|
+
(click)="keepOpen.set(!keepOpen())"
|
|
2941
|
+
>
|
|
2942
|
+
<mat-icon [praxisIcon]="keepOpen() ? 'check_box' : 'check_box_outline_blank'" aria-hidden="true"></mat-icon>
|
|
2943
|
+
{{ tx('palette.keepOpen', 'Manter aberta') }}
|
|
2944
|
+
@if (insertedCount() > 0) {
|
|
2945
|
+
<span class="pdx-inserted-badge" data-testid="palette-inserted-count">+{{ insertedCount() }}</span>
|
|
2946
|
+
}
|
|
2947
|
+
</button>
|
|
2948
|
+
</div>
|
|
2882
2949
|
@if (filtered().length) {
|
|
2883
2950
|
<div [class]="'pdx-grid ' + density()">
|
|
2884
2951
|
@for (entry of filtered(); track trackById($index, entry)) {
|
|
@@ -2923,9 +2990,11 @@ class ComponentPaletteDialogComponent {
|
|
|
2923
2990
|
|
|
2924
2991
|
</div>
|
|
2925
2992
|
<div mat-dialog-actions align="end">
|
|
2926
|
-
<button mat-stroked-button color="primary" mat-dialog-close
|
|
2993
|
+
<button mat-stroked-button color="primary" mat-dialog-close data-testid="component-palette-close">
|
|
2994
|
+
{{ insertedCount() > 0 ? tx('palette.done', 'Concluir') : tx('palette.cancel', 'Cancelar') }}
|
|
2995
|
+
</button>
|
|
2927
2996
|
</div>
|
|
2928
|
-
`, 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:12px}.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: 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.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { 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: 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 });
|
|
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: 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.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { 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: 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 });
|
|
2929
2998
|
}
|
|
2930
2999
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ComponentPaletteDialogComponent, decorators: [{
|
|
2931
3000
|
type: Component,
|
|
@@ -2953,6 +3022,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
|
|
|
2953
3022
|
data-testid="component-palette-search"
|
|
2954
3023
|
/>
|
|
2955
3024
|
</mat-form-field>
|
|
3025
|
+
<div class="pdx-palette-toolbar" data-testid="component-palette-toolbar">
|
|
3026
|
+
<div class="pdx-palette-span-group" role="group" [attr.aria-label]="tx('palette.initialWidth', 'Largura inicial')">
|
|
3027
|
+
<span class="pdx-palette-toolbar-label">{{ tx('palette.initialWidth', 'Largura inicial') }}:</span>
|
|
3028
|
+
@for (opt of spanOptions; track opt.value) {
|
|
3029
|
+
<button
|
|
3030
|
+
mat-stroked-button
|
|
3031
|
+
type="button"
|
|
3032
|
+
class="pdx-span-chip"
|
|
3033
|
+
[class.selected]="selectedColSpan() === opt.value"
|
|
3034
|
+
[attr.aria-pressed]="selectedColSpan() === opt.value"
|
|
3035
|
+
[attr.data-testid]="'palette-colspan-' + (opt.value ?? 'auto')"
|
|
3036
|
+
(click)="selectedColSpan.set(opt.value)"
|
|
3037
|
+
>
|
|
3038
|
+
{{ opt.label }}
|
|
3039
|
+
</button>
|
|
3040
|
+
}
|
|
3041
|
+
</div>
|
|
3042
|
+
<button
|
|
3043
|
+
mat-stroked-button
|
|
3044
|
+
type="button"
|
|
3045
|
+
class="pdx-keep-open-chip"
|
|
3046
|
+
[class.selected]="keepOpen()"
|
|
3047
|
+
[attr.aria-pressed]="keepOpen()"
|
|
3048
|
+
data-testid="palette-keep-open-toggle"
|
|
3049
|
+
(click)="keepOpen.set(!keepOpen())"
|
|
3050
|
+
>
|
|
3051
|
+
<mat-icon [praxisIcon]="keepOpen() ? 'check_box' : 'check_box_outline_blank'" aria-hidden="true"></mat-icon>
|
|
3052
|
+
{{ tx('palette.keepOpen', 'Manter aberta') }}
|
|
3053
|
+
@if (insertedCount() > 0) {
|
|
3054
|
+
<span class="pdx-inserted-badge" data-testid="palette-inserted-count">+{{ insertedCount() }}</span>
|
|
3055
|
+
}
|
|
3056
|
+
</button>
|
|
3057
|
+
</div>
|
|
2956
3058
|
@if (filtered().length) {
|
|
2957
3059
|
<div [class]="'pdx-grid ' + density()">
|
|
2958
3060
|
@for (entry of filtered(); track trackById($index, entry)) {
|
|
@@ -2997,9 +3099,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
|
|
|
2997
3099
|
|
|
2998
3100
|
</div>
|
|
2999
3101
|
<div mat-dialog-actions align="end">
|
|
3000
|
-
<button mat-stroked-button color="primary" mat-dialog-close
|
|
3102
|
+
<button mat-stroked-button color="primary" mat-dialog-close data-testid="component-palette-close">
|
|
3103
|
+
{{ insertedCount() > 0 ? tx('palette.done', 'Concluir') : tx('palette.cancel', 'Cancelar') }}
|
|
3104
|
+
</button>
|
|
3001
3105
|
</div>
|
|
3002
|
-
`, providers: [providePraxisPageBuilderI18n()], changeDetection: ChangeDetectionStrategy.OnPush, 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:12px}.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"] }]
|
|
3106
|
+
`, providers: [providePraxisPageBuilderI18n()], changeDetection: ChangeDetectionStrategy.OnPush, 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"] }]
|
|
3003
3107
|
}], ctorParameters: () => [{ type: i1.MatDialogRef }, { type: i2$2.ComponentMetadataRegistry }, { type: i2$2.PraxisI18nService }, { type: undefined, decorators: [{
|
|
3004
3108
|
type: Inject,
|
|
3005
3109
|
args: [MAT_DIALOG_DATA]
|
|
@@ -5489,6 +5593,244 @@ function clone$3(value) {
|
|
|
5489
5593
|
return JSON.parse(JSON.stringify(value));
|
|
5490
5594
|
}
|
|
5491
5595
|
|
|
5596
|
+
const LIMIT_KEYS = ['minColSpan', 'maxColSpan', 'minRowSpan', 'maxRowSpan', 'minHeightPx', 'maxHeightPx'];
|
|
5597
|
+
/** Editor projection of Core canvas constraints; never a second persisted document. */
|
|
5598
|
+
class CanvasConstraintEditorModel {
|
|
5599
|
+
form = new FormArray([]);
|
|
5600
|
+
targets = [];
|
|
5601
|
+
baseline = { widgets: [] };
|
|
5602
|
+
hydrate(page) {
|
|
5603
|
+
this.form.clear({ emitEvent: false });
|
|
5604
|
+
this.targets = [];
|
|
5605
|
+
this.baseline = structuredClone(page);
|
|
5606
|
+
// Missing variants inherit Core's base canvas. Expose their override controls
|
|
5607
|
+
// without creating persisted blocks until a value is actually edited.
|
|
5608
|
+
const scopes = ['base', 'desktop', 'tablet', 'mobile'];
|
|
5609
|
+
for (const scope of scopes) {
|
|
5610
|
+
for (const [key, item] of Object.entries(page.canvas?.items ?? {})) {
|
|
5611
|
+
const widget = page.widgets.find(candidate => candidate.key === key);
|
|
5612
|
+
const override = scope === 'base' ? undefined : page.deviceLayouts?.[scope]?.canvas?.items?.[key];
|
|
5613
|
+
if (!widget || override?.hidden)
|
|
5614
|
+
continue;
|
|
5615
|
+
const constraints = scope === 'base' ? item.constraints : override?.constraints;
|
|
5616
|
+
const size = scope === 'base' ? item.contentSize : override?.contentSize;
|
|
5617
|
+
const seed = {
|
|
5618
|
+
minColSpan: constraints?.minColSpan ?? null, maxColSpan: constraints?.maxColSpan ?? null,
|
|
5619
|
+
minRowSpan: constraints?.minRowSpan ?? null, maxRowSpan: constraints?.maxRowSpan ?? null,
|
|
5620
|
+
minHeightPx: constraints?.minHeightPx ?? null, maxHeightPx: constraints?.maxHeightPx ?? null,
|
|
5621
|
+
heightMode: size === 'auto' ? 'auto' : size ? 'fixed' : 'inherit',
|
|
5622
|
+
heightPx: size && size !== 'auto' ? size.heightPx : null,
|
|
5623
|
+
offsetTopPx: size && size !== 'auto' ? size.offsetTopPx ?? 0 : null,
|
|
5624
|
+
};
|
|
5625
|
+
const title = this.resolveFriendlyWidgetTitle(widget, key);
|
|
5626
|
+
this.targets.push({ key, title, scope, seed });
|
|
5627
|
+
this.form.push(new FormGroup({
|
|
5628
|
+
minColSpan: new FormControl(seed.minColSpan), maxColSpan: new FormControl(seed.maxColSpan),
|
|
5629
|
+
minRowSpan: new FormControl(seed.minRowSpan), maxRowSpan: new FormControl(seed.maxRowSpan),
|
|
5630
|
+
minHeightPx: new FormControl(seed.minHeightPx), maxHeightPx: new FormControl(seed.maxHeightPx),
|
|
5631
|
+
heightMode: new FormControl(seed.heightMode, { nonNullable: true }),
|
|
5632
|
+
heightPx: new FormControl(seed.heightPx), offsetTopPx: new FormControl(seed.offsetTopPx),
|
|
5633
|
+
}), { emitEvent: false });
|
|
5634
|
+
}
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
/** Mutates only the caller's cloned draft, preserving unknown fields and untouched overrides. */
|
|
5638
|
+
apply(page) {
|
|
5639
|
+
for (const group of this.form.controls) {
|
|
5640
|
+
for (const control of Object.values(group.controls)) {
|
|
5641
|
+
const errors = { ...control.errors };
|
|
5642
|
+
delete errors['canvasLimit'];
|
|
5643
|
+
control.setErrors(Object.keys(errors).length ? errors : null, { emitEvent: false });
|
|
5644
|
+
}
|
|
5645
|
+
}
|
|
5646
|
+
for (const [index, target] of this.targets.entries()) {
|
|
5647
|
+
const values = this.form.at(index).getRawValue();
|
|
5648
|
+
const invalidKeys = LIMIT_KEYS.filter(key => values[key] !== null && (!Number.isSafeInteger(values[key]) || values[key] < 1));
|
|
5649
|
+
if (invalidKeys.length) {
|
|
5650
|
+
return this.fail(index, 'integer', invalidKeys);
|
|
5651
|
+
}
|
|
5652
|
+
const heightChanged = values.heightMode !== target.seed.heightMode || values.heightPx !== target.seed.heightPx || values.offsetTopPx !== target.seed.offsetTopPx;
|
|
5653
|
+
if (!heightChanged && !LIMIT_KEYS.some(key => values[key] !== target.seed[key]))
|
|
5654
|
+
continue;
|
|
5655
|
+
const baseItem = page.canvas?.items[target.key];
|
|
5656
|
+
if (!baseItem)
|
|
5657
|
+
continue;
|
|
5658
|
+
const canvas = target.scope === 'base' ? page.canvas :
|
|
5659
|
+
(((page.deviceLayouts ??= {})[target.scope] ??= {}).canvas ??= {});
|
|
5660
|
+
const item = target.scope === 'base' ? baseItem :
|
|
5661
|
+
((canvas.items ??= {})[target.key] ??= {});
|
|
5662
|
+
if (heightChanged) {
|
|
5663
|
+
if (values.heightMode === 'inherit')
|
|
5664
|
+
delete item.contentSize;
|
|
5665
|
+
else if (values.heightMode === 'auto')
|
|
5666
|
+
item.contentSize = 'auto';
|
|
5667
|
+
else {
|
|
5668
|
+
if (!Number.isSafeInteger(values.heightPx) || values.heightPx < 160 || !Number.isSafeInteger(values.offsetTopPx ?? 0) || (values.offsetTopPx ?? 0) < 0) {
|
|
5669
|
+
return this.fail(index, 'height', ['heightPx', 'offsetTopPx']);
|
|
5670
|
+
}
|
|
5671
|
+
item.contentSize = { heightPx: values.heightPx, offsetTopPx: values.offsetTopPx ?? 0 };
|
|
5672
|
+
}
|
|
5673
|
+
}
|
|
5674
|
+
const constraints = { ...item.constraints };
|
|
5675
|
+
for (const key of LIMIT_KEYS) {
|
|
5676
|
+
if (values[key] === target.seed[key])
|
|
5677
|
+
continue;
|
|
5678
|
+
if (values[key] === null)
|
|
5679
|
+
delete constraints[key];
|
|
5680
|
+
else
|
|
5681
|
+
constraints[key] = values[key];
|
|
5682
|
+
}
|
|
5683
|
+
if (Object.keys(constraints).length)
|
|
5684
|
+
item.constraints = constraints;
|
|
5685
|
+
else
|
|
5686
|
+
delete item.constraints;
|
|
5687
|
+
}
|
|
5688
|
+
for (const [index, target] of this.targets.entries()) {
|
|
5689
|
+
const base = page.canvas?.items[target.key];
|
|
5690
|
+
if (!base)
|
|
5691
|
+
continue;
|
|
5692
|
+
const canvas = target.scope === 'base' ? undefined : page.deviceLayouts?.[target.scope]?.canvas;
|
|
5693
|
+
const override = canvas?.items?.[target.key];
|
|
5694
|
+
const limits = { ...base.constraints, ...override?.constraints };
|
|
5695
|
+
const minHeight = limits.minHeightPx ?? 160;
|
|
5696
|
+
const maxHeight = limits.maxHeightPx ?? Number.MAX_SAFE_INTEGER;
|
|
5697
|
+
if (minHeight < 160 || maxHeight < minHeight)
|
|
5698
|
+
return this.fail(index, 'height', ['minHeightPx', 'maxHeightPx']);
|
|
5699
|
+
const size = override?.contentSize ?? base.contentSize;
|
|
5700
|
+
if (size && size !== 'auto' && (size.heightPx < minHeight || size.heightPx > maxHeight))
|
|
5701
|
+
return this.fail(index, 'height', ['heightPx', 'minHeightPx', 'maxHeightPx']);
|
|
5702
|
+
const columns = canvas?.columns ?? page.canvas.columns;
|
|
5703
|
+
const minCol = limits.minColSpan ?? 1;
|
|
5704
|
+
const maxCol = limits.maxColSpan ?? columns;
|
|
5705
|
+
const minRow = limits.minRowSpan ?? 1;
|
|
5706
|
+
const maxRow = limits.maxRowSpan ?? Number.MAX_SAFE_INTEGER;
|
|
5707
|
+
if (minCol > columns)
|
|
5708
|
+
return this.fail(index, 'columns', ['minColSpan']);
|
|
5709
|
+
if (minCol > maxCol)
|
|
5710
|
+
return this.fail(index, 'range', ['minColSpan', 'maxColSpan']);
|
|
5711
|
+
if (minRow > maxRow)
|
|
5712
|
+
return this.fail(index, 'range', ['minRowSpan', 'maxRowSpan']);
|
|
5713
|
+
const original = this.baseline.canvas?.items[target.key];
|
|
5714
|
+
const originalOverride = target.scope === 'base' ? undefined : this.baseline.deviceLayouts?.[target.scope]?.canvas?.items?.[target.key];
|
|
5715
|
+
const previous = { ...original?.constraints, ...originalOverride?.constraints };
|
|
5716
|
+
// Compare rendered spans under the original and proposed limits, using the
|
|
5717
|
+
// edited grid width in both. Preserve already-clamped source documents.
|
|
5718
|
+
const rawCol = override?.colSpan ?? base.colSpan;
|
|
5719
|
+
const rawRow = override?.rowSpan ?? base.rowSpan;
|
|
5720
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
5721
|
+
const oldMinCol = Math.max(1, previous.minColSpan || 1);
|
|
5722
|
+
const oldMinRow = Math.max(1, previous.minRowSpan || 1);
|
|
5723
|
+
const oldCol = clamp(rawCol, oldMinCol, Math.min(columns, Math.max(oldMinCol, previous.maxColSpan || columns)));
|
|
5724
|
+
const oldRow = clamp(rawRow, oldMinRow, Math.max(oldMinRow, previous.maxRowSpan || Number.MAX_SAFE_INTEGER));
|
|
5725
|
+
const widthChanged = LIMIT_KEYS.slice(0, 2).some(key => limits[key] !== previous[key]);
|
|
5726
|
+
const heightChanged = LIMIT_KEYS.slice(2, 4).some(key => limits[key] !== previous[key]);
|
|
5727
|
+
const oldWidthValid = oldMinCol <= columns && oldMinCol <= (previous.maxColSpan ?? columns);
|
|
5728
|
+
const oldHeightValid = oldMinRow <= (previous.maxRowSpan ?? Number.MAX_SAFE_INTEGER);
|
|
5729
|
+
if (widthChanged && oldWidthValid && oldCol !== clamp(rawCol, minCol, Math.min(maxCol, columns))) {
|
|
5730
|
+
return this.fail(index, 'size', ['minColSpan', 'maxColSpan']);
|
|
5731
|
+
}
|
|
5732
|
+
if (heightChanged && oldHeightValid && oldRow !== clamp(rawRow, minRow, maxRow)) {
|
|
5733
|
+
return this.fail(index, 'size', ['minRowSpan', 'maxRowSpan']);
|
|
5734
|
+
}
|
|
5735
|
+
}
|
|
5736
|
+
return null;
|
|
5737
|
+
}
|
|
5738
|
+
fail(index, code, keys) {
|
|
5739
|
+
for (const key of keys) {
|
|
5740
|
+
const control = this.form.at(index).controls[key];
|
|
5741
|
+
control.setErrors({ ...control.errors, canvasLimit: code }, { emitEvent: false });
|
|
5742
|
+
}
|
|
5743
|
+
return { index, code };
|
|
5744
|
+
}
|
|
5745
|
+
displayedSize(index, columns) {
|
|
5746
|
+
const target = this.targets[index];
|
|
5747
|
+
const base = target && this.baseline.canvas?.items[target.key];
|
|
5748
|
+
if (!base)
|
|
5749
|
+
return { width: 1, height: 1 };
|
|
5750
|
+
const override = target.scope === 'base' ? undefined : this.baseline.deviceLayouts?.[target.scope]?.canvas?.items?.[target.key];
|
|
5751
|
+
const limits = { ...base.constraints, ...override?.constraints };
|
|
5752
|
+
const minCol = Math.max(1, limits.minColSpan || 1);
|
|
5753
|
+
const minRow = Math.max(1, limits.minRowSpan || 1);
|
|
5754
|
+
return {
|
|
5755
|
+
width: Math.max(minCol, Math.min(override?.colSpan ?? base.colSpan, columns, Math.max(minCol, limits.maxColSpan || columns))),
|
|
5756
|
+
height: Math.max(minRow, Math.min(override?.rowSpan ?? base.rowSpan, Math.max(minRow, limits.maxRowSpan || Number.MAX_SAFE_INTEGER))),
|
|
5757
|
+
};
|
|
5758
|
+
}
|
|
5759
|
+
resolveFriendlyWidgetTitle(widget, key) {
|
|
5760
|
+
return resolveFriendlyWidgetTitle(widget, key);
|
|
5761
|
+
}
|
|
5762
|
+
}
|
|
5763
|
+
function resolveFriendlyWidgetTitle(widget, key, options) {
|
|
5764
|
+
const includeKeySuffix = options?.includeKeySuffix !== false;
|
|
5765
|
+
const formatWithKey = (label) => (includeKeySuffix ? `${label} (${key})` : label);
|
|
5766
|
+
const cleanStatic = (candidate) => {
|
|
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);
|
|
5773
|
+
if (authoredTitle) {
|
|
5774
|
+
return authoredTitle;
|
|
5775
|
+
}
|
|
5776
|
+
const definitionTitle = cleanStatic(widget.definition?.title) || cleanStatic(widget.title);
|
|
5777
|
+
if (definitionTitle) {
|
|
5778
|
+
return definitionTitle;
|
|
5779
|
+
}
|
|
5780
|
+
const subtitle = cleanStatic(widget.shell?.subtitle);
|
|
5781
|
+
if (subtitle) {
|
|
5782
|
+
return formatWithKey(subtitle);
|
|
5783
|
+
}
|
|
5784
|
+
const inputs = widget.definition?.inputs && typeof widget.definition.inputs === 'object'
|
|
5785
|
+
? widget.definition.inputs
|
|
5786
|
+
: null;
|
|
5787
|
+
if (inputs) {
|
|
5788
|
+
const directInputTitle = cleanStatic(inputs['title']) || cleanStatic(inputs['label']);
|
|
5789
|
+
if (directInputTitle) {
|
|
5790
|
+
return formatWithKey(directInputTitle);
|
|
5791
|
+
}
|
|
5792
|
+
for (const nestedKey of ['config', 'chartDocument']) {
|
|
5793
|
+
const nested = inputs[nestedKey];
|
|
5794
|
+
if (nested && typeof nested === 'object') {
|
|
5795
|
+
const nestedTitle = cleanStatic(nested['title']);
|
|
5796
|
+
if (nestedTitle) {
|
|
5797
|
+
return formatWithKey(nestedTitle);
|
|
5798
|
+
}
|
|
5799
|
+
}
|
|
5800
|
+
}
|
|
5801
|
+
const doc = inputs['document'];
|
|
5802
|
+
if (doc && typeof doc === 'object' && Array.isArray(doc['nodes'])) {
|
|
5803
|
+
const findNodeTitle = (nodes) => {
|
|
5804
|
+
for (const node of nodes) {
|
|
5805
|
+
if (!node || typeof node !== 'object')
|
|
5806
|
+
continue;
|
|
5807
|
+
const rec = node;
|
|
5808
|
+
const nodeTitle = cleanStatic(rec['title']) ||
|
|
5809
|
+
cleanStatic(rec['headline']) ||
|
|
5810
|
+
cleanStatic(rec['label']) ||
|
|
5811
|
+
(rec['type'] === 'heading' ? cleanStatic(rec['text']) : '');
|
|
5812
|
+
if (nodeTitle) {
|
|
5813
|
+
return nodeTitle;
|
|
5814
|
+
}
|
|
5815
|
+
for (const childListKey of ['content', 'items', 'children', 'nodes']) {
|
|
5816
|
+
if (Array.isArray(rec[childListKey])) {
|
|
5817
|
+
const nested = findNodeTitle(rec[childListKey]);
|
|
5818
|
+
if (nested)
|
|
5819
|
+
return nested;
|
|
5820
|
+
}
|
|
5821
|
+
}
|
|
5822
|
+
}
|
|
5823
|
+
return '';
|
|
5824
|
+
};
|
|
5825
|
+
const docTitle = findNodeTitle(doc['nodes']);
|
|
5826
|
+
if (docTitle) {
|
|
5827
|
+
return formatWithKey(docTitle);
|
|
5828
|
+
}
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
5831
|
+
return key;
|
|
5832
|
+
}
|
|
5833
|
+
|
|
5492
5834
|
const nestedWidgetAccessor = new NestedWidgetConfigAccessor();
|
|
5493
5835
|
const CONNECTION_EDITOR_STATE_NODE_ID = '__page_state__';
|
|
5494
5836
|
const CONNECTION_EDITOR_GLOBAL_ACTION_NODE_ID = '__global_actions__';
|
|
@@ -5555,7 +5897,10 @@ function deriveConnectionEditorGraph(page, options = {}) {
|
|
|
5555
5897
|
}
|
|
5556
5898
|
return {
|
|
5557
5899
|
id: widgetId,
|
|
5558
|
-
title: options.widgetTitle?.(widget)
|
|
5900
|
+
title: options.widgetTitle?.(widget)
|
|
5901
|
+
|| editableText(widget.shell?.title)
|
|
5902
|
+
|| resolveFriendlyWidgetTitle(widget, widgetId, { includeKeySuffix: false })
|
|
5903
|
+
|| widgetId,
|
|
5559
5904
|
subtitle: componentType,
|
|
5560
5905
|
icon: widget.shell?.icon ?? iconForComponentType(componentType),
|
|
5561
5906
|
x: center.x + Math.cos(angle) * radiusX,
|
|
@@ -7507,8 +7852,11 @@ class ConnectionEditorComponent {
|
|
|
7507
7852
|
globalActionIds: this.availableGlobalActionIds(),
|
|
7508
7853
|
widgetTitle: widget => {
|
|
7509
7854
|
const title = this.i18n.resolve(widget.shell?.title, '').trim();
|
|
7510
|
-
if (title && !title.includes('${'))
|
|
7855
|
+
if (title && !title.includes('${') && !title.includes('{{'))
|
|
7511
7856
|
return title;
|
|
7857
|
+
const semanticTitle = resolveFriendlyWidgetTitle(widget, '', { includeKeySuffix: false }).trim();
|
|
7858
|
+
if (semanticTitle)
|
|
7859
|
+
return semanticTitle;
|
|
7512
7860
|
const metadata = this.componentRegistry.get(widget.definition.id);
|
|
7513
7861
|
if (!metadata)
|
|
7514
7862
|
return widget.key;
|
|
@@ -13971,220 +14319,6 @@ function withPagePaddingInline(layout, value) {
|
|
|
13971
14319
|
return Object.keys(next).length ? next : undefined;
|
|
13972
14320
|
}
|
|
13973
14321
|
|
|
13974
|
-
const LIMIT_KEYS = ['minColSpan', 'maxColSpan', 'minRowSpan', 'maxRowSpan', 'minHeightPx', 'maxHeightPx'];
|
|
13975
|
-
/** Editor projection of Core canvas constraints; never a second persisted document. */
|
|
13976
|
-
class CanvasConstraintEditorModel {
|
|
13977
|
-
form = new FormArray([]);
|
|
13978
|
-
targets = [];
|
|
13979
|
-
baseline = { widgets: [] };
|
|
13980
|
-
hydrate(page) {
|
|
13981
|
-
this.form.clear({ emitEvent: false });
|
|
13982
|
-
this.targets = [];
|
|
13983
|
-
this.baseline = structuredClone(page);
|
|
13984
|
-
// Missing variants inherit Core's base canvas. Expose their override controls
|
|
13985
|
-
// without creating persisted blocks until a value is actually edited.
|
|
13986
|
-
const scopes = ['base', 'desktop', 'tablet', 'mobile'];
|
|
13987
|
-
for (const scope of scopes) {
|
|
13988
|
-
for (const [key, item] of Object.entries(page.canvas?.items ?? {})) {
|
|
13989
|
-
const widget = page.widgets.find(candidate => candidate.key === key);
|
|
13990
|
-
const override = scope === 'base' ? undefined : page.deviceLayouts?.[scope]?.canvas?.items?.[key];
|
|
13991
|
-
if (!widget || override?.hidden)
|
|
13992
|
-
continue;
|
|
13993
|
-
const constraints = scope === 'base' ? item.constraints : override?.constraints;
|
|
13994
|
-
const size = scope === 'base' ? item.contentSize : override?.contentSize;
|
|
13995
|
-
const seed = {
|
|
13996
|
-
minColSpan: constraints?.minColSpan ?? null, maxColSpan: constraints?.maxColSpan ?? null,
|
|
13997
|
-
minRowSpan: constraints?.minRowSpan ?? null, maxRowSpan: constraints?.maxRowSpan ?? null,
|
|
13998
|
-
minHeightPx: constraints?.minHeightPx ?? null, maxHeightPx: constraints?.maxHeightPx ?? null,
|
|
13999
|
-
heightMode: size === 'auto' ? 'auto' : size ? 'fixed' : 'inherit',
|
|
14000
|
-
heightPx: size && size !== 'auto' ? size.heightPx : null,
|
|
14001
|
-
offsetTopPx: size && size !== 'auto' ? size.offsetTopPx ?? 0 : null,
|
|
14002
|
-
};
|
|
14003
|
-
const title = this.resolveFriendlyWidgetTitle(widget, key);
|
|
14004
|
-
this.targets.push({ key, title, scope, seed });
|
|
14005
|
-
this.form.push(new FormGroup({
|
|
14006
|
-
minColSpan: new FormControl(seed.minColSpan), maxColSpan: new FormControl(seed.maxColSpan),
|
|
14007
|
-
minRowSpan: new FormControl(seed.minRowSpan), maxRowSpan: new FormControl(seed.maxRowSpan),
|
|
14008
|
-
minHeightPx: new FormControl(seed.minHeightPx), maxHeightPx: new FormControl(seed.maxHeightPx),
|
|
14009
|
-
heightMode: new FormControl(seed.heightMode, { nonNullable: true }),
|
|
14010
|
-
heightPx: new FormControl(seed.heightPx), offsetTopPx: new FormControl(seed.offsetTopPx),
|
|
14011
|
-
}), { emitEvent: false });
|
|
14012
|
-
}
|
|
14013
|
-
}
|
|
14014
|
-
}
|
|
14015
|
-
/** Mutates only the caller's cloned draft, preserving unknown fields and untouched overrides. */
|
|
14016
|
-
apply(page) {
|
|
14017
|
-
for (const group of this.form.controls) {
|
|
14018
|
-
for (const control of Object.values(group.controls)) {
|
|
14019
|
-
const errors = { ...control.errors };
|
|
14020
|
-
delete errors['canvasLimit'];
|
|
14021
|
-
control.setErrors(Object.keys(errors).length ? errors : null, { emitEvent: false });
|
|
14022
|
-
}
|
|
14023
|
-
}
|
|
14024
|
-
for (const [index, target] of this.targets.entries()) {
|
|
14025
|
-
const values = this.form.at(index).getRawValue();
|
|
14026
|
-
const invalidKeys = LIMIT_KEYS.filter(key => values[key] !== null && (!Number.isSafeInteger(values[key]) || values[key] < 1));
|
|
14027
|
-
if (invalidKeys.length) {
|
|
14028
|
-
return this.fail(index, 'integer', invalidKeys);
|
|
14029
|
-
}
|
|
14030
|
-
const heightChanged = values.heightMode !== target.seed.heightMode || values.heightPx !== target.seed.heightPx || values.offsetTopPx !== target.seed.offsetTopPx;
|
|
14031
|
-
if (!heightChanged && !LIMIT_KEYS.some(key => values[key] !== target.seed[key]))
|
|
14032
|
-
continue;
|
|
14033
|
-
const baseItem = page.canvas?.items[target.key];
|
|
14034
|
-
if (!baseItem)
|
|
14035
|
-
continue;
|
|
14036
|
-
const canvas = target.scope === 'base' ? page.canvas :
|
|
14037
|
-
(((page.deviceLayouts ??= {})[target.scope] ??= {}).canvas ??= {});
|
|
14038
|
-
const item = target.scope === 'base' ? baseItem :
|
|
14039
|
-
((canvas.items ??= {})[target.key] ??= {});
|
|
14040
|
-
if (heightChanged) {
|
|
14041
|
-
if (values.heightMode === 'inherit')
|
|
14042
|
-
delete item.contentSize;
|
|
14043
|
-
else if (values.heightMode === 'auto')
|
|
14044
|
-
item.contentSize = 'auto';
|
|
14045
|
-
else {
|
|
14046
|
-
if (!Number.isSafeInteger(values.heightPx) || values.heightPx < 160 || !Number.isSafeInteger(values.offsetTopPx ?? 0) || (values.offsetTopPx ?? 0) < 0) {
|
|
14047
|
-
return this.fail(index, 'height', ['heightPx', 'offsetTopPx']);
|
|
14048
|
-
}
|
|
14049
|
-
item.contentSize = { heightPx: values.heightPx, offsetTopPx: values.offsetTopPx ?? 0 };
|
|
14050
|
-
}
|
|
14051
|
-
}
|
|
14052
|
-
const constraints = { ...item.constraints };
|
|
14053
|
-
for (const key of LIMIT_KEYS) {
|
|
14054
|
-
if (values[key] === target.seed[key])
|
|
14055
|
-
continue;
|
|
14056
|
-
if (values[key] === null)
|
|
14057
|
-
delete constraints[key];
|
|
14058
|
-
else
|
|
14059
|
-
constraints[key] = values[key];
|
|
14060
|
-
}
|
|
14061
|
-
if (Object.keys(constraints).length)
|
|
14062
|
-
item.constraints = constraints;
|
|
14063
|
-
else
|
|
14064
|
-
delete item.constraints;
|
|
14065
|
-
}
|
|
14066
|
-
for (const [index, target] of this.targets.entries()) {
|
|
14067
|
-
const base = page.canvas?.items[target.key];
|
|
14068
|
-
if (!base)
|
|
14069
|
-
continue;
|
|
14070
|
-
const canvas = target.scope === 'base' ? undefined : page.deviceLayouts?.[target.scope]?.canvas;
|
|
14071
|
-
const override = canvas?.items?.[target.key];
|
|
14072
|
-
const limits = { ...base.constraints, ...override?.constraints };
|
|
14073
|
-
const minHeight = limits.minHeightPx ?? 160;
|
|
14074
|
-
const maxHeight = limits.maxHeightPx ?? Number.MAX_SAFE_INTEGER;
|
|
14075
|
-
if (minHeight < 160 || maxHeight < minHeight)
|
|
14076
|
-
return this.fail(index, 'height', ['minHeightPx', 'maxHeightPx']);
|
|
14077
|
-
const size = override?.contentSize ?? base.contentSize;
|
|
14078
|
-
if (size && size !== 'auto' && (size.heightPx < minHeight || size.heightPx > maxHeight))
|
|
14079
|
-
return this.fail(index, 'height', ['heightPx', 'minHeightPx', 'maxHeightPx']);
|
|
14080
|
-
const columns = canvas?.columns ?? page.canvas.columns;
|
|
14081
|
-
const minCol = limits.minColSpan ?? 1;
|
|
14082
|
-
const maxCol = limits.maxColSpan ?? columns;
|
|
14083
|
-
const minRow = limits.minRowSpan ?? 1;
|
|
14084
|
-
const maxRow = limits.maxRowSpan ?? Number.MAX_SAFE_INTEGER;
|
|
14085
|
-
if (minCol > columns)
|
|
14086
|
-
return this.fail(index, 'columns', ['minColSpan']);
|
|
14087
|
-
if (minCol > maxCol)
|
|
14088
|
-
return this.fail(index, 'range', ['minColSpan', 'maxColSpan']);
|
|
14089
|
-
if (minRow > maxRow)
|
|
14090
|
-
return this.fail(index, 'range', ['minRowSpan', 'maxRowSpan']);
|
|
14091
|
-
const original = this.baseline.canvas?.items[target.key];
|
|
14092
|
-
const originalOverride = target.scope === 'base' ? undefined : this.baseline.deviceLayouts?.[target.scope]?.canvas?.items?.[target.key];
|
|
14093
|
-
const previous = { ...original?.constraints, ...originalOverride?.constraints };
|
|
14094
|
-
// Compare rendered spans under the original and proposed limits, using the
|
|
14095
|
-
// edited grid width in both. Preserve already-clamped source documents.
|
|
14096
|
-
const rawCol = override?.colSpan ?? base.colSpan;
|
|
14097
|
-
const rawRow = override?.rowSpan ?? base.rowSpan;
|
|
14098
|
-
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
14099
|
-
const oldMinCol = Math.max(1, previous.minColSpan || 1);
|
|
14100
|
-
const oldMinRow = Math.max(1, previous.minRowSpan || 1);
|
|
14101
|
-
const oldCol = clamp(rawCol, oldMinCol, Math.min(columns, Math.max(oldMinCol, previous.maxColSpan || columns)));
|
|
14102
|
-
const oldRow = clamp(rawRow, oldMinRow, Math.max(oldMinRow, previous.maxRowSpan || Number.MAX_SAFE_INTEGER));
|
|
14103
|
-
const widthChanged = LIMIT_KEYS.slice(0, 2).some(key => limits[key] !== previous[key]);
|
|
14104
|
-
const heightChanged = LIMIT_KEYS.slice(2, 4).some(key => limits[key] !== previous[key]);
|
|
14105
|
-
const oldWidthValid = oldMinCol <= columns && oldMinCol <= (previous.maxColSpan ?? columns);
|
|
14106
|
-
const oldHeightValid = oldMinRow <= (previous.maxRowSpan ?? Number.MAX_SAFE_INTEGER);
|
|
14107
|
-
if (widthChanged && oldWidthValid && oldCol !== clamp(rawCol, minCol, Math.min(maxCol, columns))) {
|
|
14108
|
-
return this.fail(index, 'size', ['minColSpan', 'maxColSpan']);
|
|
14109
|
-
}
|
|
14110
|
-
if (heightChanged && oldHeightValid && oldRow !== clamp(rawRow, minRow, maxRow)) {
|
|
14111
|
-
return this.fail(index, 'size', ['minRowSpan', 'maxRowSpan']);
|
|
14112
|
-
}
|
|
14113
|
-
}
|
|
14114
|
-
return null;
|
|
14115
|
-
}
|
|
14116
|
-
fail(index, code, keys) {
|
|
14117
|
-
for (const key of keys) {
|
|
14118
|
-
const control = this.form.at(index).controls[key];
|
|
14119
|
-
control.setErrors({ ...control.errors, canvasLimit: code }, { emitEvent: false });
|
|
14120
|
-
}
|
|
14121
|
-
return { index, code };
|
|
14122
|
-
}
|
|
14123
|
-
displayedSize(index, columns) {
|
|
14124
|
-
const target = this.targets[index];
|
|
14125
|
-
const base = target && this.baseline.canvas?.items[target.key];
|
|
14126
|
-
if (!base)
|
|
14127
|
-
return { width: 1, height: 1 };
|
|
14128
|
-
const override = target.scope === 'base' ? undefined : this.baseline.deviceLayouts?.[target.scope]?.canvas?.items?.[target.key];
|
|
14129
|
-
const limits = { ...base.constraints, ...override?.constraints };
|
|
14130
|
-
const minCol = Math.max(1, limits.minColSpan || 1);
|
|
14131
|
-
const minRow = Math.max(1, limits.minRowSpan || 1);
|
|
14132
|
-
return {
|
|
14133
|
-
width: Math.max(minCol, Math.min(override?.colSpan ?? base.colSpan, columns, Math.max(minCol, limits.maxColSpan || columns))),
|
|
14134
|
-
height: Math.max(minRow, Math.min(override?.rowSpan ?? base.rowSpan, Math.max(minRow, limits.maxRowSpan || Number.MAX_SAFE_INTEGER))),
|
|
14135
|
-
};
|
|
14136
|
-
}
|
|
14137
|
-
resolveFriendlyWidgetTitle(widget, key) {
|
|
14138
|
-
const cleanStatic = (candidate) => {
|
|
14139
|
-
if (typeof candidate !== 'string')
|
|
14140
|
-
return '';
|
|
14141
|
-
const trimmed = candidate.trim();
|
|
14142
|
-
return trimmed && !trimmed.includes('${') ? trimmed : '';
|
|
14143
|
-
};
|
|
14144
|
-
const authoredTitle = cleanStatic(widget.shell?.title);
|
|
14145
|
-
if (authoredTitle) {
|
|
14146
|
-
return authoredTitle;
|
|
14147
|
-
}
|
|
14148
|
-
const subtitle = cleanStatic(widget.shell?.subtitle);
|
|
14149
|
-
if (subtitle) {
|
|
14150
|
-
return `${subtitle} (${key})`;
|
|
14151
|
-
}
|
|
14152
|
-
const inputs = widget.definition?.inputs && typeof widget.definition.inputs === 'object'
|
|
14153
|
-
? widget.definition.inputs
|
|
14154
|
-
: null;
|
|
14155
|
-
if (inputs) {
|
|
14156
|
-
const directInputTitle = cleanStatic(inputs['title']) || cleanStatic(inputs['label']);
|
|
14157
|
-
if (directInputTitle) {
|
|
14158
|
-
return `${directInputTitle} (${key})`;
|
|
14159
|
-
}
|
|
14160
|
-
for (const nestedKey of ['config', 'chartDocument']) {
|
|
14161
|
-
const nested = inputs[nestedKey];
|
|
14162
|
-
if (nested && typeof nested === 'object') {
|
|
14163
|
-
const nestedTitle = cleanStatic(nested['title']);
|
|
14164
|
-
if (nestedTitle) {
|
|
14165
|
-
return `${nestedTitle} (${key})`;
|
|
14166
|
-
}
|
|
14167
|
-
}
|
|
14168
|
-
}
|
|
14169
|
-
const doc = inputs['document'];
|
|
14170
|
-
if (doc && typeof doc === 'object' && Array.isArray(doc['nodes'])) {
|
|
14171
|
-
for (const node of doc['nodes']) {
|
|
14172
|
-
if (!node || typeof node !== 'object')
|
|
14173
|
-
continue;
|
|
14174
|
-
const rec = node;
|
|
14175
|
-
const nodeTitle = cleanStatic(rec['title']) ||
|
|
14176
|
-
cleanStatic(rec['headline']) ||
|
|
14177
|
-
cleanStatic(rec['label']);
|
|
14178
|
-
if (nodeTitle) {
|
|
14179
|
-
return `${nodeTitle} (${key})`;
|
|
14180
|
-
}
|
|
14181
|
-
}
|
|
14182
|
-
}
|
|
14183
|
-
}
|
|
14184
|
-
return key;
|
|
14185
|
-
}
|
|
14186
|
-
}
|
|
14187
|
-
|
|
14188
14322
|
const LAYOUT_PRESET_ENTRIES = Object.values(BUILTIN_PAGE_LAYOUT_PRESETS).map((preset) => ({
|
|
14189
14323
|
id: preset.id,
|
|
14190
14324
|
label: preset.label,
|
|
@@ -14979,6 +15113,93 @@ class DynamicPageConfigEditorComponent {
|
|
|
14979
15113
|
}
|
|
14980
15114
|
return bindings;
|
|
14981
15115
|
}
|
|
15116
|
+
unbindStateTokenFromWidget(widgetKey, inputPath) {
|
|
15117
|
+
const targetWidgetKey = String(widgetKey || '').trim();
|
|
15118
|
+
const targetInputPath = String(inputPath || '').trim();
|
|
15119
|
+
if (!targetWidgetKey || !targetInputPath)
|
|
15120
|
+
return false;
|
|
15121
|
+
const widgets = this.clone(this.effectivePageInput().widgets || []);
|
|
15122
|
+
const target = widgets.find((item) => item.key === targetWidgetKey);
|
|
15123
|
+
if (!target)
|
|
15124
|
+
return false;
|
|
15125
|
+
const inputs = this.isPlainObject(target.definition?.inputs)
|
|
15126
|
+
? target.definition.inputs
|
|
15127
|
+
: this.isPlainObject(target.inputs)
|
|
15128
|
+
? target.inputs
|
|
15129
|
+
: undefined;
|
|
15130
|
+
if (!inputs || !this.deleteNestedValue(inputs, targetInputPath))
|
|
15131
|
+
return false;
|
|
15132
|
+
if (this.isPlainObject(inputs['context']) && Object.keys(inputs['context']).length === 0) {
|
|
15133
|
+
delete inputs['context'];
|
|
15134
|
+
}
|
|
15135
|
+
this.stagedWidgets = widgets;
|
|
15136
|
+
this.binderMessage.set(this.tx('editor.state.unboundSuccess', 'Removed binding {{widget}}.{{input}} from draft.')
|
|
15137
|
+
.replace('{{widget}}', targetWidgetKey)
|
|
15138
|
+
.replace('{{input}}', targetInputPath));
|
|
15139
|
+
this.refreshPanelState();
|
|
15140
|
+
return true;
|
|
15141
|
+
}
|
|
15142
|
+
autoBindWidgetStatePlaceholders() {
|
|
15143
|
+
const rawTokens = this.availableStateBindingTokens();
|
|
15144
|
+
if (!rawTokens.length)
|
|
15145
|
+
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
|
+
const widgets = this.clone(this.effectivePageInput().widgets || []);
|
|
15163
|
+
let boundCount = 0;
|
|
15164
|
+
for (const widget of widgets) {
|
|
15165
|
+
const inputs = this.isPlainObject(widget.definition?.inputs)
|
|
15166
|
+
? widget.definition.inputs
|
|
15167
|
+
: this.isPlainObject(widget.inputs)
|
|
15168
|
+
? widget.inputs
|
|
15169
|
+
: {};
|
|
15170
|
+
if (!Object.keys(inputs).length)
|
|
15171
|
+
continue;
|
|
15172
|
+
const placeholders = new Set();
|
|
15173
|
+
this.collectTemplatePlaceholdersRecursive(inputs, '', placeholders);
|
|
15174
|
+
if (!placeholders.size)
|
|
15175
|
+
continue;
|
|
15176
|
+
widget.definition = widget.definition || { id: widget.componentId || 'praxis-rich-content' };
|
|
15177
|
+
widget.definition.inputs = inputs;
|
|
15178
|
+
for (const placeholder of placeholders) {
|
|
15179
|
+
const matchedStatePath = resolveMatchingStatePath(placeholder);
|
|
15180
|
+
if (!matchedStatePath)
|
|
15181
|
+
continue;
|
|
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.'))) {
|
|
15186
|
+
continue;
|
|
15187
|
+
}
|
|
15188
|
+
this.assignNestedValue(inputs, targetInputPath, `{{${matchedStatePath}}}`);
|
|
15189
|
+
boundCount += 1;
|
|
15190
|
+
}
|
|
15191
|
+
}
|
|
15192
|
+
if (boundCount > 0) {
|
|
15193
|
+
this.stagedWidgets = widgets;
|
|
15194
|
+
this.binderMessage.set(this.tx('editor.state.autoBoundSuccess', 'Auto-bound {{count}} widget placeholders to page state in draft.')
|
|
15195
|
+
.replace('{{count}}', String(boundCount)));
|
|
15196
|
+
this.refreshPanelState();
|
|
15197
|
+
}
|
|
15198
|
+
else {
|
|
15199
|
+
this.binderMessage.set(this.tx('editor.state.autoBoundNone', 'No unbound ${...} widget placeholders matched available state tokens.'));
|
|
15200
|
+
}
|
|
15201
|
+
return boundCount;
|
|
15202
|
+
}
|
|
14982
15203
|
composeCollectionScreen(overrides) {
|
|
14983
15204
|
const resourcePath = (overrides?.resourcePath ?? this.composerResourcePathControl.value).trim();
|
|
14984
15205
|
if (!resourcePath)
|
|
@@ -15375,10 +15596,7 @@ class DynamicPageConfigEditorComponent {
|
|
|
15375
15596
|
this.refreshPanelState();
|
|
15376
15597
|
}
|
|
15377
15598
|
resolveWidgetTitle(widget) {
|
|
15378
|
-
return (
|
|
15379
|
-
(typeof widget.definition?.title === 'string' && widget.definition.title) ||
|
|
15380
|
-
(typeof widget.title === 'string' && widget.title) ||
|
|
15381
|
-
widget.key);
|
|
15599
|
+
return resolveFriendlyWidgetTitle(widget, widget.key, { includeKeySuffix: false });
|
|
15382
15600
|
}
|
|
15383
15601
|
isFilterLinkTargetingWidget(link, widgetKey) {
|
|
15384
15602
|
const targetWidget = link?.to?.kind === 'component-port' ? link.to.ref.widget : link?.target?.widgetKey;
|
|
@@ -16088,10 +16306,64 @@ class DynamicPageConfigEditorComponent {
|
|
|
16088
16306
|
}
|
|
16089
16307
|
cursor[segments[segments.length - 1]] = value;
|
|
16090
16308
|
}
|
|
16309
|
+
readNestedValue(target, dotPath) {
|
|
16310
|
+
const segments = dotPath.split('.').map((s) => s.trim()).filter(Boolean);
|
|
16311
|
+
let cursor = target;
|
|
16312
|
+
for (const key of segments) {
|
|
16313
|
+
if (!this.isPlainObject(cursor))
|
|
16314
|
+
return undefined;
|
|
16315
|
+
cursor = cursor[key];
|
|
16316
|
+
}
|
|
16317
|
+
return cursor;
|
|
16318
|
+
}
|
|
16319
|
+
deleteNestedValue(target, dotPath) {
|
|
16320
|
+
const segments = dotPath.split('.').map((s) => s.trim()).filter(Boolean);
|
|
16321
|
+
if (!segments.length)
|
|
16322
|
+
return false;
|
|
16323
|
+
let cursor = target;
|
|
16324
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
16325
|
+
const key = segments[i];
|
|
16326
|
+
if (!this.isPlainObject(cursor[key]))
|
|
16327
|
+
return false;
|
|
16328
|
+
cursor = cursor[key];
|
|
16329
|
+
}
|
|
16330
|
+
const leaf = segments[segments.length - 1];
|
|
16331
|
+
if (!(leaf in cursor))
|
|
16332
|
+
return false;
|
|
16333
|
+
delete cursor[leaf];
|
|
16334
|
+
return true;
|
|
16335
|
+
}
|
|
16336
|
+
collectTemplatePlaceholdersRecursive(value, path, acc) {
|
|
16337
|
+
if (typeof value === 'string') {
|
|
16338
|
+
if (path === 'context' || path.startsWith('context.'))
|
|
16339
|
+
return;
|
|
16340
|
+
const regex = /\$\{([^}]+)\}/g;
|
|
16341
|
+
let match;
|
|
16342
|
+
while ((match = regex.exec(value)) !== null) {
|
|
16343
|
+
const token = match[1].trim();
|
|
16344
|
+
if (token && !token.startsWith('state.')) {
|
|
16345
|
+
acc.add(token);
|
|
16346
|
+
}
|
|
16347
|
+
}
|
|
16348
|
+
return;
|
|
16349
|
+
}
|
|
16350
|
+
if (Array.isArray(value)) {
|
|
16351
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
16352
|
+
this.collectTemplatePlaceholdersRecursive(value[i], `${path}[${i}]`, acc);
|
|
16353
|
+
}
|
|
16354
|
+
return;
|
|
16355
|
+
}
|
|
16356
|
+
if (this.isPlainObject(value)) {
|
|
16357
|
+
for (const [k, v] of Object.entries(value)) {
|
|
16358
|
+
const nextPath = path ? `${path}.${k}` : k;
|
|
16359
|
+
this.collectTemplatePlaceholdersRecursive(v, nextPath, acc);
|
|
16360
|
+
}
|
|
16361
|
+
}
|
|
16362
|
+
}
|
|
16091
16363
|
collectWidgetBindingsRecursive(widgetKey, widgetTitle, obj, prefix, acc) {
|
|
16092
16364
|
for (const [k, v] of Object.entries(obj)) {
|
|
16093
16365
|
const nextPath = prefix ? `${prefix}.${k}` : k;
|
|
16094
|
-
if (typeof v === 'string' && v.includes('{{state.')) {
|
|
16366
|
+
if (typeof v === 'string' && (v.includes('{{state.') || v.includes('${state.'))) {
|
|
16095
16367
|
acc.push({ widgetKey, widgetTitle, inputPath: nextPath, token: v });
|
|
16096
16368
|
}
|
|
16097
16369
|
else if (this.isPlainObject(v)) {
|
|
@@ -16108,11 +16380,11 @@ class DynamicPageConfigEditorComponent {
|
|
|
16108
16380
|
clone(value) { if (value == null || typeof value !== 'object')
|
|
16109
16381
|
return value; return JSON.parse(JSON.stringify(value)); }
|
|
16110
16382
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicPageConfigEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
16111
|
-
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 </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 </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: "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 });
|
|
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 });
|
|
16112
16384
|
}
|
|
16113
16385
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicPageConfigEditorComponent, decorators: [{
|
|
16114
16386
|
type: Component,
|
|
16115
|
-
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 </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 </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"] }]
|
|
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"] }]
|
|
16116
16388
|
}], ctorParameters: () => [], propDecorators: { page: [{
|
|
16117
16389
|
type: Input
|
|
16118
16390
|
}], layout: [{
|
|
@@ -28784,6 +29056,7 @@ class DynamicPageBuilderComponent {
|
|
|
28784
29056
|
allowedWidgetIds: this.componentPaletteAllowedWidgetIds || undefined,
|
|
28785
29057
|
allowedWidgetTags: this.componentPaletteAllowedWidgetTags || undefined,
|
|
28786
29058
|
allowedPresetIds: this.componentPaletteAllowedPresetIds || undefined,
|
|
29059
|
+
onInsert: (selection) => this.addWidget(selection),
|
|
28787
29060
|
},
|
|
28788
29061
|
});
|
|
28789
29062
|
ref.afterClosed().subscribe((selection) => {
|
|
@@ -28808,18 +29081,43 @@ class DynamicPageBuilderComponent {
|
|
|
28808
29081
|
}];
|
|
28809
29082
|
next.widgets = widgets;
|
|
28810
29083
|
if (next.canvas) {
|
|
28811
|
-
const
|
|
29084
|
+
const existingItems = Object.values(next.canvas.items || {});
|
|
29085
|
+
const lastRow = Math.max(1, ...existingItems.map((item) => (item.row + item.rowSpan) - 1));
|
|
28812
29086
|
const canvasColumns = Math.max(1, next.canvas.columns || 1);
|
|
28813
29087
|
const layoutHints = this.layoutHintsFor(type);
|
|
28814
|
-
const
|
|
29088
|
+
const explicitColSpan = typeof resolved.colSpan === 'number' && Number.isFinite(resolved.colSpan) && resolved.colSpan >= 1
|
|
29089
|
+
? Math.min(canvasColumns, Math.trunc(resolved.colSpan))
|
|
29090
|
+
: undefined;
|
|
29091
|
+
const colSpan = explicitColSpan ?? Math.min(canvasColumns, Math.max(1, layoutHints?.recommendedCols ?? Math.min(4, canvasColumns)));
|
|
28815
29092
|
const rowSpan = Math.max(1, layoutHints?.recommendedRows ?? 2);
|
|
29093
|
+
let targetCol = 1;
|
|
29094
|
+
let targetRow = lastRow + 1;
|
|
29095
|
+
if (explicitColSpan !== undefined && existingItems.length > 0) {
|
|
29096
|
+
const overlapsExisting = (col, row) => {
|
|
29097
|
+
const right = col + colSpan - 1;
|
|
29098
|
+
const bottom = row + rowSpan - 1;
|
|
29099
|
+
return existingItems.some((item) => {
|
|
29100
|
+
const itemRight = item.col + item.colSpan - 1;
|
|
29101
|
+
const itemBottom = item.row + item.rowSpan - 1;
|
|
29102
|
+
return col <= itemRight && right >= item.col && row <= itemBottom && bottom >= item.row;
|
|
29103
|
+
});
|
|
29104
|
+
};
|
|
29105
|
+
const maxStartRow = Math.max(1, ...existingItems.map((item) => item.row));
|
|
29106
|
+
for (let col = 1; col + colSpan - 1 <= canvasColumns; col += 1) {
|
|
29107
|
+
if (!overlapsExisting(col, maxStartRow)) {
|
|
29108
|
+
targetCol = col;
|
|
29109
|
+
targetRow = maxStartRow;
|
|
29110
|
+
break;
|
|
29111
|
+
}
|
|
29112
|
+
}
|
|
29113
|
+
}
|
|
28816
29114
|
next.canvas = {
|
|
28817
29115
|
...next.canvas,
|
|
28818
29116
|
items: {
|
|
28819
29117
|
...(next.canvas.items || {}),
|
|
28820
29118
|
[key]: {
|
|
28821
|
-
col:
|
|
28822
|
-
row:
|
|
29119
|
+
col: targetCol,
|
|
29120
|
+
row: targetRow,
|
|
28823
29121
|
colSpan,
|
|
28824
29122
|
rowSpan,
|
|
28825
29123
|
},
|