@praxisui/rich-content 8.0.0-beta.1 → 8.0.0-beta.12

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.
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
3
3
  import * as i0 from '@angular/core';
4
4
  import { InjectionToken, inject, Injectable, input, computed, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, ENVIRONMENT_INITIALIZER } from '@angular/core';
5
5
  import { PraxisJsonLogicService, providePraxisI18n, PraxisI18nService, SETTINGS_PANEL_DATA, ComponentMetadataRegistry } from '@praxisui/core';
6
- import * as i1$1 from '@angular/forms';
6
+ import * as i2 from '@angular/forms';
7
7
  import { FormsModule } from '@angular/forms';
8
8
  import { BehaviorSubject } from 'rxjs';
9
9
 
@@ -79,6 +79,15 @@ class PraxisRichContent {
79
79
  resolveImageAlt(node) {
80
80
  return this.resolveValue(node.altExpr) ?? node.alt ?? '';
81
81
  }
82
+ resolveLinkLabel(node) {
83
+ return this.resolveValue(node.labelExpr) ?? node.label ?? node.href;
84
+ }
85
+ resolveLinkRel(node) {
86
+ if (node.rel) {
87
+ return node.rel;
88
+ }
89
+ return node.target === '_blank' ? 'noopener noreferrer' : null;
90
+ }
82
91
  resolveBadgeLabel(node) {
83
92
  return this.resolveValue(node.labelExpr) ?? node.label ?? '';
84
93
  }
@@ -171,12 +180,42 @@ class PraxisRichContent {
171
180
  return this.resolveValue(item.badgeExpr) ?? item.badge ?? null;
172
181
  }
173
182
  resolvePresetNodes(node) {
183
+ return this.expandPresetReference(node, new Set(), 0);
184
+ }
185
+ expandPresetReference(node, stack, depth) {
186
+ const key = this.getPresetKey(node.ref);
187
+ if (stack.has(key) || depth > 8) {
188
+ return [];
189
+ }
190
+ const document = this.resolvePresetDocument(node);
191
+ if (!document) {
192
+ return [];
193
+ }
194
+ const nextStack = new Set(stack);
195
+ nextStack.add(key);
196
+ return document.nodes.flatMap((child) => {
197
+ if (child.type === 'preset') {
198
+ return this.expandPresetReference(child, nextStack, depth + 1);
199
+ }
200
+ return [child];
201
+ });
202
+ }
203
+ resolvePresetDocument(node) {
174
204
  const localPreset = this.presetRegistry.get(node.ref);
175
205
  if (localPreset) {
176
- return localPreset.document.nodes;
206
+ return localPreset.document;
177
207
  }
178
208
  const hostResolved = this.hostCapabilities()?.resolvePreset?.(node.ref);
179
- return hostResolved?.nodes;
209
+ if (hostResolved &&
210
+ typeof hostResolved === 'object' &&
211
+ hostResolved.kind === 'praxis.rich-content' &&
212
+ Array.isArray(hostResolved.nodes)) {
213
+ return hostResolved;
214
+ }
215
+ return null;
216
+ }
217
+ getPresetKey(ref) {
218
+ return `${ref.kind}:${ref.namespace}:${ref.presetId}:${ref.version ?? ''}`;
180
219
  }
181
220
  buildEvaluationContext() {
182
221
  return this.context() ?? {};
@@ -238,6 +277,8 @@ class PraxisRichContent {
238
277
  <span
239
278
  class="prx-rich-icon material-symbols-outlined"
240
279
  [attr.aria-label]="node.ariaLabel || null"
280
+ [attr.aria-hidden]="node.ariaLabel ? null : 'true'"
281
+ [attr.role]="node.ariaLabel ? 'img' : null"
241
282
  >
242
283
  {{ node.icon }}
243
284
  </span>
@@ -249,6 +290,16 @@ class PraxisRichContent {
249
290
  [alt]="resolveImageAlt(node)"
250
291
  />
251
292
  }
293
+ @case ('link') {
294
+ <a
295
+ class="prx-rich-link"
296
+ [href]="node.href"
297
+ [attr.target]="node.target || null"
298
+ [attr.rel]="resolveLinkRel(node)"
299
+ >
300
+ {{ resolveLinkLabel(node) }}
301
+ </a>
302
+ }
252
303
  @case ('badge') {
253
304
  <span class="prx-rich-badge">{{ resolveBadgeLabel(node) }}</span>
254
305
  }
@@ -279,6 +330,7 @@ class PraxisRichContent {
279
330
  class="prx-rich-progress-bar"
280
331
  [value]="resolveProgressValue(node)"
281
332
  [max]="node.max ?? 100"
333
+ [attr.aria-label]="resolveProgressLabel(node) || null"
282
334
  ></progress>
283
335
  </div>
284
336
  }
@@ -369,7 +421,10 @@ class PraxisRichContent {
369
421
  <article class="prx-rich-timeline__item">
370
422
  <div class="prx-rich-timeline__item-marker">
371
423
  @if (resolveTimelineItemIcon(item); as icon) {
372
- <span class="prx-rich-timeline__item-icon material-symbols-outlined">
424
+ <span
425
+ class="prx-rich-timeline__item-icon material-symbols-outlined"
426
+ aria-hidden="true"
427
+ >
373
428
  {{ icon }}
374
429
  </span>
375
430
  } @else {
@@ -412,7 +467,7 @@ class PraxisRichContent {
412
467
  }
413
468
  }
414
469
  </ng-template>
415
- `, isInline: true, styles: [".prx-rich-content-root{display:block}.prx-rich-content-root--inline{display:inline-flex;align-items:var(--prx-rich-content-inline-align-items, center);flex-wrap:var(--prx-rich-content-inline-flex-wrap, nowrap);gap:var(--prx-rich-content-inline-gap, 6px);min-width:0;max-width:100%;vertical-align:middle}.prx-rich-node{min-width:0}.prx-rich-node--inline{display:inline-flex;align-items:center;min-width:0}.prx-rich-compose{display:flex;align-items:center;gap:8px}.prx-rich-compose.direction-column{flex-direction:column;align-items:stretch}.prx-rich-compose.wrap{flex-wrap:wrap}.prx-rich-badge{display:inline-flex;align-items:center;padding:2px 10px;border-radius:999px;background:var(--md-sys-color-primary-container, #e8def8);color:var(--md-sys-color-on-primary-container, #21005d);font-size:12px;line-height:20px}.prx-rich-avatar{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%;background:var(--md-sys-color-surface-container-high, #ece6f0);overflow:hidden}.prx-rich-avatar-image{width:100%;height:100%;object-fit:cover}.prx-rich-avatar-fallback{font-weight:600}.prx-rich-card{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, #cac4d0);border-radius:16px;background:var(--md-sys-color-surface, #fff)}.prx-rich-card-title{font-weight:600}.prx-rich-card-subtitle,.prx-rich-metric-caption{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-metric{display:flex;flex-direction:column;gap:4px}.prx-rich-metric-value{font-size:20px;font-weight:700}.prx-rich-progress{display:flex;flex-direction:column;gap:4px}.prx-rich-progress-bar{width:100%}.prx-rich-media-block{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:center;min-width:0}.prx-rich-media-block__avatar,.prx-rich-media-block__body,.prx-rich-media-block__trailing{min-width:0}.prx-rich-media-block__body{display:flex;flex-direction:column;gap:6px}.prx-rich-media-block__title{font-weight:700}.prx-rich-media-block__subtitle{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:13px}.prx-rich-media-block__meta{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-media-block__trailing{display:flex;align-items:center;justify-content:flex-end}.prx-rich-timeline{display:flex;flex-direction:column;gap:12px;min-width:0}.prx-rich-timeline__title{font-weight:700}.prx-rich-timeline__items{display:flex;flex-direction:column;gap:12px}.prx-rich-timeline__item{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:flex-start;min-width:0}.prx-rich-timeline__item-marker{display:inline-flex;align-items:center;justify-content:center;width:24px;min-height:24px}.prx-rich-timeline__item-icon{font-size:18px;line-height:18px;color:var(--md-sys-color-primary, #6750a4)}.prx-rich-timeline__item-dot{width:10px;height:10px;border-radius:50%;background:var(--md-sys-color-primary, #6750a4);display:inline-block;margin-top:4px}.prx-rich-timeline__item-body{display:flex;flex-direction:column;gap:4px;min-width:0}.prx-rich-timeline__item-title{font-weight:600}.prx-rich-timeline__item-subtitle,.prx-rich-timeline__item-meta,.prx-rich-timeline__empty{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-timeline__item-badge{display:inline-flex;align-items:flex-start;justify-content:flex-end}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i1.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
470
+ `, isInline: true, styles: [".prx-rich-content-root{display:block}.prx-rich-content-root--inline{display:inline-flex;align-items:var(--prx-rich-content-inline-align-items, center);flex-wrap:var(--prx-rich-content-inline-flex-wrap, nowrap);gap:var(--prx-rich-content-inline-gap, 6px);min-width:0;max-width:100%;vertical-align:middle}.prx-rich-node{min-width:0}.prx-rich-node--inline{display:inline-flex;align-items:center;min-width:0}.prx-rich-compose{display:flex;align-items:center;gap:8px}.prx-rich-compose.direction-column{flex-direction:column;align-items:stretch}.prx-rich-compose.wrap{flex-wrap:wrap}.prx-rich-badge{display:inline-flex;align-items:center;padding:2px 10px;border-radius:999px;background:var(--md-sys-color-primary-container, #e8def8);color:var(--md-sys-color-on-primary-container, #21005d);font-size:12px;line-height:20px}.prx-rich-link{color:var(--md-sys-color-primary, #6750a4);text-decoration:underline;text-underline-offset:2px}.prx-rich-avatar{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%;background:var(--md-sys-color-surface-container-high, #ece6f0);overflow:hidden}.prx-rich-avatar-image{width:100%;height:100%;object-fit:cover}.prx-rich-avatar-fallback{font-weight:600}.prx-rich-card{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, #cac4d0);border-radius:16px;background:var(--md-sys-color-surface, #fff)}.prx-rich-card-title{font-weight:600}.prx-rich-card-subtitle,.prx-rich-metric-caption{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-metric{display:flex;flex-direction:column;gap:4px}.prx-rich-metric-value{font-size:20px;font-weight:700}.prx-rich-progress{display:flex;flex-direction:column;gap:4px}.prx-rich-progress-bar{width:100%}.prx-rich-media-block{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:center;min-width:0}.prx-rich-media-block__avatar,.prx-rich-media-block__body,.prx-rich-media-block__trailing{min-width:0}.prx-rich-media-block__body{display:flex;flex-direction:column;gap:6px}.prx-rich-media-block__title{font-weight:700}.prx-rich-media-block__subtitle{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:13px}.prx-rich-media-block__meta{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-media-block__trailing{display:flex;align-items:center;justify-content:flex-end}.prx-rich-timeline{display:flex;flex-direction:column;gap:12px;min-width:0}.prx-rich-timeline__title{font-weight:700}.prx-rich-timeline__items{display:flex;flex-direction:column;gap:12px}.prx-rich-timeline__item{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:flex-start;min-width:0}.prx-rich-timeline__item-marker{display:inline-flex;align-items:center;justify-content:center;width:24px;min-height:24px}.prx-rich-timeline__item-icon{font-size:18px;line-height:18px;color:var(--md-sys-color-primary, #6750a4)}.prx-rich-timeline__item-dot{width:10px;height:10px;border-radius:50%;background:var(--md-sys-color-primary, #6750a4);display:inline-block;margin-top:4px}.prx-rich-timeline__item-body{display:flex;flex-direction:column;gap:4px;min-width:0}.prx-rich-timeline__item-title{font-weight:600}.prx-rich-timeline__item-subtitle,.prx-rich-timeline__item-meta,.prx-rich-timeline__empty{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-timeline__item-badge{display:inline-flex;align-items:flex-start;justify-content:flex-end}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i1.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
416
471
  }
417
472
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: PraxisRichContent, decorators: [{
418
473
  type: Component,
@@ -447,6 +502,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
447
502
  <span
448
503
  class="prx-rich-icon material-symbols-outlined"
449
504
  [attr.aria-label]="node.ariaLabel || null"
505
+ [attr.aria-hidden]="node.ariaLabel ? null : 'true'"
506
+ [attr.role]="node.ariaLabel ? 'img' : null"
450
507
  >
451
508
  {{ node.icon }}
452
509
  </span>
@@ -458,6 +515,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
458
515
  [alt]="resolveImageAlt(node)"
459
516
  />
460
517
  }
518
+ @case ('link') {
519
+ <a
520
+ class="prx-rich-link"
521
+ [href]="node.href"
522
+ [attr.target]="node.target || null"
523
+ [attr.rel]="resolveLinkRel(node)"
524
+ >
525
+ {{ resolveLinkLabel(node) }}
526
+ </a>
527
+ }
461
528
  @case ('badge') {
462
529
  <span class="prx-rich-badge">{{ resolveBadgeLabel(node) }}</span>
463
530
  }
@@ -488,6 +555,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
488
555
  class="prx-rich-progress-bar"
489
556
  [value]="resolveProgressValue(node)"
490
557
  [max]="node.max ?? 100"
558
+ [attr.aria-label]="resolveProgressLabel(node) || null"
491
559
  ></progress>
492
560
  </div>
493
561
  }
@@ -578,7 +646,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
578
646
  <article class="prx-rich-timeline__item">
579
647
  <div class="prx-rich-timeline__item-marker">
580
648
  @if (resolveTimelineItemIcon(item); as icon) {
581
- <span class="prx-rich-timeline__item-icon material-symbols-outlined">
649
+ <span
650
+ class="prx-rich-timeline__item-icon material-symbols-outlined"
651
+ aria-hidden="true"
652
+ >
582
653
  {{ icon }}
583
654
  </span>
584
655
  } @else {
@@ -621,7 +692,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
621
692
  }
622
693
  }
623
694
  </ng-template>
624
- `, styles: [".prx-rich-content-root{display:block}.prx-rich-content-root--inline{display:inline-flex;align-items:var(--prx-rich-content-inline-align-items, center);flex-wrap:var(--prx-rich-content-inline-flex-wrap, nowrap);gap:var(--prx-rich-content-inline-gap, 6px);min-width:0;max-width:100%;vertical-align:middle}.prx-rich-node{min-width:0}.prx-rich-node--inline{display:inline-flex;align-items:center;min-width:0}.prx-rich-compose{display:flex;align-items:center;gap:8px}.prx-rich-compose.direction-column{flex-direction:column;align-items:stretch}.prx-rich-compose.wrap{flex-wrap:wrap}.prx-rich-badge{display:inline-flex;align-items:center;padding:2px 10px;border-radius:999px;background:var(--md-sys-color-primary-container, #e8def8);color:var(--md-sys-color-on-primary-container, #21005d);font-size:12px;line-height:20px}.prx-rich-avatar{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%;background:var(--md-sys-color-surface-container-high, #ece6f0);overflow:hidden}.prx-rich-avatar-image{width:100%;height:100%;object-fit:cover}.prx-rich-avatar-fallback{font-weight:600}.prx-rich-card{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, #cac4d0);border-radius:16px;background:var(--md-sys-color-surface, #fff)}.prx-rich-card-title{font-weight:600}.prx-rich-card-subtitle,.prx-rich-metric-caption{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-metric{display:flex;flex-direction:column;gap:4px}.prx-rich-metric-value{font-size:20px;font-weight:700}.prx-rich-progress{display:flex;flex-direction:column;gap:4px}.prx-rich-progress-bar{width:100%}.prx-rich-media-block{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:center;min-width:0}.prx-rich-media-block__avatar,.prx-rich-media-block__body,.prx-rich-media-block__trailing{min-width:0}.prx-rich-media-block__body{display:flex;flex-direction:column;gap:6px}.prx-rich-media-block__title{font-weight:700}.prx-rich-media-block__subtitle{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:13px}.prx-rich-media-block__meta{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-media-block__trailing{display:flex;align-items:center;justify-content:flex-end}.prx-rich-timeline{display:flex;flex-direction:column;gap:12px;min-width:0}.prx-rich-timeline__title{font-weight:700}.prx-rich-timeline__items{display:flex;flex-direction:column;gap:12px}.prx-rich-timeline__item{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:flex-start;min-width:0}.prx-rich-timeline__item-marker{display:inline-flex;align-items:center;justify-content:center;width:24px;min-height:24px}.prx-rich-timeline__item-icon{font-size:18px;line-height:18px;color:var(--md-sys-color-primary, #6750a4)}.prx-rich-timeline__item-dot{width:10px;height:10px;border-radius:50%;background:var(--md-sys-color-primary, #6750a4);display:inline-block;margin-top:4px}.prx-rich-timeline__item-body{display:flex;flex-direction:column;gap:4px;min-width:0}.prx-rich-timeline__item-title{font-weight:600}.prx-rich-timeline__item-subtitle,.prx-rich-timeline__item-meta,.prx-rich-timeline__empty{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-timeline__item-badge{display:inline-flex;align-items:flex-start;justify-content:flex-end}\n"] }]
695
+ `, styles: [".prx-rich-content-root{display:block}.prx-rich-content-root--inline{display:inline-flex;align-items:var(--prx-rich-content-inline-align-items, center);flex-wrap:var(--prx-rich-content-inline-flex-wrap, nowrap);gap:var(--prx-rich-content-inline-gap, 6px);min-width:0;max-width:100%;vertical-align:middle}.prx-rich-node{min-width:0}.prx-rich-node--inline{display:inline-flex;align-items:center;min-width:0}.prx-rich-compose{display:flex;align-items:center;gap:8px}.prx-rich-compose.direction-column{flex-direction:column;align-items:stretch}.prx-rich-compose.wrap{flex-wrap:wrap}.prx-rich-badge{display:inline-flex;align-items:center;padding:2px 10px;border-radius:999px;background:var(--md-sys-color-primary-container, #e8def8);color:var(--md-sys-color-on-primary-container, #21005d);font-size:12px;line-height:20px}.prx-rich-link{color:var(--md-sys-color-primary, #6750a4);text-decoration:underline;text-underline-offset:2px}.prx-rich-avatar{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%;background:var(--md-sys-color-surface-container-high, #ece6f0);overflow:hidden}.prx-rich-avatar-image{width:100%;height:100%;object-fit:cover}.prx-rich-avatar-fallback{font-weight:600}.prx-rich-card{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, #cac4d0);border-radius:16px;background:var(--md-sys-color-surface, #fff)}.prx-rich-card-title{font-weight:600}.prx-rich-card-subtitle,.prx-rich-metric-caption{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-metric{display:flex;flex-direction:column;gap:4px}.prx-rich-metric-value{font-size:20px;font-weight:700}.prx-rich-progress{display:flex;flex-direction:column;gap:4px}.prx-rich-progress-bar{width:100%}.prx-rich-media-block{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:center;min-width:0}.prx-rich-media-block__avatar,.prx-rich-media-block__body,.prx-rich-media-block__trailing{min-width:0}.prx-rich-media-block__body{display:flex;flex-direction:column;gap:6px}.prx-rich-media-block__title{font-weight:700}.prx-rich-media-block__subtitle{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:13px}.prx-rich-media-block__meta{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-media-block__trailing{display:flex;align-items:center;justify-content:flex-end}.prx-rich-timeline{display:flex;flex-direction:column;gap:12px;min-width:0}.prx-rich-timeline__title{font-weight:700}.prx-rich-timeline__items{display:flex;flex-direction:column;gap:12px}.prx-rich-timeline__item{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:flex-start;min-width:0}.prx-rich-timeline__item-marker{display:inline-flex;align-items:center;justify-content:center;width:24px;min-height:24px}.prx-rich-timeline__item-icon{font-size:18px;line-height:18px;color:var(--md-sys-color-primary, #6750a4)}.prx-rich-timeline__item-dot{width:10px;height:10px;border-radius:50%;background:var(--md-sys-color-primary, #6750a4);display:inline-block;margin-top:4px}.prx-rich-timeline__item-body{display:flex;flex-direction:column;gap:4px;min-width:0}.prx-rich-timeline__item-title{font-weight:600}.prx-rich-timeline__item-subtitle,.prx-rich-timeline__item-meta,.prx-rich-timeline__empty{color:var(--md-sys-color-on-surface-variant, #49454f);font-size:12px}.prx-rich-timeline__item-badge{display:inline-flex;align-items:flex-start;justify-content:flex-end}\n"] }]
625
696
  }], propDecorators: { document: [{ type: i0.Input, args: [{ isSignal: true, alias: "document", required: false }] }], nodes: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodes", required: false }] }], context: [{ type: i0.Input, args: [{ isSignal: true, alias: "context", required: false }] }], hostCapabilities: [{ type: i0.Input, args: [{ isSignal: true, alias: "hostCapabilities", required: false }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], rootClassName: [{
626
697
  type: Input
627
698
  }] } });
@@ -637,31 +708,289 @@ const PRAXIS_RICH_CONTENT_EN_US = {
637
708
  'praxis.richContent.editor.layout.inline': 'Inline',
638
709
  'praxis.richContent.editor.rootClass': 'Root class',
639
710
  'praxis.richContent.editor.rootClassPlaceholder': 'Example: employee-expansion-rich',
640
- 'praxis.richContent.editor.document': 'Document JSON',
641
- 'praxis.richContent.editor.documentHelp': 'Paste or edit a RichContentDocument with kind "praxis.rich-content" and a nodes array.',
711
+ 'praxis.richContent.editor.document': 'Advanced JSON',
712
+ 'praxis.richContent.editor.documentHelp': 'Edit the canonical RichContentDocument. The inspector validates the shape and renders a preview before apply or save.',
713
+ 'praxis.richContent.editor.blocks': 'Blocks',
714
+ 'praxis.richContent.editor.blocksHelp': 'Create and edit common top-level rich-content blocks. Use JSON for advanced nested structures.',
715
+ 'praxis.richContent.editor.block': 'Block',
716
+ 'praxis.richContent.editor.blockType': 'Block type',
717
+ 'praxis.richContent.editor.addBlock': 'Add block',
718
+ 'praxis.richContent.editor.removeBlock': 'Remove',
719
+ 'praxis.richContent.editor.confirmRemove': 'Confirm remove',
720
+ 'praxis.richContent.editor.cancelRemove': 'Cancel',
721
+ 'praxis.richContent.editor.moveUp': 'Move up',
722
+ 'praxis.richContent.editor.moveDown': 'Move down',
723
+ 'praxis.richContent.editor.addItem': 'Add item',
724
+ 'praxis.richContent.editor.item': 'Item',
725
+ 'praxis.richContent.editor.cardContent': 'Card content',
726
+ 'praxis.richContent.editor.composeItems': 'Compose items',
727
+ 'praxis.richContent.editor.timelineItems': 'Timeline items',
728
+ 'praxis.richContent.editor.noBlocks': 'No blocks yet. Add a block to start authoring this document.',
729
+ 'praxis.richContent.editor.noPresets': 'No presets registered',
730
+ 'praxis.richContent.editor.noPresetsHelp': 'Register rich-block presets through PRAXIS_RICH_BLOCK_PRESETS to author preset references visually.',
731
+ 'praxis.richContent.editor.advancedOnly': 'This node type is preserved and can be edited in advanced JSON.',
732
+ 'praxis.richContent.editor.mediaBlockAdvancedHelp': 'Meta and trailing content are preserved in advanced JSON.',
733
+ 'praxis.richContent.editor.cardContentHelp': 'Card content is preserved in JSON. Use advanced JSON for nested card nodes.',
734
+ 'praxis.richContent.editor.nodeId': 'Stable id',
735
+ 'praxis.richContent.editor.nodeIdPlaceholder': 'Example: hero-title',
736
+ 'praxis.richContent.editor.visibilityRule': 'Visibility rule',
737
+ 'praxis.richContent.editor.safeStyle': 'Safe style',
738
+ 'praxis.richContent.editor.nodeType.text': 'Text',
739
+ 'praxis.richContent.editor.nodeType.badge': 'Badge',
740
+ 'praxis.richContent.editor.nodeType.icon': 'Icon',
741
+ 'praxis.richContent.editor.nodeType.image': 'Image',
742
+ 'praxis.richContent.editor.nodeType.link': 'Link',
743
+ 'praxis.richContent.editor.nodeType.metric': 'Metric',
744
+ 'praxis.richContent.editor.nodeType.progress': 'Progress',
745
+ 'praxis.richContent.editor.nodeType.compose': 'Compose',
746
+ 'praxis.richContent.editor.nodeType.card': 'Card',
747
+ 'praxis.richContent.editor.nodeType.mediaBlock': 'Media block',
748
+ 'praxis.richContent.editor.nodeType.timeline': 'Timeline',
749
+ 'praxis.richContent.editor.nodeType.preset': 'Preset',
750
+ 'praxis.richContent.editor.field.text': 'Text',
751
+ 'praxis.richContent.editor.field.textExpr': 'Text binding',
752
+ 'praxis.richContent.editor.field.label': 'Label',
753
+ 'praxis.richContent.editor.field.labelExpr': 'Label binding',
754
+ 'praxis.richContent.editor.field.icon': 'Icon',
755
+ 'praxis.richContent.editor.field.ariaLabel': 'Accessible label',
756
+ 'praxis.richContent.editor.field.src': 'Image URL',
757
+ 'praxis.richContent.editor.field.href': 'Link URL',
758
+ 'praxis.richContent.editor.field.target': 'Target',
759
+ 'praxis.richContent.editor.field.alt': 'Alternative text',
760
+ 'praxis.richContent.editor.field.testId': 'Test id',
761
+ 'praxis.richContent.editor.field.className': 'CSS class',
762
+ 'praxis.richContent.editor.field.visibleWhenPath': 'Context path',
763
+ 'praxis.richContent.editor.field.visibleWhenValue': 'Expected value',
764
+ 'praxis.richContent.editor.field.styleName': 'CSS property',
765
+ 'praxis.richContent.editor.field.styleValue': 'CSS value',
766
+ 'praxis.richContent.editor.field.valueExpr': 'Value binding',
767
+ 'praxis.richContent.editor.field.captionExpr': 'Caption binding',
768
+ 'praxis.richContent.editor.field.max': 'Maximum',
769
+ 'praxis.richContent.editor.field.direction': 'Direction',
770
+ 'praxis.richContent.editor.field.gap': 'Gap',
771
+ 'praxis.richContent.editor.field.avatarName': 'Avatar name',
772
+ 'praxis.richContent.editor.field.avatarImage': 'Avatar image',
773
+ 'praxis.richContent.editor.field.emptyText': 'Empty text',
774
+ 'praxis.richContent.editor.field.meta': 'Meta',
775
+ 'praxis.richContent.editor.field.badge': 'Badge',
776
+ 'praxis.richContent.editor.field.preset': 'Preset',
777
+ 'praxis.richContent.editor.field.title': 'Title',
778
+ 'praxis.richContent.editor.field.subtitle': 'Subtitle',
779
+ 'praxis.richContent.editor.direction.row': 'Row',
780
+ 'praxis.richContent.editor.direction.column': 'Column',
781
+ 'praxis.richContent.editor.placeholder.textExpr': 'row.name',
782
+ 'praxis.richContent.editor.placeholder.labelExpr': 'row.status',
783
+ 'praxis.richContent.editor.placeholder.icon': 'check_circle',
784
+ 'praxis.richContent.editor.placeholder.testId': 'rich-hero-title',
785
+ 'praxis.richContent.editor.placeholder.className': 'hero-title',
786
+ 'praxis.richContent.editor.placeholder.visibleWhenPath': 'row.active',
787
+ 'praxis.richContent.editor.placeholder.visibleWhenValue': 'true',
788
+ 'praxis.richContent.editor.placeholder.styleName': 'color',
789
+ 'praxis.richContent.editor.placeholder.styleValue': 'var(--md-sys-color-primary)',
790
+ 'praxis.richContent.editor.placeholder.valueExpr': 'row.total',
791
+ 'praxis.richContent.editor.placeholder.captionExpr': 'row.caption',
792
+ 'praxis.richContent.editor.placeholder.progressExpr': 'row.progress',
793
+ 'praxis.richContent.editor.defaultText': 'Text',
794
+ 'praxis.richContent.editor.defaultBadge': 'Badge',
795
+ 'praxis.richContent.editor.defaultLink': 'Link',
796
+ 'praxis.richContent.editor.defaultMetric': 'Metric',
797
+ 'praxis.richContent.editor.defaultProgress': 'Progress',
798
+ 'praxis.richContent.editor.defaultCard': 'Card',
799
+ 'praxis.richContent.editor.defaultCardText': 'Card content',
800
+ 'praxis.richContent.editor.defaultAvatarName': 'Person',
801
+ 'praxis.richContent.editor.defaultMediaTitle': 'Media title',
802
+ 'praxis.richContent.editor.defaultMediaSubtitle': 'Media subtitle',
803
+ 'praxis.richContent.editor.defaultTimeline': 'Timeline',
804
+ 'praxis.richContent.editor.defaultTimelineEmpty': 'No events.',
805
+ 'praxis.richContent.editor.defaultTimelineItem': 'Timeline item',
806
+ 'praxis.richContent.editor.defaultTimelineItemSubtitle': 'Item details',
807
+ 'praxis.richContent.editor.inspector': 'Document inspector',
808
+ 'praxis.richContent.editor.overview': 'Overview',
809
+ 'praxis.richContent.editor.nodeCount': 'Nodes',
810
+ 'praxis.richContent.editor.nodeTypes': 'Types',
811
+ 'praxis.richContent.editor.none': 'None',
812
+ 'praxis.richContent.editor.preview': 'Preview',
642
813
  'praxis.richContent.editor.restore': 'Restore',
643
814
  'praxis.richContent.editor.emptyDocument': 'Empty document',
644
- 'praxis.richContent.editor.validation.documentShape': 'The document must have kind "praxis.rich-content" and nodes[].',
815
+ 'praxis.richContent.editor.validation.documentShape': 'The document must be a valid RichContentDocument.',
645
816
  'praxis.richContent.editor.validation.invalidJson': 'Invalid JSON document.',
817
+ 'praxis.richContent.editor.validation.invalidDocument': 'Invalid rich content document.',
818
+ 'praxis.richContent.editor.validation.documentObject': 'The document must be a JSON object.',
819
+ 'praxis.richContent.editor.validation.documentKind': 'The document kind must be "praxis.rich-content".',
820
+ 'praxis.richContent.editor.validation.documentVersion': 'The document version must be "1.0.0".',
821
+ 'praxis.richContent.editor.validation.documentNodes': 'The document must include a nodes array.',
822
+ 'praxis.richContent.editor.validation.maxDepth': 'Rich content nesting is too deep.',
823
+ 'praxis.richContent.editor.validation.maxNodes': 'Rich content documents can include at most 500 nodes.',
824
+ 'praxis.richContent.editor.validation.nodeObject': 'Each rich content node must be an object.',
825
+ 'praxis.richContent.editor.validation.nodeType': 'Each node must use a supported rich-content type.',
826
+ 'praxis.richContent.editor.validation.nodeArray': 'This field must be a rich-content node array.',
827
+ 'praxis.richContent.editor.validation.timelineItems': 'Timeline nodes must include an items array.',
828
+ 'praxis.richContent.editor.validation.timelineItem': 'Each timeline item must be an object.',
829
+ 'praxis.richContent.editor.validation.presetRef': 'Preset nodes must include a rich-block ref object.',
830
+ 'praxis.richContent.editor.validation.presetKind': 'Preset ref kind must be "rich-block".',
831
+ 'praxis.richContent.editor.validation.objectField': 'This field must be an object.',
832
+ 'praxis.richContent.editor.validation.ruleArray': 'Conditional rules must be an array.',
833
+ 'praxis.richContent.editor.validation.ruleObject': 'Conditional rules must be objects.',
834
+ 'praxis.richContent.editor.validation.ruleExpression': 'This rule must include a Json Logic expression.',
835
+ 'praxis.richContent.editor.validation.styleObject': 'Styles must be an object with safe property names and values.',
836
+ 'praxis.richContent.editor.validation.styleName': 'Style names must be simple CSS property names or CSS custom properties.',
837
+ 'praxis.richContent.editor.validation.styleValue': 'Style values must be strings or numbers.',
838
+ 'praxis.richContent.editor.validation.unsafeStyleValue': 'Style values cannot contain script, import, binding or data URL expressions.',
839
+ 'praxis.richContent.editor.validation.unsafeUrl': 'URLs must be relative, http(s), or safe image data URLs.',
840
+ 'praxis.richContent.editor.validation.stringField': 'This field must be a string.',
841
+ 'praxis.richContent.editor.validation.requiredStringField': 'This field must be a non-empty string.',
842
+ 'praxis.richContent.editor.validation.className': 'Class names must be simple CSS class tokens.',
843
+ 'praxis.richContent.editor.validation.numberField': 'This field must be a number.',
844
+ 'praxis.richContent.editor.validation.booleanField': 'This field must be a boolean.',
845
+ 'praxis.richContent.editor.validation.enumField': 'This field must use one of the supported values.',
646
846
  };
647
847
 
648
848
  const PRAXIS_RICH_CONTENT_PT_BR = {
649
849
  'praxis.richContent.editor.eyebrow': 'RichContentDocument',
650
- 'praxis.richContent.editor.title': 'Configurar conteudo rico',
651
- 'praxis.richContent.editor.subtitle': 'Edite o documento canonico consumido pelo renderer. O payload salvo preserva os demais inputs do widget.',
652
- 'praxis.richContent.editor.valid': 'Documento valido',
653
- 'praxis.richContent.editor.invalid': 'JSON invalido',
850
+ 'praxis.richContent.editor.title': 'Configurar conteúdo rico',
851
+ 'praxis.richContent.editor.subtitle': 'Edite o documento canônico consumido pelo renderer. O payload salvo preserva os demais inputs do widget.',
852
+ 'praxis.richContent.editor.valid': 'Documento válido',
853
+ 'praxis.richContent.editor.invalid': 'JSON inválido',
654
854
  'praxis.richContent.editor.layout': 'Layout',
655
855
  'praxis.richContent.editor.layout.block': 'Bloco',
656
856
  'praxis.richContent.editor.layout.inline': 'Inline',
657
857
  'praxis.richContent.editor.rootClass': 'Classe raiz',
658
858
  'praxis.richContent.editor.rootClassPlaceholder': 'Exemplo: employee-expansion-rich',
659
- 'praxis.richContent.editor.document': 'JSON do documento',
660
- 'praxis.richContent.editor.documentHelp': 'Cole ou edite um RichContentDocument com kind "praxis.rich-content" e um array nodes.',
859
+ 'praxis.richContent.editor.document': 'JSON avançado',
860
+ 'praxis.richContent.editor.documentHelp': 'Edite o RichContentDocument canônico. O inspetor valida o formato e renderiza uma prévia antes de aplicar ou salvar.',
861
+ 'praxis.richContent.editor.blocks': 'Blocos',
862
+ 'praxis.richContent.editor.blocksHelp': 'Crie e edite blocos comuns de nível superior de rich-content. Use JSON para estruturas aninhadas avançadas.',
863
+ 'praxis.richContent.editor.block': 'Bloco',
864
+ 'praxis.richContent.editor.blockType': 'Tipo de bloco',
865
+ 'praxis.richContent.editor.addBlock': 'Adicionar bloco',
866
+ 'praxis.richContent.editor.removeBlock': 'Remover',
867
+ 'praxis.richContent.editor.confirmRemove': 'Confirmar remoção',
868
+ 'praxis.richContent.editor.cancelRemove': 'Cancelar',
869
+ 'praxis.richContent.editor.moveUp': 'Mover para cima',
870
+ 'praxis.richContent.editor.moveDown': 'Mover para baixo',
871
+ 'praxis.richContent.editor.addItem': 'Adicionar item',
872
+ 'praxis.richContent.editor.item': 'Item',
873
+ 'praxis.richContent.editor.cardContent': 'Conteúdo do card',
874
+ 'praxis.richContent.editor.composeItems': 'Itens do compose',
875
+ 'praxis.richContent.editor.timelineItems': 'Itens da timeline',
876
+ 'praxis.richContent.editor.noBlocks': 'Ainda não há blocos. Adicione um bloco para iniciar a autoria deste documento.',
877
+ 'praxis.richContent.editor.noPresets': 'Nenhum preset registrado',
878
+ 'praxis.richContent.editor.noPresetsHelp': 'Registre presets rich-block por PRAXIS_RICH_BLOCK_PRESETS para editar referências de preset visualmente.',
879
+ 'praxis.richContent.editor.advancedOnly': 'Este tipo de node é preservado e pode ser editado no JSON avançado.',
880
+ 'praxis.richContent.editor.mediaBlockAdvancedHelp': 'Conteúdo meta e trailing é preservado no JSON avançado.',
881
+ 'praxis.richContent.editor.cardContentHelp': 'O conteúdo do card é preservado no JSON. Use o JSON avançado para nodes aninhados do card.',
882
+ 'praxis.richContent.editor.nodeId': 'Id estável',
883
+ 'praxis.richContent.editor.nodeIdPlaceholder': 'Exemplo: hero-title',
884
+ 'praxis.richContent.editor.visibilityRule': 'Regra de visibilidade',
885
+ 'praxis.richContent.editor.safeStyle': 'Estilo seguro',
886
+ 'praxis.richContent.editor.nodeType.text': 'Texto',
887
+ 'praxis.richContent.editor.nodeType.badge': 'Badge',
888
+ 'praxis.richContent.editor.nodeType.icon': 'Ícone',
889
+ 'praxis.richContent.editor.nodeType.image': 'Imagem',
890
+ 'praxis.richContent.editor.nodeType.link': 'Link',
891
+ 'praxis.richContent.editor.nodeType.metric': 'Métrica',
892
+ 'praxis.richContent.editor.nodeType.progress': 'Progresso',
893
+ 'praxis.richContent.editor.nodeType.compose': 'Compose',
894
+ 'praxis.richContent.editor.nodeType.card': 'Card',
895
+ 'praxis.richContent.editor.nodeType.mediaBlock': 'Bloco de mídia',
896
+ 'praxis.richContent.editor.nodeType.timeline': 'Timeline',
897
+ 'praxis.richContent.editor.nodeType.preset': 'Preset',
898
+ 'praxis.richContent.editor.field.text': 'Texto',
899
+ 'praxis.richContent.editor.field.textExpr': 'Binding do texto',
900
+ 'praxis.richContent.editor.field.label': 'Label',
901
+ 'praxis.richContent.editor.field.labelExpr': 'Binding do label',
902
+ 'praxis.richContent.editor.field.icon': 'Ícone',
903
+ 'praxis.richContent.editor.field.ariaLabel': 'Label acessível',
904
+ 'praxis.richContent.editor.field.src': 'URL da imagem',
905
+ 'praxis.richContent.editor.field.href': 'URL do link',
906
+ 'praxis.richContent.editor.field.target': 'Destino',
907
+ 'praxis.richContent.editor.field.alt': 'Texto alternativo',
908
+ 'praxis.richContent.editor.field.testId': 'Id de teste',
909
+ 'praxis.richContent.editor.field.className': 'Classe CSS',
910
+ 'praxis.richContent.editor.field.visibleWhenPath': 'Path de contexto',
911
+ 'praxis.richContent.editor.field.visibleWhenValue': 'Valor esperado',
912
+ 'praxis.richContent.editor.field.styleName': 'Propriedade CSS',
913
+ 'praxis.richContent.editor.field.styleValue': 'Valor CSS',
914
+ 'praxis.richContent.editor.field.valueExpr': 'Binding do valor',
915
+ 'praxis.richContent.editor.field.captionExpr': 'Binding da legenda',
916
+ 'praxis.richContent.editor.field.max': 'Máximo',
917
+ 'praxis.richContent.editor.field.direction': 'Direção',
918
+ 'praxis.richContent.editor.field.gap': 'Espaçamento',
919
+ 'praxis.richContent.editor.field.avatarName': 'Nome do avatar',
920
+ 'praxis.richContent.editor.field.avatarImage': 'Imagem do avatar',
921
+ 'praxis.richContent.editor.field.emptyText': 'Texto vazio',
922
+ 'praxis.richContent.editor.field.meta': 'Meta',
923
+ 'praxis.richContent.editor.field.badge': 'Badge',
924
+ 'praxis.richContent.editor.field.preset': 'Preset',
925
+ 'praxis.richContent.editor.field.title': 'Título',
926
+ 'praxis.richContent.editor.field.subtitle': 'Subtítulo',
927
+ 'praxis.richContent.editor.direction.row': 'Linha',
928
+ 'praxis.richContent.editor.direction.column': 'Coluna',
929
+ 'praxis.richContent.editor.placeholder.textExpr': 'row.name',
930
+ 'praxis.richContent.editor.placeholder.labelExpr': 'row.status',
931
+ 'praxis.richContent.editor.placeholder.icon': 'check_circle',
932
+ 'praxis.richContent.editor.placeholder.testId': 'rich-hero-title',
933
+ 'praxis.richContent.editor.placeholder.className': 'hero-title',
934
+ 'praxis.richContent.editor.placeholder.visibleWhenPath': 'row.active',
935
+ 'praxis.richContent.editor.placeholder.visibleWhenValue': 'true',
936
+ 'praxis.richContent.editor.placeholder.styleName': 'color',
937
+ 'praxis.richContent.editor.placeholder.styleValue': 'var(--md-sys-color-primary)',
938
+ 'praxis.richContent.editor.placeholder.valueExpr': 'row.total',
939
+ 'praxis.richContent.editor.placeholder.captionExpr': 'row.caption',
940
+ 'praxis.richContent.editor.placeholder.progressExpr': 'row.progress',
941
+ 'praxis.richContent.editor.defaultText': 'Texto',
942
+ 'praxis.richContent.editor.defaultBadge': 'Badge',
943
+ 'praxis.richContent.editor.defaultLink': 'Link',
944
+ 'praxis.richContent.editor.defaultMetric': 'Métrica',
945
+ 'praxis.richContent.editor.defaultProgress': 'Progresso',
946
+ 'praxis.richContent.editor.defaultCard': 'Card',
947
+ 'praxis.richContent.editor.defaultCardText': 'Conteúdo do card',
948
+ 'praxis.richContent.editor.defaultAvatarName': 'Pessoa',
949
+ 'praxis.richContent.editor.defaultMediaTitle': 'Título da mídia',
950
+ 'praxis.richContent.editor.defaultMediaSubtitle': 'Subtítulo da mídia',
951
+ 'praxis.richContent.editor.defaultTimeline': 'Timeline',
952
+ 'praxis.richContent.editor.defaultTimelineEmpty': 'Sem eventos.',
953
+ 'praxis.richContent.editor.defaultTimelineItem': 'Item da timeline',
954
+ 'praxis.richContent.editor.defaultTimelineItemSubtitle': 'Detalhes do item',
955
+ 'praxis.richContent.editor.inspector': 'Inspetor do documento',
956
+ 'praxis.richContent.editor.overview': 'Visão geral',
957
+ 'praxis.richContent.editor.nodeCount': 'Nodes',
958
+ 'praxis.richContent.editor.nodeTypes': 'Tipos',
959
+ 'praxis.richContent.editor.none': 'Nenhum',
960
+ 'praxis.richContent.editor.preview': 'Prévia',
661
961
  'praxis.richContent.editor.restore': 'Restaurar',
662
962
  'praxis.richContent.editor.emptyDocument': 'Documento vazio',
663
- 'praxis.richContent.editor.validation.documentShape': 'O documento deve ter kind "praxis.rich-content" e nodes[].',
664
- 'praxis.richContent.editor.validation.invalidJson': 'Documento JSON invalido.',
963
+ 'praxis.richContent.editor.validation.documentShape': 'O documento deve ser um RichContentDocument válido.',
964
+ 'praxis.richContent.editor.validation.invalidJson': 'Documento JSON inválido.',
965
+ 'praxis.richContent.editor.validation.invalidDocument': 'Documento de conteúdo rico inválido.',
966
+ 'praxis.richContent.editor.validation.documentObject': 'O documento deve ser um objeto JSON.',
967
+ 'praxis.richContent.editor.validation.documentKind': 'O kind do documento deve ser "praxis.rich-content".',
968
+ 'praxis.richContent.editor.validation.documentVersion': 'A versão do documento deve ser "1.0.0".',
969
+ 'praxis.richContent.editor.validation.documentNodes': 'O documento deve incluir um array nodes.',
970
+ 'praxis.richContent.editor.validation.maxDepth': 'O aninhamento do conteúdo rico é muito profundo.',
971
+ 'praxis.richContent.editor.validation.maxNodes': 'Documentos de conteúdo rico podem incluir no máximo 500 nodes.',
972
+ 'praxis.richContent.editor.validation.nodeObject': 'Cada node de conteúdo rico deve ser um objeto.',
973
+ 'praxis.richContent.editor.validation.nodeType': 'Cada node deve usar um tipo de rich-content suportado.',
974
+ 'praxis.richContent.editor.validation.nodeArray': 'Este campo deve ser um array de nodes de conteúdo rico.',
975
+ 'praxis.richContent.editor.validation.timelineItems': 'Nodes de timeline devem incluir um array items.',
976
+ 'praxis.richContent.editor.validation.timelineItem': 'Cada item da timeline deve ser um objeto.',
977
+ 'praxis.richContent.editor.validation.presetRef': 'Nodes de preset devem incluir um objeto ref rich-block.',
978
+ 'praxis.richContent.editor.validation.presetKind': 'O kind do ref de preset deve ser "rich-block".',
979
+ 'praxis.richContent.editor.validation.objectField': 'Este campo deve ser um objeto.',
980
+ 'praxis.richContent.editor.validation.ruleArray': 'Regras condicionais devem ser um array.',
981
+ 'praxis.richContent.editor.validation.ruleObject': 'Regras condicionais devem ser objetos.',
982
+ 'praxis.richContent.editor.validation.ruleExpression': 'Esta regra deve incluir uma expressão Json Logic.',
983
+ 'praxis.richContent.editor.validation.styleObject': 'Estilos devem ser um objeto com nomes e valores seguros.',
984
+ 'praxis.richContent.editor.validation.styleName': 'Nomes de estilo devem ser propriedades CSS simples ou propriedades customizadas CSS.',
985
+ 'praxis.richContent.editor.validation.styleValue': 'Valores de estilo devem ser strings ou números.',
986
+ 'praxis.richContent.editor.validation.unsafeStyleValue': 'Valores de estilo não podem conter script, import, binding ou expressões de URL data.',
987
+ 'praxis.richContent.editor.validation.unsafeUrl': 'URLs devem ser relativas, http(s) ou URLs data de imagem seguras.',
988
+ 'praxis.richContent.editor.validation.stringField': 'Este campo deve ser uma string.',
989
+ 'praxis.richContent.editor.validation.requiredStringField': 'Este campo deve ser uma string não vazia.',
990
+ 'praxis.richContent.editor.validation.className': 'Classes devem ser tokens CSS simples.',
991
+ 'praxis.richContent.editor.validation.numberField': 'Este campo deve ser um número.',
992
+ 'praxis.richContent.editor.validation.booleanField': 'Este campo deve ser booleano.',
993
+ 'praxis.richContent.editor.validation.enumField': 'Este campo deve usar um dos valores suportados.',
665
994
  };
666
995
 
667
996
  function createPraxisRichContentI18nConfig(options = {}) {
@@ -696,6 +1025,634 @@ function resolvePraxisRichContentText(i18n, key, fallback) {
696
1025
  return i18n.t(namespacedKey, undefined, fallback) || fallback;
697
1026
  }
698
1027
 
1028
+ const SUPPORTED_DOCUMENT_KIND = 'praxis.rich-content';
1029
+ const SUPPORTED_DOCUMENT_VERSION = '1.0.0';
1030
+ const MAX_NODE_DEPTH = 12;
1031
+ const MAX_NODE_COUNT = 500;
1032
+ const SUPPORTED_NODE_TYPES = new Set([
1033
+ 'text',
1034
+ 'icon',
1035
+ 'image',
1036
+ 'link',
1037
+ 'badge',
1038
+ 'avatar',
1039
+ 'metric',
1040
+ 'progress',
1041
+ 'compose',
1042
+ 'card',
1043
+ 'mediaBlock',
1044
+ 'timeline',
1045
+ 'preset',
1046
+ ]);
1047
+ const STYLE_NAME_PATTERN = /^--[a-zA-Z0-9-_]+$|^[a-z][a-zA-Z0-9-]*$/;
1048
+ const CLASS_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_-]*(\s+[a-zA-Z_][a-zA-Z0-9_-]*)*$/;
1049
+ const UNSAFE_STYLE_VALUE_PATTERN = /(?:javascript\s*:|vbscript\s*:|data\s*:|expression\s*\(|@import|-moz-binding|behavior\s*:)/i;
1050
+ function validateRichContentDocument(value) {
1051
+ const issues = [];
1052
+ const state = { nodeCount: 0 };
1053
+ if (!isRecord(value)) {
1054
+ addIssue(issues, '$', 'editor.validation.documentObject', 'The document must be a JSON object.');
1055
+ return { valid: false, issues };
1056
+ }
1057
+ if (value['kind'] !== SUPPORTED_DOCUMENT_KIND) {
1058
+ addIssue(issues, '$.kind', 'editor.validation.documentKind', 'The document kind must be "praxis.rich-content".');
1059
+ }
1060
+ if (value['version'] !== SUPPORTED_DOCUMENT_VERSION) {
1061
+ addIssue(issues, '$.version', 'editor.validation.documentVersion', 'The document version must be "1.0.0".');
1062
+ }
1063
+ if (!Array.isArray(value['nodes'])) {
1064
+ addIssue(issues, '$.nodes', 'editor.validation.documentNodes', 'The document must include a nodes array.');
1065
+ }
1066
+ else {
1067
+ validateNodes(value['nodes'], '$.nodes', issues, state, 0);
1068
+ }
1069
+ return { valid: issues.length === 0, issues };
1070
+ }
1071
+ function isValidRichContentDocument(value) {
1072
+ return validateRichContentDocument(value).valid;
1073
+ }
1074
+ function validateNodes(nodes, path, issues, state, depth) {
1075
+ if (depth > MAX_NODE_DEPTH) {
1076
+ addIssue(issues, path, 'editor.validation.maxDepth', 'Rich content nesting is too deep.');
1077
+ return;
1078
+ }
1079
+ for (const [index, node] of nodes.entries()) {
1080
+ state.nodeCount += 1;
1081
+ if (state.nodeCount > MAX_NODE_COUNT) {
1082
+ addIssue(issues, path, 'editor.validation.maxNodes', 'Rich content documents can include at most 500 nodes.');
1083
+ return;
1084
+ }
1085
+ validateNode(node, `${path}[${index}]`, issues, state, depth);
1086
+ }
1087
+ }
1088
+ function validateNode(value, path, issues, state, depth) {
1089
+ if (!isRecord(value)) {
1090
+ addIssue(issues, path, 'editor.validation.nodeObject', 'Each rich content node must be an object.');
1091
+ return;
1092
+ }
1093
+ const type = value['type'];
1094
+ if (typeof type !== 'string' || !SUPPORTED_NODE_TYPES.has(type)) {
1095
+ addIssue(issues, `${path}.type`, 'editor.validation.nodeType', 'Each node must use a supported rich-content type.');
1096
+ return;
1097
+ }
1098
+ validateBaseNode(value, path, issues);
1099
+ switch (type) {
1100
+ case 'text':
1101
+ validateOptionalString(value, 'text', path, issues);
1102
+ validateOptionalExpressionPath(value, 'textExpr', path, issues);
1103
+ break;
1104
+ case 'icon':
1105
+ validateRequiredString(value, 'icon', path, issues);
1106
+ validateOptionalString(value, 'ariaLabel', path, issues);
1107
+ break;
1108
+ case 'image':
1109
+ validateOptionalUrl(value, 'src', path, issues, 'image');
1110
+ validateOptionalExpressionPath(value, 'srcExpr', path, issues);
1111
+ validateOptionalString(value, 'alt', path, issues);
1112
+ validateOptionalExpressionPath(value, 'altExpr', path, issues);
1113
+ break;
1114
+ case 'link':
1115
+ validateOptionalString(value, 'label', path, issues);
1116
+ validateOptionalExpressionPath(value, 'labelExpr', path, issues);
1117
+ validateRequiredUrl(value, 'href', path, issues, 'link');
1118
+ validateEnum(value, 'target', ['_blank', '_self'], path, issues, false);
1119
+ validateOptionalString(value, 'rel', path, issues);
1120
+ break;
1121
+ case 'badge':
1122
+ validateOptionalString(value, 'label', path, issues);
1123
+ validateOptionalExpressionPath(value, 'labelExpr', path, issues);
1124
+ validateOptionalString(value, 'icon', path, issues);
1125
+ break;
1126
+ case 'avatar':
1127
+ validateOptionalString(value, 'name', path, issues);
1128
+ validateOptionalExpressionPath(value, 'nameExpr', path, issues);
1129
+ validateOptionalUrl(value, 'imageSrc', path, issues, 'image');
1130
+ validateOptionalExpressionPath(value, 'imageSrcExpr', path, issues);
1131
+ validateOptionalString(value, 'initials', path, issues);
1132
+ validateOptionalExpressionPath(value, 'initialsExpr', path, issues);
1133
+ break;
1134
+ case 'metric':
1135
+ validateRequiredString(value, 'label', path, issues);
1136
+ validateRequiredExpressionPath(value, 'valueExpr', path, issues);
1137
+ validateOptionalExpressionPath(value, 'captionExpr', path, issues);
1138
+ validateOptionalString(value, 'icon', path, issues);
1139
+ break;
1140
+ case 'progress':
1141
+ validateRequiredExpressionPath(value, 'valueExpr', path, issues);
1142
+ validateOptionalNumber(value, 'max', path, issues);
1143
+ validateOptionalString(value, 'label', path, issues);
1144
+ validateOptionalExpressionPath(value, 'labelExpr', path, issues);
1145
+ validateOptionalBoolean(value, 'showPercent', path, issues);
1146
+ break;
1147
+ case 'compose':
1148
+ validateEnum(value, 'direction', ['row', 'column'], path, issues, false);
1149
+ validateEnum(value, 'gap', ['xs', 'sm', 'md', 'lg', 'xl'], path, issues, false);
1150
+ validateOptionalBoolean(value, 'wrap', path, issues);
1151
+ validateRequiredNodeArray(value, 'items', path, issues, state, depth + 1);
1152
+ break;
1153
+ case 'card':
1154
+ validateOptionalString(value, 'title', path, issues);
1155
+ validateOptionalExpressionPath(value, 'titleExpr', path, issues);
1156
+ validateOptionalString(value, 'subtitle', path, issues);
1157
+ validateOptionalExpressionPath(value, 'subtitleExpr', path, issues);
1158
+ validateRequiredNodeArray(value, 'content', path, issues, state, depth + 1);
1159
+ break;
1160
+ case 'mediaBlock':
1161
+ validateOptionalChildNode(value, 'avatar', path, issues, state, depth + 1);
1162
+ validateOptionalChildNode(value, 'title', path, issues, state, depth + 1);
1163
+ validateOptionalChildNode(value, 'subtitle', path, issues, state, depth + 1);
1164
+ validateOptionalChildNode(value, 'meta', path, issues, state, depth + 1);
1165
+ validateOptionalNodeArray(value, 'trailing', path, issues, state, depth + 1);
1166
+ break;
1167
+ case 'timeline':
1168
+ validateOptionalString(value, 'title', path, issues);
1169
+ validateOptionalExpressionPath(value, 'titleExpr', path, issues);
1170
+ validateOptionalString(value, 'emptyText', path, issues);
1171
+ validateTimelineItems(value['items'], `${path}.items`, issues);
1172
+ break;
1173
+ case 'preset':
1174
+ validatePresetRef(value['ref'], `${path}.ref`, issues);
1175
+ if (value['inputs'] !== undefined && !isRecord(value['inputs'])) {
1176
+ addIssue(issues, `${path}.inputs`, 'editor.validation.objectField', 'Preset inputs must be an object.');
1177
+ }
1178
+ break;
1179
+ }
1180
+ }
1181
+ function validateBaseNode(node, path, issues) {
1182
+ validateOptionalString(node, 'id', path, issues);
1183
+ validateOptionalString(node, 'testId', path, issues);
1184
+ validateOptionalClassName(node, 'className', path, issues);
1185
+ validateStyleObject(node['style'], `${path}.style`, issues);
1186
+ validateRuleExpression(node['visibleWhen'], `${path}.visibleWhen`, issues);
1187
+ validateRuleExpression(node['disabledWhen'], `${path}.disabledWhen`, issues);
1188
+ validateRuleExpression(node['loadWhen'], `${path}.loadWhen`, issues);
1189
+ validateClassWhen(node['classWhen'], `${path}.classWhen`, issues);
1190
+ validateStyleWhen(node['styleWhen'], `${path}.styleWhen`, issues);
1191
+ }
1192
+ function validateRequiredNodeArray(node, field, path, issues, state, depth) {
1193
+ const value = node[field];
1194
+ if (!Array.isArray(value)) {
1195
+ addIssue(issues, `${path}.${field}`, 'editor.validation.nodeArray', 'This field must be a rich-content node array.');
1196
+ return;
1197
+ }
1198
+ validateNodes(value, `${path}.${field}`, issues, state, depth);
1199
+ }
1200
+ function validateOptionalNodeArray(node, field, path, issues, state, depth) {
1201
+ if (node[field] === undefined)
1202
+ return;
1203
+ validateRequiredNodeArray(node, field, path, issues, state, depth);
1204
+ }
1205
+ function validateOptionalChildNode(node, field, path, issues, state, depth) {
1206
+ if (node[field] === undefined)
1207
+ return;
1208
+ validateNode(node[field], `${path}.${field}`, issues, state, depth);
1209
+ }
1210
+ function validateTimelineItems(value, path, issues) {
1211
+ if (!Array.isArray(value)) {
1212
+ addIssue(issues, path, 'editor.validation.timelineItems', 'Timeline nodes must include an items array.');
1213
+ return;
1214
+ }
1215
+ for (const [index, item] of value.entries()) {
1216
+ const itemPath = `${path}[${index}]`;
1217
+ if (!isRecord(item)) {
1218
+ addIssue(issues, itemPath, 'editor.validation.timelineItem', 'Each timeline item must be an object.');
1219
+ continue;
1220
+ }
1221
+ const record = item;
1222
+ for (const field of ['id', 'title', 'subtitle', 'meta', 'icon', 'badge']) {
1223
+ validateOptionalString(record, field, itemPath, issues);
1224
+ }
1225
+ for (const field of ['titleExpr', 'subtitleExpr', 'metaExpr', 'iconExpr', 'badgeExpr']) {
1226
+ validateOptionalExpressionPath(record, field, itemPath, issues);
1227
+ }
1228
+ }
1229
+ }
1230
+ function validatePresetRef(value, path, issues) {
1231
+ if (!isRecord(value)) {
1232
+ addIssue(issues, path, 'editor.validation.presetRef', 'Preset nodes must include a rich-block ref object.');
1233
+ return;
1234
+ }
1235
+ const ref = value;
1236
+ if (ref.kind !== 'rich-block') {
1237
+ addIssue(issues, `${path}.kind`, 'editor.validation.presetKind', 'Preset ref kind must be "rich-block".');
1238
+ }
1239
+ validateRequiredString(ref, 'namespace', path, issues);
1240
+ validateRequiredString(ref, 'presetId', path, issues);
1241
+ validateOptionalString(ref, 'version', path, issues);
1242
+ }
1243
+ function validateClassWhen(value, path, issues) {
1244
+ if (value === undefined)
1245
+ return;
1246
+ if (!Array.isArray(value)) {
1247
+ addIssue(issues, path, 'editor.validation.ruleArray', 'Conditional class rules must be an array.');
1248
+ return;
1249
+ }
1250
+ for (const [index, rule] of value.entries()) {
1251
+ const rulePath = `${path}[${index}]`;
1252
+ if (!isRecord(rule)) {
1253
+ addIssue(issues, rulePath, 'editor.validation.ruleObject', 'Conditional class rules must be objects.');
1254
+ continue;
1255
+ }
1256
+ validateOptionalClassName(rule, 'className', rulePath, issues, true);
1257
+ validateRuleExpression(rule['expr'], `${rulePath}.expr`, issues, true);
1258
+ }
1259
+ }
1260
+ function validateStyleWhen(value, path, issues) {
1261
+ if (value === undefined)
1262
+ return;
1263
+ if (!Array.isArray(value)) {
1264
+ addIssue(issues, path, 'editor.validation.ruleArray', 'Conditional style rules must be an array.');
1265
+ return;
1266
+ }
1267
+ for (const [index, rule] of value.entries()) {
1268
+ const rulePath = `${path}[${index}]`;
1269
+ if (!isRecord(rule)) {
1270
+ addIssue(issues, rulePath, 'editor.validation.ruleObject', 'Conditional style rules must be objects.');
1271
+ continue;
1272
+ }
1273
+ validateStyleObject(rule['style'], `${rulePath}.style`, issues, true);
1274
+ validateRuleExpression(rule['expr'], `${rulePath}.expr`, issues, true);
1275
+ }
1276
+ }
1277
+ function validateRuleExpression(value, path, issues, required = false) {
1278
+ if (value === undefined || value === null) {
1279
+ if (required) {
1280
+ addIssue(issues, path, 'editor.validation.ruleExpression', 'This rule must include a Json Logic expression.');
1281
+ }
1282
+ return;
1283
+ }
1284
+ if (!isRecord(value) && !Array.isArray(value)) {
1285
+ addIssue(issues, path, 'editor.validation.ruleExpression', 'Rules must use Json Logic objects or arrays.');
1286
+ }
1287
+ }
1288
+ function validateStyleObject(value, path, issues, required = false) {
1289
+ if (value === undefined || value === null) {
1290
+ if (required) {
1291
+ addIssue(issues, path, 'editor.validation.styleObject', 'Styles must be an object with safe property names and values.');
1292
+ }
1293
+ return;
1294
+ }
1295
+ if (!isRecord(value)) {
1296
+ addIssue(issues, path, 'editor.validation.styleObject', 'Styles must be an object with safe property names and values.');
1297
+ return;
1298
+ }
1299
+ for (const [name, styleValue] of Object.entries(value)) {
1300
+ if (!STYLE_NAME_PATTERN.test(name)) {
1301
+ addIssue(issues, `${path}.${name}`, 'editor.validation.styleName', 'Style names must be simple CSS property names or CSS custom properties.');
1302
+ }
1303
+ if (typeof styleValue !== 'string' &&
1304
+ typeof styleValue !== 'number') {
1305
+ addIssue(issues, `${path}.${name}`, 'editor.validation.styleValue', 'Style values must be strings or numbers.');
1306
+ continue;
1307
+ }
1308
+ if (typeof styleValue === 'string' &&
1309
+ UNSAFE_STYLE_VALUE_PATTERN.test(styleValue)) {
1310
+ addIssue(issues, `${path}.${name}`, 'editor.validation.unsafeStyleValue', 'Style values cannot contain script, import, binding or data URL expressions.');
1311
+ }
1312
+ }
1313
+ }
1314
+ function validateOptionalUrl(node, field, path, issues, kind) {
1315
+ const value = node[field];
1316
+ if (value === undefined || value === null || value === '')
1317
+ return;
1318
+ if (typeof value !== 'string') {
1319
+ addIssue(issues, `${path}.${field}`, 'editor.validation.stringField', 'This field must be a string.');
1320
+ return;
1321
+ }
1322
+ if (!isSafeUrl(value, kind)) {
1323
+ addIssue(issues, `${path}.${field}`, 'editor.validation.unsafeUrl', 'URLs must be relative, http(s), or safe image data URLs.');
1324
+ }
1325
+ }
1326
+ function validateRequiredUrl(node, field, path, issues, kind) {
1327
+ const value = node[field];
1328
+ if (typeof value !== 'string' || !value.trim()) {
1329
+ addIssue(issues, `${path}.${field}`, 'editor.validation.requiredStringField', 'This field must be a non-empty string.');
1330
+ return;
1331
+ }
1332
+ if (!isSafeUrl(value, kind)) {
1333
+ addIssue(issues, `${path}.${field}`, 'editor.validation.unsafeUrl', 'URLs must be relative, http(s), mailto or tel links, or safe image data URLs.');
1334
+ }
1335
+ }
1336
+ function isSafeUrl(value, kind) {
1337
+ const trimmed = value.trim();
1338
+ if (!trimmed)
1339
+ return true;
1340
+ const lower = trimmed.toLowerCase();
1341
+ if (lower.startsWith('javascript:') ||
1342
+ lower.startsWith('vbscript:') ||
1343
+ lower.startsWith('file:') ||
1344
+ lower.includes('\u0000')) {
1345
+ return false;
1346
+ }
1347
+ if (lower.startsWith('data:')) {
1348
+ return kind === 'image' && /^data:image\/(png|jpeg|jpg|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(trimmed);
1349
+ }
1350
+ if (kind === 'link' && (lower.startsWith('mailto:') || lower.startsWith('tel:') || lower.startsWith('#'))) {
1351
+ return true;
1352
+ }
1353
+ try {
1354
+ const url = new URL(trimmed, 'https://praxis.local');
1355
+ return ['http:', 'https:'].includes(url.protocol);
1356
+ }
1357
+ catch {
1358
+ return false;
1359
+ }
1360
+ }
1361
+ function validateRequiredString(node, field, path, issues) {
1362
+ if (typeof node[field] !== 'string' || !node[field].trim()) {
1363
+ addIssue(issues, `${path}.${field}`, 'editor.validation.requiredStringField', 'This field must be a non-empty string.');
1364
+ }
1365
+ }
1366
+ function validateOptionalString(node, field, path, issues) {
1367
+ if (node[field] !== undefined && typeof node[field] !== 'string') {
1368
+ addIssue(issues, `${path}.${field}`, 'editor.validation.stringField', 'This field must be a string.');
1369
+ }
1370
+ }
1371
+ function validateRequiredExpressionPath(node, field, path, issues) {
1372
+ validateRequiredString(node, field, path, issues);
1373
+ }
1374
+ function validateOptionalExpressionPath(node, field, path, issues) {
1375
+ validateOptionalString(node, field, path, issues);
1376
+ }
1377
+ function validateOptionalClassName(node, field, path, issues, required = false) {
1378
+ const value = node[field];
1379
+ if (value === undefined || value === null || value === '') {
1380
+ if (required) {
1381
+ addIssue(issues, `${path}.${field}`, 'editor.validation.className', 'Class names must be simple CSS class tokens.');
1382
+ }
1383
+ return;
1384
+ }
1385
+ if (typeof value !== 'string' || !CLASS_NAME_PATTERN.test(value.trim())) {
1386
+ addIssue(issues, `${path}.${field}`, 'editor.validation.className', 'Class names must be simple CSS class tokens.');
1387
+ }
1388
+ }
1389
+ function validateOptionalNumber(node, field, path, issues) {
1390
+ if (node[field] !== undefined && typeof node[field] !== 'number') {
1391
+ addIssue(issues, `${path}.${field}`, 'editor.validation.numberField', 'This field must be a number.');
1392
+ }
1393
+ }
1394
+ function validateOptionalBoolean(node, field, path, issues) {
1395
+ if (node[field] !== undefined && typeof node[field] !== 'boolean') {
1396
+ addIssue(issues, `${path}.${field}`, 'editor.validation.booleanField', 'This field must be a boolean.');
1397
+ }
1398
+ }
1399
+ function validateEnum(node, field, allowedValues, path, issues, required) {
1400
+ const value = node[field];
1401
+ if (value === undefined || value === null) {
1402
+ if (required) {
1403
+ addIssue(issues, `${path}.${field}`, 'editor.validation.enumField', 'This field must use one of the supported values.');
1404
+ }
1405
+ return;
1406
+ }
1407
+ if (typeof value !== 'string' || !allowedValues.includes(value)) {
1408
+ addIssue(issues, `${path}.${field}`, 'editor.validation.enumField', 'This field must use one of the supported values.');
1409
+ }
1410
+ }
1411
+ function addIssue(issues, path, messageKey, fallback) {
1412
+ issues.push({ path, messageKey, fallback });
1413
+ }
1414
+ function isRecord(value) {
1415
+ return !!value && typeof value === 'object' && !Array.isArray(value);
1416
+ }
1417
+
1418
+ const EDITABLE_TOP_LEVEL_NODE_TYPES = [
1419
+ 'text',
1420
+ 'badge',
1421
+ 'icon',
1422
+ 'image',
1423
+ 'link',
1424
+ 'metric',
1425
+ 'progress',
1426
+ 'compose',
1427
+ 'card',
1428
+ 'mediaBlock',
1429
+ 'timeline',
1430
+ 'preset',
1431
+ ];
1432
+ const EDITABLE_PRESENTER_NODE_TYPES = [
1433
+ 'text',
1434
+ 'badge',
1435
+ 'icon',
1436
+ 'image',
1437
+ 'link',
1438
+ 'metric',
1439
+ 'progress',
1440
+ ];
1441
+ const EDITABLE_CARD_CONTENT_NODE_TYPES = [
1442
+ ...EDITABLE_PRESENTER_NODE_TYPES,
1443
+ 'compose',
1444
+ ];
1445
+ function createDefaultRichContentNode(type, tx, presetRef) {
1446
+ switch (type) {
1447
+ case 'badge':
1448
+ return { type: 'badge', label: tx('editor.defaultBadge', 'Badge') };
1449
+ case 'icon':
1450
+ return { type: 'icon', icon: 'check_circle' };
1451
+ case 'image':
1452
+ return { type: 'image', src: '/assets/placeholder.png', alt: '' };
1453
+ case 'link':
1454
+ return { type: 'link', label: tx('editor.defaultLink', 'Link'), href: '#' };
1455
+ case 'metric':
1456
+ return {
1457
+ type: 'metric',
1458
+ label: tx('editor.defaultMetric', 'Metric'),
1459
+ valueExpr: 'row.value',
1460
+ };
1461
+ case 'progress':
1462
+ return {
1463
+ type: 'progress',
1464
+ label: tx('editor.defaultProgress', 'Progress'),
1465
+ valueExpr: 'row.progress',
1466
+ max: 100,
1467
+ };
1468
+ case 'compose':
1469
+ return {
1470
+ type: 'compose',
1471
+ direction: 'row',
1472
+ gap: 'md',
1473
+ items: [createDefaultPresenterNode('text', tx)],
1474
+ };
1475
+ case 'card':
1476
+ return {
1477
+ type: 'card',
1478
+ title: tx('editor.defaultCard', 'Card'),
1479
+ content: [
1480
+ {
1481
+ type: 'text',
1482
+ text: tx('editor.defaultCardText', 'Card content'),
1483
+ },
1484
+ ],
1485
+ };
1486
+ case 'mediaBlock':
1487
+ return {
1488
+ type: 'mediaBlock',
1489
+ avatar: {
1490
+ type: 'avatar',
1491
+ name: tx('editor.defaultAvatarName', 'Person'),
1492
+ },
1493
+ title: {
1494
+ type: 'text',
1495
+ text: tx('editor.defaultMediaTitle', 'Media title'),
1496
+ },
1497
+ subtitle: {
1498
+ type: 'text',
1499
+ text: tx('editor.defaultMediaSubtitle', 'Media subtitle'),
1500
+ },
1501
+ };
1502
+ case 'timeline':
1503
+ return {
1504
+ type: 'timeline',
1505
+ title: tx('editor.defaultTimeline', 'Timeline'),
1506
+ emptyText: tx('editor.defaultTimelineEmpty', 'No events.'),
1507
+ items: [createDefaultTimelineItem(tx)],
1508
+ };
1509
+ case 'preset':
1510
+ return {
1511
+ type: 'preset',
1512
+ ref: { ...presetRef },
1513
+ };
1514
+ case 'text':
1515
+ default:
1516
+ return { type: 'text', text: tx('editor.defaultText', 'Text') };
1517
+ }
1518
+ }
1519
+ function createDefaultPresenterNode(type, tx) {
1520
+ switch (type) {
1521
+ case 'badge':
1522
+ return { type: 'badge', label: tx('editor.defaultBadge', 'Badge') };
1523
+ case 'icon':
1524
+ return { type: 'icon', icon: 'check_circle' };
1525
+ case 'image':
1526
+ return { type: 'image', src: '/assets/placeholder.png', alt: '' };
1527
+ case 'link':
1528
+ return { type: 'link', label: tx('editor.defaultLink', 'Link'), href: '#' };
1529
+ case 'metric':
1530
+ return {
1531
+ type: 'metric',
1532
+ label: tx('editor.defaultMetric', 'Metric'),
1533
+ valueExpr: 'row.value',
1534
+ };
1535
+ case 'progress':
1536
+ return {
1537
+ type: 'progress',
1538
+ label: tx('editor.defaultProgress', 'Progress'),
1539
+ valueExpr: 'row.progress',
1540
+ max: 100,
1541
+ };
1542
+ case 'text':
1543
+ default:
1544
+ return { type: 'text', text: tx('editor.defaultText', 'Text') };
1545
+ }
1546
+ }
1547
+ function createDefaultTimelineItem(tx) {
1548
+ return {
1549
+ title: tx('editor.defaultTimelineItem', 'Timeline item'),
1550
+ subtitle: tx('editor.defaultTimelineItemSubtitle', 'Item details'),
1551
+ };
1552
+ }
1553
+ function getPresetOptionValue(ref) {
1554
+ return `${ref.namespace}::${ref.presetId}::${ref.version ?? ''}`;
1555
+ }
1556
+ function getStringField(node, field) {
1557
+ const value = node[field];
1558
+ return typeof value === 'string' ? value : '';
1559
+ }
1560
+ function setStringOnNode(node, field, value) {
1561
+ const record = node;
1562
+ const trimmedValue = value.trim();
1563
+ if (trimmedValue) {
1564
+ record[field] = value;
1565
+ }
1566
+ else {
1567
+ delete record[field];
1568
+ }
1569
+ }
1570
+ function getNumberField(node, field) {
1571
+ const value = node[field];
1572
+ return typeof value === 'number' ? value : null;
1573
+ }
1574
+ function setNumberOnNode(node, field, value) {
1575
+ const record = node;
1576
+ const normalized = typeof value === 'number'
1577
+ ? value
1578
+ : value === '' || value == null
1579
+ ? null
1580
+ : Number(value);
1581
+ if (normalized == null || Number.isNaN(normalized)) {
1582
+ delete record[field];
1583
+ }
1584
+ else {
1585
+ record[field] = normalized;
1586
+ }
1587
+ }
1588
+ function parseSimpleVisibleWhen(expression) {
1589
+ if (!expression || typeof expression !== 'object' || Array.isArray(expression)) {
1590
+ return null;
1591
+ }
1592
+ const equality = expression['==='];
1593
+ if (!Array.isArray(equality) || equality.length !== 2) {
1594
+ return null;
1595
+ }
1596
+ const [left, right] = equality;
1597
+ if (!left || typeof left !== 'object' || Array.isArray(left)) {
1598
+ return null;
1599
+ }
1600
+ const path = left['var'];
1601
+ return typeof path === 'string'
1602
+ ? { path, value: stringifyScalar(right) }
1603
+ : null;
1604
+ }
1605
+ function setSimpleVisibleWhen(node, path, value) {
1606
+ const record = node;
1607
+ const trimmedPath = path.trim();
1608
+ if (!trimmedPath) {
1609
+ delete record['visibleWhen'];
1610
+ return;
1611
+ }
1612
+ record['visibleWhen'] = {
1613
+ '===': [{ var: trimmedPath }, parseScalar(value)],
1614
+ };
1615
+ }
1616
+ function getFirstStyleName(node) {
1617
+ return Object.keys(node.style ?? {})[0] ?? '';
1618
+ }
1619
+ function getFirstStyleValue(node) {
1620
+ const styleName = getFirstStyleName(node);
1621
+ if (!styleName)
1622
+ return '';
1623
+ const value = node.style?.[styleName];
1624
+ return value == null ? '' : String(value);
1625
+ }
1626
+ function setStyleEntry(node, propertyName, propertyValue) {
1627
+ const record = node;
1628
+ const trimmedName = propertyName.trim();
1629
+ const trimmedValue = propertyValue.trim();
1630
+ if (!trimmedName) {
1631
+ delete record['style'];
1632
+ return;
1633
+ }
1634
+ record['style'] = { [trimmedName]: trimmedValue ? propertyValue : '' };
1635
+ }
1636
+ function parseScalar(value) {
1637
+ const trimmed = value.trim();
1638
+ if (trimmed === 'true')
1639
+ return true;
1640
+ if (trimmed === 'false')
1641
+ return false;
1642
+ if (trimmed !== '' && !Number.isNaN(Number(trimmed))) {
1643
+ return Number(trimmed);
1644
+ }
1645
+ return value;
1646
+ }
1647
+ function stringifyScalar(value) {
1648
+ if (typeof value === 'string')
1649
+ return value;
1650
+ if (typeof value === 'number' || typeof value === 'boolean') {
1651
+ return String(value);
1652
+ }
1653
+ return '';
1654
+ }
1655
+
699
1656
  const EMPTY_DOCUMENT = {
700
1657
  kind: 'praxis.rich-content',
701
1658
  version: '1.0.0',
@@ -704,6 +1661,7 @@ const EMPTY_DOCUMENT = {
704
1661
  class PraxisRichContentConfigEditor {
705
1662
  cdr = inject(ChangeDetectorRef);
706
1663
  i18n = inject(PraxisI18nService);
1664
+ presetRegistry = inject(RichContentPresetRegistryService);
707
1665
  injectedData = inject(SETTINGS_PANEL_DATA, {
708
1666
  optional: true,
709
1667
  });
@@ -711,12 +1669,22 @@ class PraxisRichContentConfigEditor {
711
1669
  isDirty$ = new BehaviorSubject(false);
712
1670
  isValid$ = new BehaviorSubject(true);
713
1671
  isBusy$ = new BehaviorSubject(false);
1672
+ editableNodeTypes = EDITABLE_TOP_LEVEL_NODE_TYPES;
1673
+ presenterNodeTypes = EDITABLE_PRESENTER_NODE_TYPES;
1674
+ cardContentNodeTypes = EDITABLE_CARD_CONTENT_NODE_TYPES;
1675
+ presetOptions = this.presetRegistry.list();
714
1676
  documentJson = '';
715
1677
  layout = 'block';
716
1678
  rootClassName = '';
1679
+ newNodeType = 'text';
717
1680
  errorMessage = '';
718
1681
  valid = true;
1682
+ parsedDocument = null;
1683
+ validationIssues = [];
1684
+ nodeCount = 0;
1685
+ nodeTypeSummary = '';
719
1686
  initialInputs = this.normalizeInputs(this.inputs);
1687
+ pendingRemoval = null;
720
1688
  ngOnChanges(changes) {
721
1689
  if (changes['inputs']) {
722
1690
  this.load(this.normalizeInputs(this.inputs), false);
@@ -767,6 +1735,236 @@ class PraxisRichContentConfigEditor {
767
1735
  this.parseDocument();
768
1736
  this.markDirty();
769
1737
  }
1738
+ addTopLevelNode() {
1739
+ const document = this.ensureEditableDocument();
1740
+ document.nodes.push(this.createDefaultNode(this.newNodeType));
1741
+ this.syncStructuredDocumentChange();
1742
+ }
1743
+ removeTopLevelNode(index) {
1744
+ const document = this.ensureEditableDocument();
1745
+ document.nodes.splice(index, 1);
1746
+ this.syncStructuredDocumentChange();
1747
+ }
1748
+ moveTopLevelNode(index, delta) {
1749
+ const document = this.ensureEditableDocument();
1750
+ const nextIndex = index + delta;
1751
+ if (nextIndex < 0 || nextIndex >= document.nodes.length) {
1752
+ return;
1753
+ }
1754
+ const [node] = document.nodes.splice(index, 1);
1755
+ document.nodes.splice(nextIndex, 0, node);
1756
+ this.syncStructuredDocumentChange();
1757
+ }
1758
+ changeTopLevelNodeType(index, nextType) {
1759
+ const document = this.ensureEditableDocument();
1760
+ if (!this.isEditableNodeType(nextType)) {
1761
+ return;
1762
+ }
1763
+ document.nodes[index] = this.createDefaultNode(nextType);
1764
+ this.syncStructuredDocumentChange();
1765
+ }
1766
+ getStringField(node, field) {
1767
+ return getStringField(node, field);
1768
+ }
1769
+ setStringField(index, field, value) {
1770
+ const node = this.ensureEditableDocument().nodes[index];
1771
+ setStringOnNode(node, field, value);
1772
+ this.syncStructuredDocumentChange();
1773
+ }
1774
+ getNumberField(node, field) {
1775
+ return getNumberField(node, field);
1776
+ }
1777
+ setNumberField(index, field, value) {
1778
+ const node = this.ensureEditableDocument().nodes[index];
1779
+ setNumberOnNode(node, field, value);
1780
+ this.syncStructuredDocumentChange();
1781
+ }
1782
+ getVisibleWhenPath(node) {
1783
+ return parseSimpleVisibleWhen(node.visibleWhen)?.path ?? '';
1784
+ }
1785
+ getVisibleWhenValue(node) {
1786
+ return parseSimpleVisibleWhen(node.visibleWhen)?.value ?? '';
1787
+ }
1788
+ setVisibleWhenPath(index, path) {
1789
+ const node = this.ensureEditableDocument().nodes[index];
1790
+ setSimpleVisibleWhen(node, path, this.getVisibleWhenValue(node));
1791
+ this.syncStructuredDocumentChange();
1792
+ }
1793
+ setVisibleWhenValue(index, value) {
1794
+ const node = this.ensureEditableDocument().nodes[index];
1795
+ setSimpleVisibleWhen(node, this.getVisibleWhenPath(node), value);
1796
+ this.syncStructuredDocumentChange();
1797
+ }
1798
+ getFirstStyleName(node) {
1799
+ return getFirstStyleName(node);
1800
+ }
1801
+ getFirstStyleValue(node) {
1802
+ return getFirstStyleValue(node);
1803
+ }
1804
+ setFirstStyleName(index, propertyName) {
1805
+ const node = this.ensureEditableDocument().nodes[index];
1806
+ setStyleEntry(node, propertyName, this.getFirstStyleValue(node));
1807
+ this.syncStructuredDocumentChange();
1808
+ }
1809
+ setFirstStyleValue(index, propertyValue) {
1810
+ const node = this.ensureEditableDocument().nodes[index];
1811
+ setStyleEntry(node, this.getFirstStyleName(node), propertyValue);
1812
+ this.syncStructuredDocumentChange();
1813
+ }
1814
+ requestTopLevelNodeRemoval(index) {
1815
+ this.pendingRemoval = { scope: 'nodes', index };
1816
+ }
1817
+ confirmTopLevelNodeRemoval(index) {
1818
+ this.removeTopLevelNode(index);
1819
+ this.cancelRemoval();
1820
+ }
1821
+ requestNestedRemoval(scope, index) {
1822
+ this.pendingRemoval = { scope, index };
1823
+ }
1824
+ cancelRemoval() {
1825
+ this.pendingRemoval = null;
1826
+ }
1827
+ isRemovalPending(scope, index) {
1828
+ return this.pendingRemoval?.scope === scope && this.pendingRemoval.index === index;
1829
+ }
1830
+ addCardContentNode(cardIndex) {
1831
+ const card = this.ensureCardNode(cardIndex);
1832
+ card.content.push(createDefaultPresenterNode('text', this.tx.bind(this)));
1833
+ this.syncStructuredDocumentChange();
1834
+ }
1835
+ setCardContentStringField(cardIndex, contentIndex, field, value) {
1836
+ const node = this.ensureCardNode(cardIndex).content[contentIndex];
1837
+ setStringOnNode(node, field, value);
1838
+ this.syncStructuredDocumentChange();
1839
+ }
1840
+ setCardContentNumberField(cardIndex, contentIndex, field, value) {
1841
+ const node = this.ensureCardNode(cardIndex).content[contentIndex];
1842
+ setNumberOnNode(node, field, value);
1843
+ this.syncStructuredDocumentChange();
1844
+ }
1845
+ changeCardContentType(cardIndex, contentIndex, type) {
1846
+ if (!this.isPresenterNodeType(type))
1847
+ return;
1848
+ this.ensureCardNode(cardIndex).content[contentIndex] =
1849
+ createDefaultPresenterNode(type, this.tx.bind(this));
1850
+ this.syncStructuredDocumentChange();
1851
+ }
1852
+ confirmCardContentRemoval(cardIndex, contentIndex) {
1853
+ this.ensureCardNode(cardIndex).content.splice(contentIndex, 1);
1854
+ this.cancelRemoval();
1855
+ this.syncStructuredDocumentChange();
1856
+ }
1857
+ addComposeItem(composeIndex) {
1858
+ const compose = this.ensureComposeNode(composeIndex);
1859
+ compose.items.push(createDefaultPresenterNode('text', this.tx.bind(this)));
1860
+ this.syncStructuredDocumentChange();
1861
+ }
1862
+ setComposeItemStringField(composeIndex, itemIndex, field, value) {
1863
+ const node = this.ensureComposeNode(composeIndex).items[itemIndex];
1864
+ setStringOnNode(node, field, value);
1865
+ this.syncStructuredDocumentChange();
1866
+ }
1867
+ setComposeItemNumberField(composeIndex, itemIndex, field, value) {
1868
+ const node = this.ensureComposeNode(composeIndex).items[itemIndex];
1869
+ setNumberOnNode(node, field, value);
1870
+ this.syncStructuredDocumentChange();
1871
+ }
1872
+ changeComposeItemType(composeIndex, itemIndex, type) {
1873
+ if (!this.isPresenterNodeType(type))
1874
+ return;
1875
+ this.ensureComposeNode(composeIndex).items[itemIndex] =
1876
+ createDefaultPresenterNode(type, this.tx.bind(this));
1877
+ this.syncStructuredDocumentChange();
1878
+ }
1879
+ confirmComposeItemRemoval(composeIndex, itemIndex) {
1880
+ this.ensureComposeNode(composeIndex).items.splice(itemIndex, 1);
1881
+ this.cancelRemoval();
1882
+ this.syncStructuredDocumentChange();
1883
+ }
1884
+ isPresenterNode(node) {
1885
+ return this.isPresenterNodeType(node.type);
1886
+ }
1887
+ isCardContentEditable(type) {
1888
+ return this.isPresenterNodeType(type);
1889
+ }
1890
+ setMediaBlockAvatarField(index, field, value) {
1891
+ const avatar = this.ensureMediaBlockAvatar(this.ensureMediaBlockNode(index));
1892
+ setStringOnNode(avatar, field, value);
1893
+ this.syncStructuredDocumentChange();
1894
+ }
1895
+ getMediaBlockAvatarField(index, field) {
1896
+ const node = this.ensureEditableDocument().nodes[index];
1897
+ if (node.type !== 'mediaBlock' || !node.avatar)
1898
+ return '';
1899
+ return getStringField(node.avatar, field);
1900
+ }
1901
+ setMediaBlockTextField(index, field, value) {
1902
+ const mediaBlock = this.ensureMediaBlockNode(index);
1903
+ const record = mediaBlock;
1904
+ if (value.trim()) {
1905
+ record[field] = { type: 'text', text: value };
1906
+ }
1907
+ else {
1908
+ delete record[field];
1909
+ }
1910
+ this.syncStructuredDocumentChange();
1911
+ }
1912
+ getMediaBlockTextField(index, field) {
1913
+ const node = this.ensureEditableDocument().nodes[index];
1914
+ if (node.type !== 'mediaBlock')
1915
+ return '';
1916
+ const value = node[field];
1917
+ return value?.type === 'text' ? getStringField(value, 'text') : '';
1918
+ }
1919
+ addTimelineItem(index) {
1920
+ this.ensureTimelineNode(index).items.push(createDefaultTimelineItem(this.tx.bind(this)));
1921
+ this.syncStructuredDocumentChange();
1922
+ }
1923
+ setTimelineItemField(index, itemIndex, field, value) {
1924
+ const item = this.ensureTimelineNode(index).items[itemIndex];
1925
+ if (value.trim()) {
1926
+ item[field] = value;
1927
+ }
1928
+ else {
1929
+ delete item[field];
1930
+ }
1931
+ this.syncStructuredDocumentChange();
1932
+ }
1933
+ getTimelineItemField(index, itemIndex, field) {
1934
+ const item = this.ensureTimelineNode(index).items[itemIndex];
1935
+ const value = item?.[field];
1936
+ return typeof value === 'string' ? value : '';
1937
+ }
1938
+ confirmTimelineItemRemoval(index, itemIndex) {
1939
+ this.ensureTimelineNode(index).items.splice(itemIndex, 1);
1940
+ this.cancelRemoval();
1941
+ this.syncStructuredDocumentChange();
1942
+ }
1943
+ setPresetSelection(index, optionValue) {
1944
+ const preset = this.presetOptions.find((candidate) => getPresetOptionValue(candidate.ref) === optionValue);
1945
+ const node = this.ensureEditableDocument().nodes[index];
1946
+ if (!preset || node.type !== 'preset')
1947
+ return;
1948
+ node.ref = { ...preset.ref };
1949
+ this.syncStructuredDocumentChange();
1950
+ }
1951
+ getPresetSelection(node) {
1952
+ return node.type === 'preset' ? getPresetOptionValue(node.ref) : '';
1953
+ }
1954
+ getPresetOptionValue(ref) {
1955
+ return getPresetOptionValue(ref);
1956
+ }
1957
+ getIssueMessages(path) {
1958
+ return this.validationIssues
1959
+ .filter((issue) => issue.path === path || issue.path.startsWith(`${path}.`))
1960
+ .map((issue) => this.tx(issue.messageKey, issue.fallback));
1961
+ }
1962
+ isEditableNodeType(type) {
1963
+ return EDITABLE_TOP_LEVEL_NODE_TYPES.includes(type);
1964
+ }
1965
+ isPresenterNodeType(type) {
1966
+ return EDITABLE_PRESENTER_NODE_TYPES.includes(type);
1967
+ }
770
1968
  load(inputs, dirty) {
771
1969
  const document = inputs.document ?? EMPTY_DOCUMENT;
772
1970
  const layout = inputs.layout === 'inline' ? 'inline' : 'block';
@@ -786,30 +1984,129 @@ class PraxisRichContentConfigEditor {
786
1984
  normalizeInputs(inputs) {
787
1985
  return this.cloneInputs(inputs ?? {});
788
1986
  }
1987
+ ensureEditableDocument() {
1988
+ if (this.parsedDocument) {
1989
+ return this.parsedDocument;
1990
+ }
1991
+ this.parsedDocument = this.cloneValue(EMPTY_DOCUMENT);
1992
+ return this.parsedDocument;
1993
+ }
1994
+ syncStructuredDocumentChange() {
1995
+ if (!this.parsedDocument) {
1996
+ return;
1997
+ }
1998
+ this.documentJson = this.stringify(this.parsedDocument);
1999
+ this.parseDocument();
2000
+ this.markDirty();
2001
+ }
2002
+ createDefaultNode(type) {
2003
+ return createDefaultRichContentNode(type, this.tx.bind(this), this.getDefaultPresetRef());
2004
+ }
2005
+ getDefaultPresetRef() {
2006
+ return (this.presetOptions[0]?.ref ?? {
2007
+ kind: 'rich-block',
2008
+ namespace: 'praxis.rich-content',
2009
+ presetId: 'default',
2010
+ });
2011
+ }
2012
+ ensureCardNode(index) {
2013
+ const node = this.ensureEditableDocument().nodes[index];
2014
+ if (node.type !== 'card') {
2015
+ throw new Error('Expected card node.');
2016
+ }
2017
+ return node;
2018
+ }
2019
+ ensureComposeNode(index) {
2020
+ const node = this.ensureEditableDocument().nodes[index];
2021
+ if (node.type !== 'compose') {
2022
+ throw new Error('Expected compose node.');
2023
+ }
2024
+ return node;
2025
+ }
2026
+ ensureMediaBlockNode(index) {
2027
+ const node = this.ensureEditableDocument().nodes[index];
2028
+ if (node.type !== 'mediaBlock') {
2029
+ throw new Error('Expected mediaBlock node.');
2030
+ }
2031
+ return node;
2032
+ }
2033
+ ensureMediaBlockAvatar(node) {
2034
+ if (!node.avatar || node.avatar.type !== 'avatar') {
2035
+ node.avatar = { type: 'avatar' };
2036
+ }
2037
+ return node.avatar;
2038
+ }
2039
+ ensureTimelineNode(index) {
2040
+ const node = this.ensureEditableDocument().nodes[index];
2041
+ if (node.type !== 'timeline') {
2042
+ throw new Error('Expected timeline node.');
2043
+ }
2044
+ return node;
2045
+ }
789
2046
  parseDocument() {
790
2047
  try {
791
2048
  const parsed = JSON.parse(this.documentJson);
792
- if (!parsed ||
793
- typeof parsed !== 'object' ||
794
- parsed.kind !== 'praxis.rich-content' ||
795
- !Array.isArray(parsed.nodes)) {
796
- throw new Error(this.tx('editor.validation.documentShape', 'The document must have kind "praxis.rich-content" and nodes[].'));
2049
+ const validation = validateRichContentDocument(parsed);
2050
+ this.validationIssues = validation.issues;
2051
+ if (!validation.valid) {
2052
+ this.setInvalidState(this.tx('editor.validation.documentShape', 'The document must be a valid RichContentDocument.'));
2053
+ return null;
797
2054
  }
2055
+ const document = parsed;
798
2056
  this.valid = true;
799
2057
  this.errorMessage = '';
800
2058
  this.isValid$.next(true);
801
- return parsed;
2059
+ this.parsedDocument = document;
2060
+ this.updateDocumentOverview(document);
2061
+ return document;
802
2062
  }
803
2063
  catch (error) {
804
- this.valid = false;
805
- this.errorMessage =
806
- error instanceof Error
807
- ? error.message
808
- : this.tx('editor.validation.invalidJson', 'Invalid JSON document.');
809
- this.isValid$.next(false);
2064
+ this.validationIssues = [];
2065
+ this.setInvalidState(error instanceof SyntaxError
2066
+ ? this.tx('editor.validation.invalidJson', 'Invalid JSON document.')
2067
+ : this.tx('editor.validation.invalidDocument', 'Invalid rich content document.'));
810
2068
  return null;
811
2069
  }
812
2070
  }
2071
+ setInvalidState(message) {
2072
+ this.valid = false;
2073
+ this.errorMessage = message;
2074
+ this.isValid$.next(false);
2075
+ this.parsedDocument = null;
2076
+ this.nodeCount = 0;
2077
+ this.nodeTypeSummary = '';
2078
+ }
2079
+ updateDocumentOverview(document) {
2080
+ const typeCounts = new Map();
2081
+ let count = 0;
2082
+ const visit = (nodes) => {
2083
+ for (const node of nodes) {
2084
+ count += 1;
2085
+ typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1);
2086
+ if (node.type === 'compose') {
2087
+ visit(node.items);
2088
+ }
2089
+ else if (node.type === 'card') {
2090
+ visit(node.content);
2091
+ }
2092
+ else if (node.type === 'mediaBlock') {
2093
+ visit([
2094
+ node.avatar,
2095
+ node.title,
2096
+ node.subtitle,
2097
+ node.meta,
2098
+ ...(node.trailing ?? []),
2099
+ ].filter(Boolean));
2100
+ }
2101
+ }
2102
+ };
2103
+ visit(document.nodes);
2104
+ this.nodeCount = count;
2105
+ this.nodeTypeSummary = Array.from(typeCounts.entries())
2106
+ .sort(([left], [right]) => left.localeCompare(right))
2107
+ .map(([type, total]) => `${type} (${total})`)
2108
+ .join(', ');
2109
+ }
813
2110
  matchesInitialState() {
814
2111
  const current = this.getSettingsValue();
815
2112
  if (!current)
@@ -888,37 +2185,781 @@ class PraxisRichContentConfigEditor {
888
2185
  </label>
889
2186
  </div>
890
2187
 
2188
+ <ng-template
2189
+ #presenterFields
2190
+ let-node="node"
2191
+ let-path="path"
2192
+ let-setString="setString"
2193
+ let-setNumber="setNumber"
2194
+ >
2195
+ @switch (node.type) {
2196
+ @case ('text') {
2197
+ <label class="prx-rich-editor__wide-field">
2198
+ <span>{{ tx('editor.field.text', 'Text') }}</span>
2199
+ <textarea
2200
+ [ngModel]="getStringField(node, 'text')"
2201
+ (ngModelChange)="setString('text', $event)"
2202
+ ></textarea>
2203
+ </label>
2204
+ }
2205
+ @case ('badge') {
2206
+ <label>
2207
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
2208
+ <input
2209
+ [ngModel]="getStringField(node, 'label')"
2210
+ (ngModelChange)="setString('label', $event)"
2211
+ />
2212
+ </label>
2213
+ }
2214
+ @case ('icon') {
2215
+ <label>
2216
+ <span>{{ tx('editor.field.icon', 'Icon') }}</span>
2217
+ <input
2218
+ [ngModel]="getStringField(node, 'icon')"
2219
+ (ngModelChange)="setString('icon', $event)"
2220
+ />
2221
+ </label>
2222
+ }
2223
+ @case ('image') {
2224
+ <label>
2225
+ <span>{{ tx('editor.field.src', 'Image URL') }}</span>
2226
+ <input
2227
+ [ngModel]="getStringField(node, 'src')"
2228
+ (ngModelChange)="setString('src', $event)"
2229
+ />
2230
+ </label>
2231
+ <label>
2232
+ <span>{{ tx('editor.field.alt', 'Alternative text') }}</span>
2233
+ <input
2234
+ [ngModel]="getStringField(node, 'alt')"
2235
+ (ngModelChange)="setString('alt', $event)"
2236
+ />
2237
+ </label>
2238
+ }
2239
+ @case ('link') {
2240
+ <label>
2241
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
2242
+ <input
2243
+ [ngModel]="getStringField(node, 'label')"
2244
+ (ngModelChange)="setString('label', $event)"
2245
+ />
2246
+ </label>
2247
+ <label>
2248
+ <span>{{ tx('editor.field.href', 'Link URL') }}</span>
2249
+ <input
2250
+ [ngModel]="getStringField(node, 'href')"
2251
+ (ngModelChange)="setString('href', $event)"
2252
+ />
2253
+ </label>
2254
+ }
2255
+ @case ('metric') {
2256
+ <label>
2257
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
2258
+ <input
2259
+ [ngModel]="getStringField(node, 'label')"
2260
+ (ngModelChange)="setString('label', $event)"
2261
+ />
2262
+ </label>
2263
+ <label>
2264
+ <span>{{ tx('editor.field.valueExpr', 'Value binding') }}</span>
2265
+ <input
2266
+ [ngModel]="getStringField(node, 'valueExpr')"
2267
+ (ngModelChange)="setString('valueExpr', $event)"
2268
+ />
2269
+ </label>
2270
+ }
2271
+ @case ('progress') {
2272
+ <label>
2273
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
2274
+ <input
2275
+ [ngModel]="getStringField(node, 'label')"
2276
+ (ngModelChange)="setString('label', $event)"
2277
+ />
2278
+ </label>
2279
+ <label>
2280
+ <span>{{ tx('editor.field.max', 'Maximum') }}</span>
2281
+ <input
2282
+ type="number"
2283
+ [ngModel]="getNumberField(node, 'max')"
2284
+ (ngModelChange)="setNumber('max', $event)"
2285
+ />
2286
+ </label>
2287
+ }
2288
+ }
2289
+ @if (getIssueMessages(path).length) {
2290
+ <ul class="prx-rich-editor__field-errors">
2291
+ @for (message of getIssueMessages(path); track message) {
2292
+ <li>{{ message }}</li>
2293
+ }
2294
+ </ul>
2295
+ }
2296
+ </ng-template>
2297
+
2298
+ <section class="prx-rich-editor__blocks">
2299
+ <header class="prx-rich-editor__section-header">
2300
+ <div>
2301
+ <h3>{{ tx('editor.blocks', 'Blocks') }}</h3>
2302
+ <p>
2303
+ {{
2304
+ tx(
2305
+ 'editor.blocksHelp',
2306
+ 'Create and edit common top-level rich-content blocks. Use JSON for advanced nested structures.'
2307
+ )
2308
+ }}
2309
+ </p>
2310
+ </div>
2311
+ <div class="prx-rich-editor__add-block">
2312
+ <label>
2313
+ <span>{{ tx('editor.blockType', 'Block type') }}</span>
2314
+ <select
2315
+ name="rich-content-new-node-type"
2316
+ [(ngModel)]="newNodeType"
2317
+ data-testid="rich-content-new-node-type"
2318
+ >
2319
+ @for (type of editableNodeTypes; track type) {
2320
+ <option [value]="type">{{ tx('editor.nodeType.' + type, type) }}</option>
2321
+ }
2322
+ </select>
2323
+ </label>
2324
+ <button
2325
+ type="button"
2326
+ (click)="addTopLevelNode()"
2327
+ data-testid="rich-content-add-node"
2328
+ >
2329
+ {{ tx('editor.addBlock', 'Add block') }}
2330
+ </button>
2331
+ </div>
2332
+ </header>
2333
+
2334
+ @if (parsedDocument?.nodes?.length) {
2335
+ <div class="prx-rich-editor__node-list">
2336
+ @for (node of parsedDocument?.nodes ?? []; track node.id ?? $index) {
2337
+ <article
2338
+ class="prx-rich-editor__node-card"
2339
+ [attr.data-rich-editor-node-type]="node.type"
2340
+ >
2341
+ <header class="prx-rich-editor__node-header">
2342
+ <div>
2343
+ <p class="prx-rich-editor__node-eyebrow">
2344
+ {{ tx('editor.block', 'Block') }} {{ $index + 1 }}
2345
+ </p>
2346
+ <h4>{{ tx('editor.nodeType.' + node.type, node.type) }}</h4>
2347
+ </div>
2348
+ <div class="prx-rich-editor__node-actions">
2349
+ <button type="button" (click)="moveTopLevelNode($index, -1)" [disabled]="$index === 0">
2350
+ {{ tx('editor.moveUp', 'Move up') }}
2351
+ </button>
2352
+ <button
2353
+ type="button"
2354
+ (click)="moveTopLevelNode($index, 1)"
2355
+ [disabled]="$index === (parsedDocument?.nodes?.length ?? 0) - 1"
2356
+ >
2357
+ {{ tx('editor.moveDown', 'Move down') }}
2358
+ </button>
2359
+ @if (isRemovalPending('nodes', $index)) {
2360
+ <button type="button" (click)="confirmTopLevelNodeRemoval($index)">
2361
+ {{ tx('editor.confirmRemove', 'Confirm remove') }}
2362
+ </button>
2363
+ <button type="button" (click)="cancelRemoval()">
2364
+ {{ tx('editor.cancelRemove', 'Cancel') }}
2365
+ </button>
2366
+ } @else {
2367
+ <button type="button" (click)="requestTopLevelNodeRemoval($index)">
2368
+ {{ tx('editor.removeBlock', 'Remove') }}
2369
+ </button>
2370
+ }
2371
+ </div>
2372
+ </header>
2373
+
2374
+ @if (getIssueMessages('$.nodes[' + $index + ']').length) {
2375
+ <ul class="prx-rich-editor__field-errors">
2376
+ @for (message of getIssueMessages('$.nodes[' + $index + ']'); track message) {
2377
+ <li>{{ message }}</li>
2378
+ }
2379
+ </ul>
2380
+ }
2381
+
2382
+ <div class="prx-rich-editor__node-grid">
2383
+ <label>
2384
+ <span>{{ tx('editor.blockType', 'Block type') }}</span>
2385
+ <select
2386
+ [ngModel]="isEditableNodeType(node.type) ? node.type : 'text'"
2387
+ (ngModelChange)="changeTopLevelNodeType($index, $event)"
2388
+ [disabled]="!isEditableNodeType(node.type)"
2389
+ >
2390
+ @for (type of editableNodeTypes; track type) {
2391
+ <option [value]="type">{{ tx('editor.nodeType.' + type, type) }}</option>
2392
+ }
2393
+ </select>
2394
+ </label>
2395
+
2396
+ <label>
2397
+ <span>{{ tx('editor.nodeId', 'Stable id') }}</span>
2398
+ <input
2399
+ [ngModel]="getStringField(node, 'id')"
2400
+ (ngModelChange)="setStringField($index, 'id', $event)"
2401
+ [placeholder]="tx('editor.nodeIdPlaceholder', 'Example: hero-title')"
2402
+ />
2403
+ </label>
2404
+
2405
+ @switch (node.type) {
2406
+ @case ('text') {
2407
+ <label class="prx-rich-editor__wide-field">
2408
+ <span>{{ tx('editor.field.text', 'Text') }}</span>
2409
+ <textarea
2410
+ [ngModel]="getStringField(node, 'text')"
2411
+ (ngModelChange)="setStringField($index, 'text', $event)"
2412
+ ></textarea>
2413
+ @if (getIssueMessages('$.nodes[' + $index + '].text').length) {
2414
+ <small class="prx-rich-editor__field-error">
2415
+ {{ getIssueMessages('$.nodes[' + $index + '].text').join(' ') }}
2416
+ </small>
2417
+ }
2418
+ </label>
2419
+ <label>
2420
+ <span>{{ tx('editor.field.textExpr', 'Text binding') }}</span>
2421
+ <input
2422
+ [ngModel]="getStringField(node, 'textExpr')"
2423
+ (ngModelChange)="setStringField($index, 'textExpr', $event)"
2424
+ [placeholder]="tx('editor.placeholder.textExpr', 'row.name')"
2425
+ />
2426
+ @if (getIssueMessages('$.nodes[' + $index + '].textExpr').length) {
2427
+ <small class="prx-rich-editor__field-error">
2428
+ {{ getIssueMessages('$.nodes[' + $index + '].textExpr').join(' ') }}
2429
+ </small>
2430
+ }
2431
+ </label>
2432
+ }
2433
+ @case ('badge') {
2434
+ <label>
2435
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
2436
+ <input
2437
+ [ngModel]="getStringField(node, 'label')"
2438
+ (ngModelChange)="setStringField($index, 'label', $event)"
2439
+ />
2440
+ </label>
2441
+ <label>
2442
+ <span>{{ tx('editor.field.labelExpr', 'Label binding') }}</span>
2443
+ <input
2444
+ [ngModel]="getStringField(node, 'labelExpr')"
2445
+ (ngModelChange)="setStringField($index, 'labelExpr', $event)"
2446
+ [placeholder]="tx('editor.placeholder.labelExpr', 'row.status')"
2447
+ />
2448
+ </label>
2449
+ <label>
2450
+ <span>{{ tx('editor.field.icon', 'Icon') }}</span>
2451
+ <input
2452
+ [ngModel]="getStringField(node, 'icon')"
2453
+ (ngModelChange)="setStringField($index, 'icon', $event)"
2454
+ [placeholder]="tx('editor.placeholder.icon', 'check_circle')"
2455
+ />
2456
+ </label>
2457
+ }
2458
+ @case ('icon') {
2459
+ <label>
2460
+ <span>{{ tx('editor.field.icon', 'Icon') }}</span>
2461
+ <input
2462
+ [ngModel]="getStringField(node, 'icon')"
2463
+ (ngModelChange)="setStringField($index, 'icon', $event)"
2464
+ [placeholder]="tx('editor.placeholder.icon', 'check_circle')"
2465
+ />
2466
+ </label>
2467
+ <label>
2468
+ <span>{{ tx('editor.field.ariaLabel', 'Accessible label') }}</span>
2469
+ <input
2470
+ [ngModel]="getStringField(node, 'ariaLabel')"
2471
+ (ngModelChange)="setStringField($index, 'ariaLabel', $event)"
2472
+ />
2473
+ </label>
2474
+ }
2475
+ @case ('image') {
2476
+ <label>
2477
+ <span>{{ tx('editor.field.src', 'Image URL') }}</span>
2478
+ <input
2479
+ [ngModel]="getStringField(node, 'src')"
2480
+ (ngModelChange)="setStringField($index, 'src', $event)"
2481
+ />
2482
+ </label>
2483
+ <label>
2484
+ <span>{{ tx('editor.field.alt', 'Alternative text') }}</span>
2485
+ <input
2486
+ [ngModel]="getStringField(node, 'alt')"
2487
+ (ngModelChange)="setStringField($index, 'alt', $event)"
2488
+ />
2489
+ </label>
2490
+ }
2491
+ @case ('metric') {
2492
+ <label>
2493
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
2494
+ <input
2495
+ [ngModel]="getStringField(node, 'label')"
2496
+ (ngModelChange)="setStringField($index, 'label', $event)"
2497
+ />
2498
+ </label>
2499
+ <label>
2500
+ <span>{{ tx('editor.field.valueExpr', 'Value binding') }}</span>
2501
+ <input
2502
+ [ngModel]="getStringField(node, 'valueExpr')"
2503
+ (ngModelChange)="setStringField($index, 'valueExpr', $event)"
2504
+ [placeholder]="tx('editor.placeholder.valueExpr', 'row.total')"
2505
+ />
2506
+ </label>
2507
+ <label>
2508
+ <span>{{ tx('editor.field.captionExpr', 'Caption binding') }}</span>
2509
+ <input
2510
+ [ngModel]="getStringField(node, 'captionExpr')"
2511
+ (ngModelChange)="setStringField($index, 'captionExpr', $event)"
2512
+ [placeholder]="tx('editor.placeholder.captionExpr', 'row.caption')"
2513
+ />
2514
+ </label>
2515
+ }
2516
+ @case ('progress') {
2517
+ <label>
2518
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
2519
+ <input
2520
+ [ngModel]="getStringField(node, 'label')"
2521
+ (ngModelChange)="setStringField($index, 'label', $event)"
2522
+ />
2523
+ </label>
2524
+ <label>
2525
+ <span>{{ tx('editor.field.valueExpr', 'Value binding') }}</span>
2526
+ <input
2527
+ [ngModel]="getStringField(node, 'valueExpr')"
2528
+ (ngModelChange)="setStringField($index, 'valueExpr', $event)"
2529
+ [placeholder]="tx('editor.placeholder.progressExpr', 'row.progress')"
2530
+ />
2531
+ </label>
2532
+ <label>
2533
+ <span>{{ tx('editor.field.max', 'Maximum') }}</span>
2534
+ <input
2535
+ type="number"
2536
+ [ngModel]="getNumberField(node, 'max')"
2537
+ (ngModelChange)="setNumberField($index, 'max', $event)"
2538
+ />
2539
+ </label>
2540
+ }
2541
+ @case ('compose') {
2542
+ <label>
2543
+ <span>{{ tx('editor.field.direction', 'Direction') }}</span>
2544
+ <select
2545
+ [ngModel]="getStringField(node, 'direction') || 'row'"
2546
+ (ngModelChange)="setStringField($index, 'direction', $event)"
2547
+ >
2548
+ <option value="row">{{ tx('editor.direction.row', 'Row') }}</option>
2549
+ <option value="column">{{ tx('editor.direction.column', 'Column') }}</option>
2550
+ </select>
2551
+ </label>
2552
+ <label>
2553
+ <span>{{ tx('editor.field.gap', 'Gap') }}</span>
2554
+ <select
2555
+ [ngModel]="getStringField(node, 'gap') || 'md'"
2556
+ (ngModelChange)="setStringField($index, 'gap', $event)"
2557
+ >
2558
+ <option value="xs">xs</option>
2559
+ <option value="sm">sm</option>
2560
+ <option value="md">md</option>
2561
+ <option value="lg">lg</option>
2562
+ <option value="xl">xl</option>
2563
+ </select>
2564
+ </label>
2565
+ <div class="prx-rich-editor__nested-editor">
2566
+ <header class="prx-rich-editor__nested-header">
2567
+ <h5>{{ tx('editor.composeItems', 'Compose items') }}</h5>
2568
+ <button type="button" (click)="addComposeItem($index)">
2569
+ {{ tx('editor.addItem', 'Add item') }}
2570
+ </button>
2571
+ </header>
2572
+ @for (item of node.items; track item.id ?? $index; let itemIndex = $index) {
2573
+ <div class="prx-rich-editor__nested-node">
2574
+ <div class="prx-rich-editor__nested-actions">
2575
+ <strong>{{ tx('editor.item', 'Item') }} {{ itemIndex + 1 }}</strong>
2576
+ @if (isRemovalPending('nodes.' + $index + '.items', itemIndex)) {
2577
+ <button type="button" (click)="confirmComposeItemRemoval($index, itemIndex)">
2578
+ {{ tx('editor.confirmRemove', 'Confirm remove') }}
2579
+ </button>
2580
+ <button type="button" (click)="cancelRemoval()">
2581
+ {{ tx('editor.cancelRemove', 'Cancel') }}
2582
+ </button>
2583
+ } @else {
2584
+ <button type="button" (click)="requestNestedRemoval('nodes.' + $index + '.items', itemIndex)">
2585
+ {{ tx('editor.removeBlock', 'Remove') }}
2586
+ </button>
2587
+ }
2588
+ </div>
2589
+ <div class="prx-rich-editor__node-grid">
2590
+ <label>
2591
+ <span>{{ tx('editor.blockType', 'Block type') }}</span>
2592
+ <select
2593
+ [ngModel]="item.type"
2594
+ (ngModelChange)="changeComposeItemType($index, itemIndex, $event)"
2595
+ >
2596
+ @for (type of presenterNodeTypes; track type) {
2597
+ <option [value]="type">{{ tx('editor.nodeType.' + type, type) }}</option>
2598
+ }
2599
+ </select>
2600
+ </label>
2601
+ <ng-container
2602
+ *ngTemplateOutlet="presenterFields; context: {
2603
+ node: item,
2604
+ path: '$.nodes[' + $index + '].items[' + itemIndex + ']',
2605
+ setString: setComposeItemStringField.bind(this, $index, itemIndex),
2606
+ setNumber: setComposeItemNumberField.bind(this, $index, itemIndex)
2607
+ }"
2608
+ ></ng-container>
2609
+ </div>
2610
+ </div>
2611
+ }
2612
+ </div>
2613
+ }
2614
+ @case ('card') {
2615
+ <label>
2616
+ <span>{{ tx('editor.field.title', 'Title') }}</span>
2617
+ <input
2618
+ [ngModel]="getStringField(node, 'title')"
2619
+ (ngModelChange)="setStringField($index, 'title', $event)"
2620
+ />
2621
+ </label>
2622
+ <label>
2623
+ <span>{{ tx('editor.field.subtitle', 'Subtitle') }}</span>
2624
+ <input
2625
+ [ngModel]="getStringField(node, 'subtitle')"
2626
+ (ngModelChange)="setStringField($index, 'subtitle', $event)"
2627
+ />
2628
+ </label>
2629
+ <div class="prx-rich-editor__nested-editor">
2630
+ <header class="prx-rich-editor__nested-header">
2631
+ <h5>{{ tx('editor.cardContent', 'Card content') }}</h5>
2632
+ <button type="button" (click)="addCardContentNode($index)">
2633
+ {{ tx('editor.addItem', 'Add item') }}
2634
+ </button>
2635
+ </header>
2636
+ @for (item of node.content; track item.id ?? $index; let itemIndex = $index) {
2637
+ <div class="prx-rich-editor__nested-node">
2638
+ <div class="prx-rich-editor__nested-actions">
2639
+ <strong>{{ tx('editor.item', 'Item') }} {{ itemIndex + 1 }}</strong>
2640
+ @if (isRemovalPending('nodes.' + $index + '.content', itemIndex)) {
2641
+ <button type="button" (click)="confirmCardContentRemoval($index, itemIndex)">
2642
+ {{ tx('editor.confirmRemove', 'Confirm remove') }}
2643
+ </button>
2644
+ <button type="button" (click)="cancelRemoval()">
2645
+ {{ tx('editor.cancelRemove', 'Cancel') }}
2646
+ </button>
2647
+ } @else {
2648
+ <button type="button" (click)="requestNestedRemoval('nodes.' + $index + '.content', itemIndex)">
2649
+ {{ tx('editor.removeBlock', 'Remove') }}
2650
+ </button>
2651
+ }
2652
+ </div>
2653
+ <div class="prx-rich-editor__node-grid">
2654
+ <label>
2655
+ <span>{{ tx('editor.blockType', 'Block type') }}</span>
2656
+ <select
2657
+ [ngModel]="isCardContentEditable(item.type) ? item.type : 'text'"
2658
+ (ngModelChange)="changeCardContentType($index, itemIndex, $event)"
2659
+ [disabled]="!isCardContentEditable(item.type)"
2660
+ >
2661
+ @for (type of cardContentNodeTypes; track type) {
2662
+ <option [value]="type">{{ tx('editor.nodeType.' + type, type) }}</option>
2663
+ }
2664
+ </select>
2665
+ </label>
2666
+ @if (isPresenterNode(item)) {
2667
+ <ng-container
2668
+ *ngTemplateOutlet="presenterFields; context: {
2669
+ node: item,
2670
+ path: '$.nodes[' + $index + '].content[' + itemIndex + ']',
2671
+ setString: setCardContentStringField.bind(this, $index, itemIndex),
2672
+ setNumber: setCardContentNumberField.bind(this, $index, itemIndex)
2673
+ }"
2674
+ ></ng-container>
2675
+ } @else {
2676
+ <p class="prx-rich-editor__node-note">
2677
+ {{ tx('editor.advancedOnly', 'This node type is preserved and can be edited in advanced JSON.') }}
2678
+ </p>
2679
+ }
2680
+ </div>
2681
+ </div>
2682
+ }
2683
+ </div>
2684
+ }
2685
+ @case ('mediaBlock') {
2686
+ <label>
2687
+ <span>{{ tx('editor.field.avatarName', 'Avatar name') }}</span>
2688
+ <input
2689
+ [ngModel]="getMediaBlockAvatarField($index, 'name')"
2690
+ (ngModelChange)="setMediaBlockAvatarField($index, 'name', $event)"
2691
+ />
2692
+ </label>
2693
+ <label>
2694
+ <span>{{ tx('editor.field.title', 'Title') }}</span>
2695
+ <input
2696
+ [ngModel]="getMediaBlockTextField($index, 'title')"
2697
+ (ngModelChange)="setMediaBlockTextField($index, 'title', $event)"
2698
+ />
2699
+ </label>
2700
+ <label>
2701
+ <span>{{ tx('editor.field.subtitle', 'Subtitle') }}</span>
2702
+ <input
2703
+ [ngModel]="getMediaBlockTextField($index, 'subtitle')"
2704
+ (ngModelChange)="setMediaBlockTextField($index, 'subtitle', $event)"
2705
+ />
2706
+ </label>
2707
+ <p class="prx-rich-editor__node-note">
2708
+ {{ tx('editor.mediaBlockAdvancedHelp', 'Meta and trailing content are preserved in advanced JSON.') }}
2709
+ </p>
2710
+ }
2711
+ @case ('timeline') {
2712
+ <label>
2713
+ <span>{{ tx('editor.field.title', 'Title') }}</span>
2714
+ <input
2715
+ [ngModel]="getStringField(node, 'title')"
2716
+ (ngModelChange)="setStringField($index, 'title', $event)"
2717
+ />
2718
+ </label>
2719
+ <label>
2720
+ <span>{{ tx('editor.field.emptyText', 'Empty text') }}</span>
2721
+ <input
2722
+ [ngModel]="getStringField(node, 'emptyText')"
2723
+ (ngModelChange)="setStringField($index, 'emptyText', $event)"
2724
+ />
2725
+ </label>
2726
+ <div class="prx-rich-editor__nested-editor">
2727
+ <header class="prx-rich-editor__nested-header">
2728
+ <h5>{{ tx('editor.timelineItems', 'Timeline items') }}</h5>
2729
+ <button type="button" (click)="addTimelineItem($index)">
2730
+ {{ tx('editor.addItem', 'Add item') }}
2731
+ </button>
2732
+ </header>
2733
+ @for (item of node.items; track item.id ?? $index; let itemIndex = $index) {
2734
+ <div class="prx-rich-editor__nested-node">
2735
+ <div class="prx-rich-editor__nested-actions">
2736
+ <strong>{{ tx('editor.item', 'Item') }} {{ itemIndex + 1 }}</strong>
2737
+ @if (isRemovalPending('nodes.' + $index + '.timelineItems', itemIndex)) {
2738
+ <button type="button" (click)="confirmTimelineItemRemoval($index, itemIndex)">
2739
+ {{ tx('editor.confirmRemove', 'Confirm remove') }}
2740
+ </button>
2741
+ <button type="button" (click)="cancelRemoval()">
2742
+ {{ tx('editor.cancelRemove', 'Cancel') }}
2743
+ </button>
2744
+ } @else {
2745
+ <button type="button" (click)="requestNestedRemoval('nodes.' + $index + '.timelineItems', itemIndex)">
2746
+ {{ tx('editor.removeBlock', 'Remove') }}
2747
+ </button>
2748
+ }
2749
+ </div>
2750
+ <div class="prx-rich-editor__node-grid">
2751
+ <label>
2752
+ <span>{{ tx('editor.field.title', 'Title') }}</span>
2753
+ <input
2754
+ [ngModel]="getTimelineItemField($index, itemIndex, 'title')"
2755
+ (ngModelChange)="setTimelineItemField($index, itemIndex, 'title', $event)"
2756
+ />
2757
+ </label>
2758
+ <label>
2759
+ <span>{{ tx('editor.field.subtitle', 'Subtitle') }}</span>
2760
+ <input
2761
+ [ngModel]="getTimelineItemField($index, itemIndex, 'subtitle')"
2762
+ (ngModelChange)="setTimelineItemField($index, itemIndex, 'subtitle', $event)"
2763
+ />
2764
+ </label>
2765
+ <label>
2766
+ <span>{{ tx('editor.field.badge', 'Badge') }}</span>
2767
+ <input
2768
+ [ngModel]="getTimelineItemField($index, itemIndex, 'badge')"
2769
+ (ngModelChange)="setTimelineItemField($index, itemIndex, 'badge', $event)"
2770
+ />
2771
+ </label>
2772
+ <label>
2773
+ <span>{{ tx('editor.field.icon', 'Icon') }}</span>
2774
+ <input
2775
+ [ngModel]="getTimelineItemField($index, itemIndex, 'icon')"
2776
+ (ngModelChange)="setTimelineItemField($index, itemIndex, 'icon', $event)"
2777
+ [placeholder]="tx('editor.placeholder.icon', 'check_circle')"
2778
+ />
2779
+ </label>
2780
+ </div>
2781
+ </div>
2782
+ }
2783
+ </div>
2784
+ }
2785
+ @case ('preset') {
2786
+ @if (presetOptions.length) {
2787
+ <label>
2788
+ <span>{{ tx('editor.field.preset', 'Preset') }}</span>
2789
+ <select
2790
+ [ngModel]="getPresetSelection(node)"
2791
+ (ngModelChange)="setPresetSelection($index, $event)"
2792
+ >
2793
+ @for (preset of presetOptions; track getPresetOptionValue(preset.ref)) {
2794
+ <option [value]="getPresetOptionValue(preset.ref)">
2795
+ {{ preset.label || preset.ref.presetId }}
2796
+ </option>
2797
+ }
2798
+ </select>
2799
+ </label>
2800
+ } @else {
2801
+ <p class="prx-rich-editor__node-note">
2802
+ {{ tx('editor.noPresets', 'No presets registered') }}.
2803
+ {{ tx('editor.noPresetsHelp', 'Register rich-block presets through PRAXIS_RICH_BLOCK_PRESETS to author preset references visually.') }}
2804
+ </p>
2805
+ }
2806
+ }
2807
+ @default {
2808
+ <p class="prx-rich-editor__node-note">
2809
+ {{
2810
+ tx(
2811
+ 'editor.advancedOnly',
2812
+ 'This node type is preserved and can be edited in advanced JSON.'
2813
+ )
2814
+ }}
2815
+ </p>
2816
+ }
2817
+ }
2818
+ <fieldset class="prx-rich-editor__wide-field prx-rich-editor__field-group">
2819
+ <legend>{{ tx('editor.commonMetadata', 'Common metadata') }}</legend>
2820
+ <label>
2821
+ <span>{{ tx('editor.field.testId', 'Test id') }}</span>
2822
+ <input
2823
+ [ngModel]="getStringField(node, 'testId')"
2824
+ (ngModelChange)="setStringField($index, 'testId', $event)"
2825
+ [placeholder]="tx('editor.placeholder.testId', 'rich-hero-title')"
2826
+ />
2827
+ </label>
2828
+ <label>
2829
+ <span>{{ tx('editor.field.className', 'CSS class') }}</span>
2830
+ <input
2831
+ [ngModel]="getStringField(node, 'className')"
2832
+ (ngModelChange)="setStringField($index, 'className', $event)"
2833
+ [placeholder]="tx('editor.placeholder.className', 'hero-title')"
2834
+ />
2835
+ </label>
2836
+ </fieldset>
2837
+
2838
+ <fieldset class="prx-rich-editor__wide-field prx-rich-editor__field-group">
2839
+ <legend>{{ tx('editor.visibilityRule', 'Visibility rule') }}</legend>
2840
+ <label>
2841
+ <span>{{ tx('editor.field.visibleWhenPath', 'Context path') }}</span>
2842
+ <input
2843
+ [ngModel]="getVisibleWhenPath(node)"
2844
+ (ngModelChange)="setVisibleWhenPath($index, $event)"
2845
+ [placeholder]="tx('editor.placeholder.visibleWhenPath', 'row.active')"
2846
+ />
2847
+ </label>
2848
+ <label>
2849
+ <span>{{ tx('editor.field.visibleWhenValue', 'Expected value') }}</span>
2850
+ <input
2851
+ [ngModel]="getVisibleWhenValue(node)"
2852
+ (ngModelChange)="setVisibleWhenValue($index, $event)"
2853
+ [placeholder]="tx('editor.placeholder.visibleWhenValue', 'true')"
2854
+ />
2855
+ </label>
2856
+ </fieldset>
2857
+
2858
+ <fieldset class="prx-rich-editor__wide-field prx-rich-editor__field-group">
2859
+ <legend>{{ tx('editor.safeStyle', 'Safe style') }}</legend>
2860
+ <label>
2861
+ <span>{{ tx('editor.field.styleName', 'CSS property') }}</span>
2862
+ <input
2863
+ [ngModel]="getFirstStyleName(node)"
2864
+ (ngModelChange)="setFirstStyleName($index, $event)"
2865
+ [placeholder]="tx('editor.placeholder.styleName', 'color')"
2866
+ />
2867
+ </label>
2868
+ <label>
2869
+ <span>{{ tx('editor.field.styleValue', 'CSS value') }}</span>
2870
+ <input
2871
+ [ngModel]="getFirstStyleValue(node)"
2872
+ (ngModelChange)="setFirstStyleValue($index, $event)"
2873
+ [placeholder]="tx('editor.placeholder.styleValue', 'var(--md-sys-color-primary)')"
2874
+ />
2875
+ </label>
2876
+ </fieldset>
2877
+ </div>
2878
+ </article>
2879
+ }
2880
+ </div>
2881
+ } @else {
2882
+ <p class="prx-rich-editor__empty">
2883
+ {{ tx('editor.noBlocks', 'No blocks yet. Add a block to start authoring this document.') }}
2884
+ </p>
2885
+ }
2886
+ </section>
2887
+
891
2888
  <label class="prx-rich-editor__document">
892
- <span>{{ tx('editor.document', 'Document JSON') }}</span>
893
- <textarea
894
- name="rich-content-document"
895
- [(ngModel)]="documentJson"
896
- (ngModelChange)="onDocumentJsonChange()"
897
- [attr.aria-describedby]="
898
- errorMessage
899
- ? 'rich-content-config-error'
900
- : 'rich-content-document-help'
901
- "
902
- [attr.aria-invalid]="!valid"
903
- spellcheck="false"
904
- data-testid="rich-content-document-input"
905
- ></textarea>
2889
+ <span>{{ tx('editor.document', 'Advanced JSON') }}</span>
2890
+ <div class="prx-rich-editor__workbench">
2891
+ <textarea
2892
+ name="rich-content-document"
2893
+ [(ngModel)]="documentJson"
2894
+ (ngModelChange)="onDocumentJsonChange()"
2895
+ [attr.aria-describedby]="
2896
+ errorMessage
2897
+ ? 'rich-content-config-error'
2898
+ : 'rich-content-document-help'
2899
+ "
2900
+ [attr.aria-invalid]="!valid"
2901
+ spellcheck="false"
2902
+ data-testid="rich-content-document-input"
2903
+ ></textarea>
2904
+
2905
+ <aside
2906
+ class="prx-rich-editor__inspector"
2907
+ [attr.aria-label]="tx('editor.inspector', 'Document inspector')"
2908
+ >
2909
+ <section>
2910
+ <h3>{{ tx('editor.overview', 'Overview') }}</h3>
2911
+ <dl>
2912
+ <div>
2913
+ <dt>{{ tx('editor.nodeCount', 'Nodes') }}</dt>
2914
+ <dd>{{ nodeCount }}</dd>
2915
+ </div>
2916
+ <div>
2917
+ <dt>{{ tx('editor.nodeTypes', 'Types') }}</dt>
2918
+ <dd>{{ nodeTypeSummary || tx('editor.none', 'None') }}</dd>
2919
+ </div>
2920
+ </dl>
2921
+ </section>
2922
+
2923
+ @if (parsedDocument) {
2924
+ <section>
2925
+ <h3>{{ tx('editor.preview', 'Preview') }}</h3>
2926
+ <div class="prx-rich-editor__preview">
2927
+ <praxis-rich-content
2928
+ [document]="parsedDocument"
2929
+ [layout]="layout"
2930
+ [rootClassName]="rootClassName"
2931
+ ></praxis-rich-content>
2932
+ </div>
2933
+ </section>
2934
+ }
2935
+ </aside>
2936
+ </div>
906
2937
  </label>
907
2938
 
908
2939
  @if (errorMessage) {
909
- <p
2940
+ <div
910
2941
  id="rich-content-config-error"
911
2942
  class="prx-rich-editor__error"
912
2943
  data-testid="rich-content-config-error"
913
2944
  >
914
- {{ errorMessage }}
915
- </p>
2945
+ <p>{{ errorMessage }}</p>
2946
+ @if (validationIssues.length) {
2947
+ <ul>
2948
+ @for (issue of validationIssues; track issue.path + issue.messageKey) {
2949
+ <li>
2950
+ <code>{{ issue.path }}</code>
2951
+ {{ tx(issue.messageKey, issue.fallback) }}
2952
+ </li>
2953
+ }
2954
+ </ul>
2955
+ }
2956
+ </div>
916
2957
  } @else {
917
2958
  <p id="rich-content-document-help" class="prx-rich-editor__help">
918
2959
  {{
919
2960
  tx(
920
2961
  'editor.documentHelp',
921
- 'Paste or edit a RichContentDocument with kind "praxis.rich-content" and a nodes array.'
2962
+ 'Edit the canonical RichContentDocument. The inspector validates the shape and renders a preview before apply or save.'
922
2963
  )
923
2964
  }}
924
2965
  </p>
@@ -933,11 +2974,11 @@ class PraxisRichContentConfigEditor {
933
2974
  </button>
934
2975
  </div>
935
2976
  </section>
936
- `, isInline: true, styles: [":host{display:block;min-width:0}.prx-rich-editor{display:grid;gap:18px;color:var(--md-sys-color-on-surface, #1f2937)}.prx-rich-editor__header{display:flex;gap:16px;align-items:flex-start;justify-content:space-between}.prx-rich-editor__header h2{margin:0;font-size:1.25rem}.prx-rich-editor__header p{margin:6px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673)}.prx-rich-editor__eyebrow{margin:0 0 4px;text-transform:uppercase;letter-spacing:.08em;font-size:.72rem;color:var(--md-sys-color-primary, #3154e7)}.prx-rich-editor__status{flex:0 0 auto;padding:6px 10px;border-radius:999px;background:color-mix(in srgb,#16a34a 12%,transparent);color:#166534;font-size:.78rem;font-weight:700}.prx-rich-editor__status.invalid{background:color-mix(in srgb,#dc2626 12%,transparent);color:#991b1b}.prx-rich-editor__grid{display:grid;gap:14px;grid-template-columns:minmax(0,180px) minmax(0,1fr)}label,.prx-rich-editor__document{display:grid;gap:7px;font-weight:700}input,select,textarea{box-sizing:border-box;width:100%;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:12px;padding:10px 12px;font:inherit;color:inherit;background:var(--md-sys-color-surface, #fff)}textarea{min-height:360px;resize:vertical;font-family:Cascadia Code,Fira Code,Consolas,monospace;font-size:.86rem;line-height:1.45}.prx-rich-editor__help{margin:-8px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.85rem}.prx-rich-editor__error{margin:0;padding:10px 12px;border-radius:12px;color:#991b1b;background:color-mix(in srgb,#dc2626 9%,transparent)}.prx-rich-editor__actions{display:flex;flex-wrap:wrap;gap:10px}button{border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:999px;padding:8px 14px;background:var(--md-sys-color-surface, #fff);color:var(--md-sys-color-primary, #3154e7);font-weight:700;cursor:pointer}@media(max-width:760px){.prx-rich-editor__header,.prx-rich-editor__grid{grid-template-columns:1fr;display:grid}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { 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.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2977
+ `, isInline: true, styles: [":host{display:block;min-width:0}.prx-rich-editor{display:grid;gap:18px;color:var(--md-sys-color-on-surface, #1f2937)}.prx-rich-editor__header{display:flex;gap:16px;align-items:flex-start;justify-content:space-between}.prx-rich-editor__header h2{margin:0;font-size:1.25rem}.prx-rich-editor__header p{margin:6px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673)}.prx-rich-editor__eyebrow{margin:0 0 4px;text-transform:uppercase;letter-spacing:.08em;font-size:.72rem;color:var(--md-sys-color-primary, #3154e7)}.prx-rich-editor__status{flex:0 0 auto;padding:6px 10px;border-radius:999px;background:color-mix(in srgb,#16a34a 12%,transparent);color:#166534;font-size:.78rem;font-weight:700}.prx-rich-editor__status.invalid{background:color-mix(in srgb,#dc2626 12%,transparent);color:#991b1b}.prx-rich-editor__grid{display:grid;gap:14px;grid-template-columns:minmax(0,180px) minmax(0,1fr)}.prx-rich-editor__blocks{display:grid;gap:14px}.prx-rich-editor__section-header,.prx-rich-editor__node-header{display:flex;gap:16px;align-items:flex-start;justify-content:space-between}.prx-rich-editor__section-header h3,.prx-rich-editor__node-header h4{margin:0}.prx-rich-editor__section-header p,.prx-rich-editor__node-note,.prx-rich-editor__empty{margin:6px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.88rem}.prx-rich-editor__add-block,.prx-rich-editor__node-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end}.prx-rich-editor__add-block label{min-width:180px}.prx-rich-editor__node-list{display:grid;gap:12px}.prx-rich-editor__node-card{display:grid;gap:14px;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:14px;background:var(--md-sys-color-surface-container-lowest, #fff)}.prx-rich-editor__node-eyebrow{margin:0 0 4px;color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.75rem;font-weight:700;text-transform:uppercase}.prx-rich-editor__node-grid{display:grid;gap:12px;grid-template-columns:repeat(2,minmax(0,1fr))}.prx-rich-editor__wide-field,.prx-rich-editor__node-note{grid-column:1 / -1}.prx-rich-editor__field-group,.prx-rich-editor__nested-editor,.prx-rich-editor__nested-node{grid-column:1 / -1;display:grid;gap:12px}.prx-rich-editor__field-group,.prx-rich-editor__nested-node{margin:0;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:12px}.prx-rich-editor__field-group{grid-template-columns:repeat(2,minmax(0,1fr))}.prx-rich-editor__field-group legend{padding:0 4px;font-weight:700}.prx-rich-editor__nested-header,.prx-rich-editor__nested-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between}.prx-rich-editor__node-grid textarea{min-height:96px;font-family:inherit}label,.prx-rich-editor__document{display:grid;gap:7px;font-weight:700}input,select,textarea{box-sizing:border-box;width:100%;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:12px;padding:10px 12px;font:inherit;color:inherit;background:var(--md-sys-color-surface, #fff)}textarea{min-height:360px;resize:vertical;font-family:Cascadia Code,Fira Code,Consolas,monospace;font-size:.86rem;line-height:1.45}.prx-rich-editor__workbench{display:grid;gap:14px;grid-template-columns:minmax(0,1.2fr) minmax(260px,.8fr)}.prx-rich-editor__inspector{display:grid;align-content:start;gap:14px;min-width:0;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:12px;background:var(--md-sys-color-surface-container-lowest, #fff)}.prx-rich-editor__inspector h3{margin:0 0 8px;font-size:.95rem}.prx-rich-editor__inspector dl{display:grid;gap:8px;margin:0}.prx-rich-editor__inspector dl div{display:grid;gap:2px}.prx-rich-editor__inspector dt{color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.78rem;font-weight:700}.prx-rich-editor__inspector dd{margin:0;overflow-wrap:anywhere;font-weight:600}.prx-rich-editor__preview{max-height:260px;overflow:auto;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:12px;background:var(--md-sys-color-surface, #fff)}.prx-rich-editor__help{margin:-8px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.85rem}.prx-rich-editor__error{margin:0;padding:10px 12px;border-radius:8px;color:#991b1b;background:color-mix(in srgb,#dc2626 9%,transparent)}.prx-rich-editor__error p{margin:0}.prx-rich-editor__error ul{display:grid;gap:6px;margin:8px 0 0;padding-inline-start:18px}.prx-rich-editor__error code{margin-right:4px;font-family:Cascadia Code,Fira Code,Consolas,monospace}.prx-rich-editor__actions{display:flex;flex-wrap:wrap;gap:10px}button{border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:8px 14px;background:var(--md-sys-color-surface, #fff);color:var(--md-sys-color-primary, #3154e7);font-weight:700;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.55}@media(max-width:760px){.prx-rich-editor__header,.prx-rich-editor__section-header,.prx-rich-editor__node-header,.prx-rich-editor__grid,.prx-rich-editor__field-group,.prx-rich-editor__node-grid,.prx-rich-editor__workbench{grid-template-columns:1fr;display:grid}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2.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: i2.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i2.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: PraxisRichContent, selector: "praxis-rich-content", inputs: ["document", "nodes", "context", "hostCapabilities", "layout", "rootClassName"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
937
2978
  }
938
2979
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: PraxisRichContentConfigEditor, decorators: [{
939
2980
  type: Component,
940
- args: [{ selector: 'praxis-rich-content-config-editor', standalone: true, imports: [CommonModule, FormsModule], providers: [providePraxisRichContentI18n()], changeDetection: ChangeDetectionStrategy.OnPush, template: `
2981
+ args: [{ selector: 'praxis-rich-content-config-editor', standalone: true, imports: [CommonModule, FormsModule, PraxisRichContent], providers: [providePraxisRichContentI18n()], changeDetection: ChangeDetectionStrategy.OnPush, template: `
941
2982
  <section class="prx-rich-editor" data-testid="rich-content-config-editor">
942
2983
  <header class="prx-rich-editor__header">
943
2984
  <div>
@@ -999,37 +3040,781 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
999
3040
  </label>
1000
3041
  </div>
1001
3042
 
3043
+ <ng-template
3044
+ #presenterFields
3045
+ let-node="node"
3046
+ let-path="path"
3047
+ let-setString="setString"
3048
+ let-setNumber="setNumber"
3049
+ >
3050
+ @switch (node.type) {
3051
+ @case ('text') {
3052
+ <label class="prx-rich-editor__wide-field">
3053
+ <span>{{ tx('editor.field.text', 'Text') }}</span>
3054
+ <textarea
3055
+ [ngModel]="getStringField(node, 'text')"
3056
+ (ngModelChange)="setString('text', $event)"
3057
+ ></textarea>
3058
+ </label>
3059
+ }
3060
+ @case ('badge') {
3061
+ <label>
3062
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
3063
+ <input
3064
+ [ngModel]="getStringField(node, 'label')"
3065
+ (ngModelChange)="setString('label', $event)"
3066
+ />
3067
+ </label>
3068
+ }
3069
+ @case ('icon') {
3070
+ <label>
3071
+ <span>{{ tx('editor.field.icon', 'Icon') }}</span>
3072
+ <input
3073
+ [ngModel]="getStringField(node, 'icon')"
3074
+ (ngModelChange)="setString('icon', $event)"
3075
+ />
3076
+ </label>
3077
+ }
3078
+ @case ('image') {
3079
+ <label>
3080
+ <span>{{ tx('editor.field.src', 'Image URL') }}</span>
3081
+ <input
3082
+ [ngModel]="getStringField(node, 'src')"
3083
+ (ngModelChange)="setString('src', $event)"
3084
+ />
3085
+ </label>
3086
+ <label>
3087
+ <span>{{ tx('editor.field.alt', 'Alternative text') }}</span>
3088
+ <input
3089
+ [ngModel]="getStringField(node, 'alt')"
3090
+ (ngModelChange)="setString('alt', $event)"
3091
+ />
3092
+ </label>
3093
+ }
3094
+ @case ('link') {
3095
+ <label>
3096
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
3097
+ <input
3098
+ [ngModel]="getStringField(node, 'label')"
3099
+ (ngModelChange)="setString('label', $event)"
3100
+ />
3101
+ </label>
3102
+ <label>
3103
+ <span>{{ tx('editor.field.href', 'Link URL') }}</span>
3104
+ <input
3105
+ [ngModel]="getStringField(node, 'href')"
3106
+ (ngModelChange)="setString('href', $event)"
3107
+ />
3108
+ </label>
3109
+ }
3110
+ @case ('metric') {
3111
+ <label>
3112
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
3113
+ <input
3114
+ [ngModel]="getStringField(node, 'label')"
3115
+ (ngModelChange)="setString('label', $event)"
3116
+ />
3117
+ </label>
3118
+ <label>
3119
+ <span>{{ tx('editor.field.valueExpr', 'Value binding') }}</span>
3120
+ <input
3121
+ [ngModel]="getStringField(node, 'valueExpr')"
3122
+ (ngModelChange)="setString('valueExpr', $event)"
3123
+ />
3124
+ </label>
3125
+ }
3126
+ @case ('progress') {
3127
+ <label>
3128
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
3129
+ <input
3130
+ [ngModel]="getStringField(node, 'label')"
3131
+ (ngModelChange)="setString('label', $event)"
3132
+ />
3133
+ </label>
3134
+ <label>
3135
+ <span>{{ tx('editor.field.max', 'Maximum') }}</span>
3136
+ <input
3137
+ type="number"
3138
+ [ngModel]="getNumberField(node, 'max')"
3139
+ (ngModelChange)="setNumber('max', $event)"
3140
+ />
3141
+ </label>
3142
+ }
3143
+ }
3144
+ @if (getIssueMessages(path).length) {
3145
+ <ul class="prx-rich-editor__field-errors">
3146
+ @for (message of getIssueMessages(path); track message) {
3147
+ <li>{{ message }}</li>
3148
+ }
3149
+ </ul>
3150
+ }
3151
+ </ng-template>
3152
+
3153
+ <section class="prx-rich-editor__blocks">
3154
+ <header class="prx-rich-editor__section-header">
3155
+ <div>
3156
+ <h3>{{ tx('editor.blocks', 'Blocks') }}</h3>
3157
+ <p>
3158
+ {{
3159
+ tx(
3160
+ 'editor.blocksHelp',
3161
+ 'Create and edit common top-level rich-content blocks. Use JSON for advanced nested structures.'
3162
+ )
3163
+ }}
3164
+ </p>
3165
+ </div>
3166
+ <div class="prx-rich-editor__add-block">
3167
+ <label>
3168
+ <span>{{ tx('editor.blockType', 'Block type') }}</span>
3169
+ <select
3170
+ name="rich-content-new-node-type"
3171
+ [(ngModel)]="newNodeType"
3172
+ data-testid="rich-content-new-node-type"
3173
+ >
3174
+ @for (type of editableNodeTypes; track type) {
3175
+ <option [value]="type">{{ tx('editor.nodeType.' + type, type) }}</option>
3176
+ }
3177
+ </select>
3178
+ </label>
3179
+ <button
3180
+ type="button"
3181
+ (click)="addTopLevelNode()"
3182
+ data-testid="rich-content-add-node"
3183
+ >
3184
+ {{ tx('editor.addBlock', 'Add block') }}
3185
+ </button>
3186
+ </div>
3187
+ </header>
3188
+
3189
+ @if (parsedDocument?.nodes?.length) {
3190
+ <div class="prx-rich-editor__node-list">
3191
+ @for (node of parsedDocument?.nodes ?? []; track node.id ?? $index) {
3192
+ <article
3193
+ class="prx-rich-editor__node-card"
3194
+ [attr.data-rich-editor-node-type]="node.type"
3195
+ >
3196
+ <header class="prx-rich-editor__node-header">
3197
+ <div>
3198
+ <p class="prx-rich-editor__node-eyebrow">
3199
+ {{ tx('editor.block', 'Block') }} {{ $index + 1 }}
3200
+ </p>
3201
+ <h4>{{ tx('editor.nodeType.' + node.type, node.type) }}</h4>
3202
+ </div>
3203
+ <div class="prx-rich-editor__node-actions">
3204
+ <button type="button" (click)="moveTopLevelNode($index, -1)" [disabled]="$index === 0">
3205
+ {{ tx('editor.moveUp', 'Move up') }}
3206
+ </button>
3207
+ <button
3208
+ type="button"
3209
+ (click)="moveTopLevelNode($index, 1)"
3210
+ [disabled]="$index === (parsedDocument?.nodes?.length ?? 0) - 1"
3211
+ >
3212
+ {{ tx('editor.moveDown', 'Move down') }}
3213
+ </button>
3214
+ @if (isRemovalPending('nodes', $index)) {
3215
+ <button type="button" (click)="confirmTopLevelNodeRemoval($index)">
3216
+ {{ tx('editor.confirmRemove', 'Confirm remove') }}
3217
+ </button>
3218
+ <button type="button" (click)="cancelRemoval()">
3219
+ {{ tx('editor.cancelRemove', 'Cancel') }}
3220
+ </button>
3221
+ } @else {
3222
+ <button type="button" (click)="requestTopLevelNodeRemoval($index)">
3223
+ {{ tx('editor.removeBlock', 'Remove') }}
3224
+ </button>
3225
+ }
3226
+ </div>
3227
+ </header>
3228
+
3229
+ @if (getIssueMessages('$.nodes[' + $index + ']').length) {
3230
+ <ul class="prx-rich-editor__field-errors">
3231
+ @for (message of getIssueMessages('$.nodes[' + $index + ']'); track message) {
3232
+ <li>{{ message }}</li>
3233
+ }
3234
+ </ul>
3235
+ }
3236
+
3237
+ <div class="prx-rich-editor__node-grid">
3238
+ <label>
3239
+ <span>{{ tx('editor.blockType', 'Block type') }}</span>
3240
+ <select
3241
+ [ngModel]="isEditableNodeType(node.type) ? node.type : 'text'"
3242
+ (ngModelChange)="changeTopLevelNodeType($index, $event)"
3243
+ [disabled]="!isEditableNodeType(node.type)"
3244
+ >
3245
+ @for (type of editableNodeTypes; track type) {
3246
+ <option [value]="type">{{ tx('editor.nodeType.' + type, type) }}</option>
3247
+ }
3248
+ </select>
3249
+ </label>
3250
+
3251
+ <label>
3252
+ <span>{{ tx('editor.nodeId', 'Stable id') }}</span>
3253
+ <input
3254
+ [ngModel]="getStringField(node, 'id')"
3255
+ (ngModelChange)="setStringField($index, 'id', $event)"
3256
+ [placeholder]="tx('editor.nodeIdPlaceholder', 'Example: hero-title')"
3257
+ />
3258
+ </label>
3259
+
3260
+ @switch (node.type) {
3261
+ @case ('text') {
3262
+ <label class="prx-rich-editor__wide-field">
3263
+ <span>{{ tx('editor.field.text', 'Text') }}</span>
3264
+ <textarea
3265
+ [ngModel]="getStringField(node, 'text')"
3266
+ (ngModelChange)="setStringField($index, 'text', $event)"
3267
+ ></textarea>
3268
+ @if (getIssueMessages('$.nodes[' + $index + '].text').length) {
3269
+ <small class="prx-rich-editor__field-error">
3270
+ {{ getIssueMessages('$.nodes[' + $index + '].text').join(' ') }}
3271
+ </small>
3272
+ }
3273
+ </label>
3274
+ <label>
3275
+ <span>{{ tx('editor.field.textExpr', 'Text binding') }}</span>
3276
+ <input
3277
+ [ngModel]="getStringField(node, 'textExpr')"
3278
+ (ngModelChange)="setStringField($index, 'textExpr', $event)"
3279
+ [placeholder]="tx('editor.placeholder.textExpr', 'row.name')"
3280
+ />
3281
+ @if (getIssueMessages('$.nodes[' + $index + '].textExpr').length) {
3282
+ <small class="prx-rich-editor__field-error">
3283
+ {{ getIssueMessages('$.nodes[' + $index + '].textExpr').join(' ') }}
3284
+ </small>
3285
+ }
3286
+ </label>
3287
+ }
3288
+ @case ('badge') {
3289
+ <label>
3290
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
3291
+ <input
3292
+ [ngModel]="getStringField(node, 'label')"
3293
+ (ngModelChange)="setStringField($index, 'label', $event)"
3294
+ />
3295
+ </label>
3296
+ <label>
3297
+ <span>{{ tx('editor.field.labelExpr', 'Label binding') }}</span>
3298
+ <input
3299
+ [ngModel]="getStringField(node, 'labelExpr')"
3300
+ (ngModelChange)="setStringField($index, 'labelExpr', $event)"
3301
+ [placeholder]="tx('editor.placeholder.labelExpr', 'row.status')"
3302
+ />
3303
+ </label>
3304
+ <label>
3305
+ <span>{{ tx('editor.field.icon', 'Icon') }}</span>
3306
+ <input
3307
+ [ngModel]="getStringField(node, 'icon')"
3308
+ (ngModelChange)="setStringField($index, 'icon', $event)"
3309
+ [placeholder]="tx('editor.placeholder.icon', 'check_circle')"
3310
+ />
3311
+ </label>
3312
+ }
3313
+ @case ('icon') {
3314
+ <label>
3315
+ <span>{{ tx('editor.field.icon', 'Icon') }}</span>
3316
+ <input
3317
+ [ngModel]="getStringField(node, 'icon')"
3318
+ (ngModelChange)="setStringField($index, 'icon', $event)"
3319
+ [placeholder]="tx('editor.placeholder.icon', 'check_circle')"
3320
+ />
3321
+ </label>
3322
+ <label>
3323
+ <span>{{ tx('editor.field.ariaLabel', 'Accessible label') }}</span>
3324
+ <input
3325
+ [ngModel]="getStringField(node, 'ariaLabel')"
3326
+ (ngModelChange)="setStringField($index, 'ariaLabel', $event)"
3327
+ />
3328
+ </label>
3329
+ }
3330
+ @case ('image') {
3331
+ <label>
3332
+ <span>{{ tx('editor.field.src', 'Image URL') }}</span>
3333
+ <input
3334
+ [ngModel]="getStringField(node, 'src')"
3335
+ (ngModelChange)="setStringField($index, 'src', $event)"
3336
+ />
3337
+ </label>
3338
+ <label>
3339
+ <span>{{ tx('editor.field.alt', 'Alternative text') }}</span>
3340
+ <input
3341
+ [ngModel]="getStringField(node, 'alt')"
3342
+ (ngModelChange)="setStringField($index, 'alt', $event)"
3343
+ />
3344
+ </label>
3345
+ }
3346
+ @case ('metric') {
3347
+ <label>
3348
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
3349
+ <input
3350
+ [ngModel]="getStringField(node, 'label')"
3351
+ (ngModelChange)="setStringField($index, 'label', $event)"
3352
+ />
3353
+ </label>
3354
+ <label>
3355
+ <span>{{ tx('editor.field.valueExpr', 'Value binding') }}</span>
3356
+ <input
3357
+ [ngModel]="getStringField(node, 'valueExpr')"
3358
+ (ngModelChange)="setStringField($index, 'valueExpr', $event)"
3359
+ [placeholder]="tx('editor.placeholder.valueExpr', 'row.total')"
3360
+ />
3361
+ </label>
3362
+ <label>
3363
+ <span>{{ tx('editor.field.captionExpr', 'Caption binding') }}</span>
3364
+ <input
3365
+ [ngModel]="getStringField(node, 'captionExpr')"
3366
+ (ngModelChange)="setStringField($index, 'captionExpr', $event)"
3367
+ [placeholder]="tx('editor.placeholder.captionExpr', 'row.caption')"
3368
+ />
3369
+ </label>
3370
+ }
3371
+ @case ('progress') {
3372
+ <label>
3373
+ <span>{{ tx('editor.field.label', 'Label') }}</span>
3374
+ <input
3375
+ [ngModel]="getStringField(node, 'label')"
3376
+ (ngModelChange)="setStringField($index, 'label', $event)"
3377
+ />
3378
+ </label>
3379
+ <label>
3380
+ <span>{{ tx('editor.field.valueExpr', 'Value binding') }}</span>
3381
+ <input
3382
+ [ngModel]="getStringField(node, 'valueExpr')"
3383
+ (ngModelChange)="setStringField($index, 'valueExpr', $event)"
3384
+ [placeholder]="tx('editor.placeholder.progressExpr', 'row.progress')"
3385
+ />
3386
+ </label>
3387
+ <label>
3388
+ <span>{{ tx('editor.field.max', 'Maximum') }}</span>
3389
+ <input
3390
+ type="number"
3391
+ [ngModel]="getNumberField(node, 'max')"
3392
+ (ngModelChange)="setNumberField($index, 'max', $event)"
3393
+ />
3394
+ </label>
3395
+ }
3396
+ @case ('compose') {
3397
+ <label>
3398
+ <span>{{ tx('editor.field.direction', 'Direction') }}</span>
3399
+ <select
3400
+ [ngModel]="getStringField(node, 'direction') || 'row'"
3401
+ (ngModelChange)="setStringField($index, 'direction', $event)"
3402
+ >
3403
+ <option value="row">{{ tx('editor.direction.row', 'Row') }}</option>
3404
+ <option value="column">{{ tx('editor.direction.column', 'Column') }}</option>
3405
+ </select>
3406
+ </label>
3407
+ <label>
3408
+ <span>{{ tx('editor.field.gap', 'Gap') }}</span>
3409
+ <select
3410
+ [ngModel]="getStringField(node, 'gap') || 'md'"
3411
+ (ngModelChange)="setStringField($index, 'gap', $event)"
3412
+ >
3413
+ <option value="xs">xs</option>
3414
+ <option value="sm">sm</option>
3415
+ <option value="md">md</option>
3416
+ <option value="lg">lg</option>
3417
+ <option value="xl">xl</option>
3418
+ </select>
3419
+ </label>
3420
+ <div class="prx-rich-editor__nested-editor">
3421
+ <header class="prx-rich-editor__nested-header">
3422
+ <h5>{{ tx('editor.composeItems', 'Compose items') }}</h5>
3423
+ <button type="button" (click)="addComposeItem($index)">
3424
+ {{ tx('editor.addItem', 'Add item') }}
3425
+ </button>
3426
+ </header>
3427
+ @for (item of node.items; track item.id ?? $index; let itemIndex = $index) {
3428
+ <div class="prx-rich-editor__nested-node">
3429
+ <div class="prx-rich-editor__nested-actions">
3430
+ <strong>{{ tx('editor.item', 'Item') }} {{ itemIndex + 1 }}</strong>
3431
+ @if (isRemovalPending('nodes.' + $index + '.items', itemIndex)) {
3432
+ <button type="button" (click)="confirmComposeItemRemoval($index, itemIndex)">
3433
+ {{ tx('editor.confirmRemove', 'Confirm remove') }}
3434
+ </button>
3435
+ <button type="button" (click)="cancelRemoval()">
3436
+ {{ tx('editor.cancelRemove', 'Cancel') }}
3437
+ </button>
3438
+ } @else {
3439
+ <button type="button" (click)="requestNestedRemoval('nodes.' + $index + '.items', itemIndex)">
3440
+ {{ tx('editor.removeBlock', 'Remove') }}
3441
+ </button>
3442
+ }
3443
+ </div>
3444
+ <div class="prx-rich-editor__node-grid">
3445
+ <label>
3446
+ <span>{{ tx('editor.blockType', 'Block type') }}</span>
3447
+ <select
3448
+ [ngModel]="item.type"
3449
+ (ngModelChange)="changeComposeItemType($index, itemIndex, $event)"
3450
+ >
3451
+ @for (type of presenterNodeTypes; track type) {
3452
+ <option [value]="type">{{ tx('editor.nodeType.' + type, type) }}</option>
3453
+ }
3454
+ </select>
3455
+ </label>
3456
+ <ng-container
3457
+ *ngTemplateOutlet="presenterFields; context: {
3458
+ node: item,
3459
+ path: '$.nodes[' + $index + '].items[' + itemIndex + ']',
3460
+ setString: setComposeItemStringField.bind(this, $index, itemIndex),
3461
+ setNumber: setComposeItemNumberField.bind(this, $index, itemIndex)
3462
+ }"
3463
+ ></ng-container>
3464
+ </div>
3465
+ </div>
3466
+ }
3467
+ </div>
3468
+ }
3469
+ @case ('card') {
3470
+ <label>
3471
+ <span>{{ tx('editor.field.title', 'Title') }}</span>
3472
+ <input
3473
+ [ngModel]="getStringField(node, 'title')"
3474
+ (ngModelChange)="setStringField($index, 'title', $event)"
3475
+ />
3476
+ </label>
3477
+ <label>
3478
+ <span>{{ tx('editor.field.subtitle', 'Subtitle') }}</span>
3479
+ <input
3480
+ [ngModel]="getStringField(node, 'subtitle')"
3481
+ (ngModelChange)="setStringField($index, 'subtitle', $event)"
3482
+ />
3483
+ </label>
3484
+ <div class="prx-rich-editor__nested-editor">
3485
+ <header class="prx-rich-editor__nested-header">
3486
+ <h5>{{ tx('editor.cardContent', 'Card content') }}</h5>
3487
+ <button type="button" (click)="addCardContentNode($index)">
3488
+ {{ tx('editor.addItem', 'Add item') }}
3489
+ </button>
3490
+ </header>
3491
+ @for (item of node.content; track item.id ?? $index; let itemIndex = $index) {
3492
+ <div class="prx-rich-editor__nested-node">
3493
+ <div class="prx-rich-editor__nested-actions">
3494
+ <strong>{{ tx('editor.item', 'Item') }} {{ itemIndex + 1 }}</strong>
3495
+ @if (isRemovalPending('nodes.' + $index + '.content', itemIndex)) {
3496
+ <button type="button" (click)="confirmCardContentRemoval($index, itemIndex)">
3497
+ {{ tx('editor.confirmRemove', 'Confirm remove') }}
3498
+ </button>
3499
+ <button type="button" (click)="cancelRemoval()">
3500
+ {{ tx('editor.cancelRemove', 'Cancel') }}
3501
+ </button>
3502
+ } @else {
3503
+ <button type="button" (click)="requestNestedRemoval('nodes.' + $index + '.content', itemIndex)">
3504
+ {{ tx('editor.removeBlock', 'Remove') }}
3505
+ </button>
3506
+ }
3507
+ </div>
3508
+ <div class="prx-rich-editor__node-grid">
3509
+ <label>
3510
+ <span>{{ tx('editor.blockType', 'Block type') }}</span>
3511
+ <select
3512
+ [ngModel]="isCardContentEditable(item.type) ? item.type : 'text'"
3513
+ (ngModelChange)="changeCardContentType($index, itemIndex, $event)"
3514
+ [disabled]="!isCardContentEditable(item.type)"
3515
+ >
3516
+ @for (type of cardContentNodeTypes; track type) {
3517
+ <option [value]="type">{{ tx('editor.nodeType.' + type, type) }}</option>
3518
+ }
3519
+ </select>
3520
+ </label>
3521
+ @if (isPresenterNode(item)) {
3522
+ <ng-container
3523
+ *ngTemplateOutlet="presenterFields; context: {
3524
+ node: item,
3525
+ path: '$.nodes[' + $index + '].content[' + itemIndex + ']',
3526
+ setString: setCardContentStringField.bind(this, $index, itemIndex),
3527
+ setNumber: setCardContentNumberField.bind(this, $index, itemIndex)
3528
+ }"
3529
+ ></ng-container>
3530
+ } @else {
3531
+ <p class="prx-rich-editor__node-note">
3532
+ {{ tx('editor.advancedOnly', 'This node type is preserved and can be edited in advanced JSON.') }}
3533
+ </p>
3534
+ }
3535
+ </div>
3536
+ </div>
3537
+ }
3538
+ </div>
3539
+ }
3540
+ @case ('mediaBlock') {
3541
+ <label>
3542
+ <span>{{ tx('editor.field.avatarName', 'Avatar name') }}</span>
3543
+ <input
3544
+ [ngModel]="getMediaBlockAvatarField($index, 'name')"
3545
+ (ngModelChange)="setMediaBlockAvatarField($index, 'name', $event)"
3546
+ />
3547
+ </label>
3548
+ <label>
3549
+ <span>{{ tx('editor.field.title', 'Title') }}</span>
3550
+ <input
3551
+ [ngModel]="getMediaBlockTextField($index, 'title')"
3552
+ (ngModelChange)="setMediaBlockTextField($index, 'title', $event)"
3553
+ />
3554
+ </label>
3555
+ <label>
3556
+ <span>{{ tx('editor.field.subtitle', 'Subtitle') }}</span>
3557
+ <input
3558
+ [ngModel]="getMediaBlockTextField($index, 'subtitle')"
3559
+ (ngModelChange)="setMediaBlockTextField($index, 'subtitle', $event)"
3560
+ />
3561
+ </label>
3562
+ <p class="prx-rich-editor__node-note">
3563
+ {{ tx('editor.mediaBlockAdvancedHelp', 'Meta and trailing content are preserved in advanced JSON.') }}
3564
+ </p>
3565
+ }
3566
+ @case ('timeline') {
3567
+ <label>
3568
+ <span>{{ tx('editor.field.title', 'Title') }}</span>
3569
+ <input
3570
+ [ngModel]="getStringField(node, 'title')"
3571
+ (ngModelChange)="setStringField($index, 'title', $event)"
3572
+ />
3573
+ </label>
3574
+ <label>
3575
+ <span>{{ tx('editor.field.emptyText', 'Empty text') }}</span>
3576
+ <input
3577
+ [ngModel]="getStringField(node, 'emptyText')"
3578
+ (ngModelChange)="setStringField($index, 'emptyText', $event)"
3579
+ />
3580
+ </label>
3581
+ <div class="prx-rich-editor__nested-editor">
3582
+ <header class="prx-rich-editor__nested-header">
3583
+ <h5>{{ tx('editor.timelineItems', 'Timeline items') }}</h5>
3584
+ <button type="button" (click)="addTimelineItem($index)">
3585
+ {{ tx('editor.addItem', 'Add item') }}
3586
+ </button>
3587
+ </header>
3588
+ @for (item of node.items; track item.id ?? $index; let itemIndex = $index) {
3589
+ <div class="prx-rich-editor__nested-node">
3590
+ <div class="prx-rich-editor__nested-actions">
3591
+ <strong>{{ tx('editor.item', 'Item') }} {{ itemIndex + 1 }}</strong>
3592
+ @if (isRemovalPending('nodes.' + $index + '.timelineItems', itemIndex)) {
3593
+ <button type="button" (click)="confirmTimelineItemRemoval($index, itemIndex)">
3594
+ {{ tx('editor.confirmRemove', 'Confirm remove') }}
3595
+ </button>
3596
+ <button type="button" (click)="cancelRemoval()">
3597
+ {{ tx('editor.cancelRemove', 'Cancel') }}
3598
+ </button>
3599
+ } @else {
3600
+ <button type="button" (click)="requestNestedRemoval('nodes.' + $index + '.timelineItems', itemIndex)">
3601
+ {{ tx('editor.removeBlock', 'Remove') }}
3602
+ </button>
3603
+ }
3604
+ </div>
3605
+ <div class="prx-rich-editor__node-grid">
3606
+ <label>
3607
+ <span>{{ tx('editor.field.title', 'Title') }}</span>
3608
+ <input
3609
+ [ngModel]="getTimelineItemField($index, itemIndex, 'title')"
3610
+ (ngModelChange)="setTimelineItemField($index, itemIndex, 'title', $event)"
3611
+ />
3612
+ </label>
3613
+ <label>
3614
+ <span>{{ tx('editor.field.subtitle', 'Subtitle') }}</span>
3615
+ <input
3616
+ [ngModel]="getTimelineItemField($index, itemIndex, 'subtitle')"
3617
+ (ngModelChange)="setTimelineItemField($index, itemIndex, 'subtitle', $event)"
3618
+ />
3619
+ </label>
3620
+ <label>
3621
+ <span>{{ tx('editor.field.badge', 'Badge') }}</span>
3622
+ <input
3623
+ [ngModel]="getTimelineItemField($index, itemIndex, 'badge')"
3624
+ (ngModelChange)="setTimelineItemField($index, itemIndex, 'badge', $event)"
3625
+ />
3626
+ </label>
3627
+ <label>
3628
+ <span>{{ tx('editor.field.icon', 'Icon') }}</span>
3629
+ <input
3630
+ [ngModel]="getTimelineItemField($index, itemIndex, 'icon')"
3631
+ (ngModelChange)="setTimelineItemField($index, itemIndex, 'icon', $event)"
3632
+ [placeholder]="tx('editor.placeholder.icon', 'check_circle')"
3633
+ />
3634
+ </label>
3635
+ </div>
3636
+ </div>
3637
+ }
3638
+ </div>
3639
+ }
3640
+ @case ('preset') {
3641
+ @if (presetOptions.length) {
3642
+ <label>
3643
+ <span>{{ tx('editor.field.preset', 'Preset') }}</span>
3644
+ <select
3645
+ [ngModel]="getPresetSelection(node)"
3646
+ (ngModelChange)="setPresetSelection($index, $event)"
3647
+ >
3648
+ @for (preset of presetOptions; track getPresetOptionValue(preset.ref)) {
3649
+ <option [value]="getPresetOptionValue(preset.ref)">
3650
+ {{ preset.label || preset.ref.presetId }}
3651
+ </option>
3652
+ }
3653
+ </select>
3654
+ </label>
3655
+ } @else {
3656
+ <p class="prx-rich-editor__node-note">
3657
+ {{ tx('editor.noPresets', 'No presets registered') }}.
3658
+ {{ tx('editor.noPresetsHelp', 'Register rich-block presets through PRAXIS_RICH_BLOCK_PRESETS to author preset references visually.') }}
3659
+ </p>
3660
+ }
3661
+ }
3662
+ @default {
3663
+ <p class="prx-rich-editor__node-note">
3664
+ {{
3665
+ tx(
3666
+ 'editor.advancedOnly',
3667
+ 'This node type is preserved and can be edited in advanced JSON.'
3668
+ )
3669
+ }}
3670
+ </p>
3671
+ }
3672
+ }
3673
+ <fieldset class="prx-rich-editor__wide-field prx-rich-editor__field-group">
3674
+ <legend>{{ tx('editor.commonMetadata', 'Common metadata') }}</legend>
3675
+ <label>
3676
+ <span>{{ tx('editor.field.testId', 'Test id') }}</span>
3677
+ <input
3678
+ [ngModel]="getStringField(node, 'testId')"
3679
+ (ngModelChange)="setStringField($index, 'testId', $event)"
3680
+ [placeholder]="tx('editor.placeholder.testId', 'rich-hero-title')"
3681
+ />
3682
+ </label>
3683
+ <label>
3684
+ <span>{{ tx('editor.field.className', 'CSS class') }}</span>
3685
+ <input
3686
+ [ngModel]="getStringField(node, 'className')"
3687
+ (ngModelChange)="setStringField($index, 'className', $event)"
3688
+ [placeholder]="tx('editor.placeholder.className', 'hero-title')"
3689
+ />
3690
+ </label>
3691
+ </fieldset>
3692
+
3693
+ <fieldset class="prx-rich-editor__wide-field prx-rich-editor__field-group">
3694
+ <legend>{{ tx('editor.visibilityRule', 'Visibility rule') }}</legend>
3695
+ <label>
3696
+ <span>{{ tx('editor.field.visibleWhenPath', 'Context path') }}</span>
3697
+ <input
3698
+ [ngModel]="getVisibleWhenPath(node)"
3699
+ (ngModelChange)="setVisibleWhenPath($index, $event)"
3700
+ [placeholder]="tx('editor.placeholder.visibleWhenPath', 'row.active')"
3701
+ />
3702
+ </label>
3703
+ <label>
3704
+ <span>{{ tx('editor.field.visibleWhenValue', 'Expected value') }}</span>
3705
+ <input
3706
+ [ngModel]="getVisibleWhenValue(node)"
3707
+ (ngModelChange)="setVisibleWhenValue($index, $event)"
3708
+ [placeholder]="tx('editor.placeholder.visibleWhenValue', 'true')"
3709
+ />
3710
+ </label>
3711
+ </fieldset>
3712
+
3713
+ <fieldset class="prx-rich-editor__wide-field prx-rich-editor__field-group">
3714
+ <legend>{{ tx('editor.safeStyle', 'Safe style') }}</legend>
3715
+ <label>
3716
+ <span>{{ tx('editor.field.styleName', 'CSS property') }}</span>
3717
+ <input
3718
+ [ngModel]="getFirstStyleName(node)"
3719
+ (ngModelChange)="setFirstStyleName($index, $event)"
3720
+ [placeholder]="tx('editor.placeholder.styleName', 'color')"
3721
+ />
3722
+ </label>
3723
+ <label>
3724
+ <span>{{ tx('editor.field.styleValue', 'CSS value') }}</span>
3725
+ <input
3726
+ [ngModel]="getFirstStyleValue(node)"
3727
+ (ngModelChange)="setFirstStyleValue($index, $event)"
3728
+ [placeholder]="tx('editor.placeholder.styleValue', 'var(--md-sys-color-primary)')"
3729
+ />
3730
+ </label>
3731
+ </fieldset>
3732
+ </div>
3733
+ </article>
3734
+ }
3735
+ </div>
3736
+ } @else {
3737
+ <p class="prx-rich-editor__empty">
3738
+ {{ tx('editor.noBlocks', 'No blocks yet. Add a block to start authoring this document.') }}
3739
+ </p>
3740
+ }
3741
+ </section>
3742
+
1002
3743
  <label class="prx-rich-editor__document">
1003
- <span>{{ tx('editor.document', 'Document JSON') }}</span>
1004
- <textarea
1005
- name="rich-content-document"
1006
- [(ngModel)]="documentJson"
1007
- (ngModelChange)="onDocumentJsonChange()"
1008
- [attr.aria-describedby]="
1009
- errorMessage
1010
- ? 'rich-content-config-error'
1011
- : 'rich-content-document-help'
1012
- "
1013
- [attr.aria-invalid]="!valid"
1014
- spellcheck="false"
1015
- data-testid="rich-content-document-input"
1016
- ></textarea>
3744
+ <span>{{ tx('editor.document', 'Advanced JSON') }}</span>
3745
+ <div class="prx-rich-editor__workbench">
3746
+ <textarea
3747
+ name="rich-content-document"
3748
+ [(ngModel)]="documentJson"
3749
+ (ngModelChange)="onDocumentJsonChange()"
3750
+ [attr.aria-describedby]="
3751
+ errorMessage
3752
+ ? 'rich-content-config-error'
3753
+ : 'rich-content-document-help'
3754
+ "
3755
+ [attr.aria-invalid]="!valid"
3756
+ spellcheck="false"
3757
+ data-testid="rich-content-document-input"
3758
+ ></textarea>
3759
+
3760
+ <aside
3761
+ class="prx-rich-editor__inspector"
3762
+ [attr.aria-label]="tx('editor.inspector', 'Document inspector')"
3763
+ >
3764
+ <section>
3765
+ <h3>{{ tx('editor.overview', 'Overview') }}</h3>
3766
+ <dl>
3767
+ <div>
3768
+ <dt>{{ tx('editor.nodeCount', 'Nodes') }}</dt>
3769
+ <dd>{{ nodeCount }}</dd>
3770
+ </div>
3771
+ <div>
3772
+ <dt>{{ tx('editor.nodeTypes', 'Types') }}</dt>
3773
+ <dd>{{ nodeTypeSummary || tx('editor.none', 'None') }}</dd>
3774
+ </div>
3775
+ </dl>
3776
+ </section>
3777
+
3778
+ @if (parsedDocument) {
3779
+ <section>
3780
+ <h3>{{ tx('editor.preview', 'Preview') }}</h3>
3781
+ <div class="prx-rich-editor__preview">
3782
+ <praxis-rich-content
3783
+ [document]="parsedDocument"
3784
+ [layout]="layout"
3785
+ [rootClassName]="rootClassName"
3786
+ ></praxis-rich-content>
3787
+ </div>
3788
+ </section>
3789
+ }
3790
+ </aside>
3791
+ </div>
1017
3792
  </label>
1018
3793
 
1019
3794
  @if (errorMessage) {
1020
- <p
3795
+ <div
1021
3796
  id="rich-content-config-error"
1022
3797
  class="prx-rich-editor__error"
1023
3798
  data-testid="rich-content-config-error"
1024
3799
  >
1025
- {{ errorMessage }}
1026
- </p>
3800
+ <p>{{ errorMessage }}</p>
3801
+ @if (validationIssues.length) {
3802
+ <ul>
3803
+ @for (issue of validationIssues; track issue.path + issue.messageKey) {
3804
+ <li>
3805
+ <code>{{ issue.path }}</code>
3806
+ {{ tx(issue.messageKey, issue.fallback) }}
3807
+ </li>
3808
+ }
3809
+ </ul>
3810
+ }
3811
+ </div>
1027
3812
  } @else {
1028
3813
  <p id="rich-content-document-help" class="prx-rich-editor__help">
1029
3814
  {{
1030
3815
  tx(
1031
3816
  'editor.documentHelp',
1032
- 'Paste or edit a RichContentDocument with kind "praxis.rich-content" and a nodes array.'
3817
+ 'Edit the canonical RichContentDocument. The inspector validates the shape and renders a preview before apply or save.'
1033
3818
  )
1034
3819
  }}
1035
3820
  </p>
@@ -1044,7 +3829,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
1044
3829
  </button>
1045
3830
  </div>
1046
3831
  </section>
1047
- `, styles: [":host{display:block;min-width:0}.prx-rich-editor{display:grid;gap:18px;color:var(--md-sys-color-on-surface, #1f2937)}.prx-rich-editor__header{display:flex;gap:16px;align-items:flex-start;justify-content:space-between}.prx-rich-editor__header h2{margin:0;font-size:1.25rem}.prx-rich-editor__header p{margin:6px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673)}.prx-rich-editor__eyebrow{margin:0 0 4px;text-transform:uppercase;letter-spacing:.08em;font-size:.72rem;color:var(--md-sys-color-primary, #3154e7)}.prx-rich-editor__status{flex:0 0 auto;padding:6px 10px;border-radius:999px;background:color-mix(in srgb,#16a34a 12%,transparent);color:#166534;font-size:.78rem;font-weight:700}.prx-rich-editor__status.invalid{background:color-mix(in srgb,#dc2626 12%,transparent);color:#991b1b}.prx-rich-editor__grid{display:grid;gap:14px;grid-template-columns:minmax(0,180px) minmax(0,1fr)}label,.prx-rich-editor__document{display:grid;gap:7px;font-weight:700}input,select,textarea{box-sizing:border-box;width:100%;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:12px;padding:10px 12px;font:inherit;color:inherit;background:var(--md-sys-color-surface, #fff)}textarea{min-height:360px;resize:vertical;font-family:Cascadia Code,Fira Code,Consolas,monospace;font-size:.86rem;line-height:1.45}.prx-rich-editor__help{margin:-8px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.85rem}.prx-rich-editor__error{margin:0;padding:10px 12px;border-radius:12px;color:#991b1b;background:color-mix(in srgb,#dc2626 9%,transparent)}.prx-rich-editor__actions{display:flex;flex-wrap:wrap;gap:10px}button{border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:999px;padding:8px 14px;background:var(--md-sys-color-surface, #fff);color:var(--md-sys-color-primary, #3154e7);font-weight:700;cursor:pointer}@media(max-width:760px){.prx-rich-editor__header,.prx-rich-editor__grid{grid-template-columns:1fr;display:grid}}\n"] }]
3832
+ `, styles: [":host{display:block;min-width:0}.prx-rich-editor{display:grid;gap:18px;color:var(--md-sys-color-on-surface, #1f2937)}.prx-rich-editor__header{display:flex;gap:16px;align-items:flex-start;justify-content:space-between}.prx-rich-editor__header h2{margin:0;font-size:1.25rem}.prx-rich-editor__header p{margin:6px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673)}.prx-rich-editor__eyebrow{margin:0 0 4px;text-transform:uppercase;letter-spacing:.08em;font-size:.72rem;color:var(--md-sys-color-primary, #3154e7)}.prx-rich-editor__status{flex:0 0 auto;padding:6px 10px;border-radius:999px;background:color-mix(in srgb,#16a34a 12%,transparent);color:#166534;font-size:.78rem;font-weight:700}.prx-rich-editor__status.invalid{background:color-mix(in srgb,#dc2626 12%,transparent);color:#991b1b}.prx-rich-editor__grid{display:grid;gap:14px;grid-template-columns:minmax(0,180px) minmax(0,1fr)}.prx-rich-editor__blocks{display:grid;gap:14px}.prx-rich-editor__section-header,.prx-rich-editor__node-header{display:flex;gap:16px;align-items:flex-start;justify-content:space-between}.prx-rich-editor__section-header h3,.prx-rich-editor__node-header h4{margin:0}.prx-rich-editor__section-header p,.prx-rich-editor__node-note,.prx-rich-editor__empty{margin:6px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.88rem}.prx-rich-editor__add-block,.prx-rich-editor__node-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end}.prx-rich-editor__add-block label{min-width:180px}.prx-rich-editor__node-list{display:grid;gap:12px}.prx-rich-editor__node-card{display:grid;gap:14px;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:14px;background:var(--md-sys-color-surface-container-lowest, #fff)}.prx-rich-editor__node-eyebrow{margin:0 0 4px;color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.75rem;font-weight:700;text-transform:uppercase}.prx-rich-editor__node-grid{display:grid;gap:12px;grid-template-columns:repeat(2,minmax(0,1fr))}.prx-rich-editor__wide-field,.prx-rich-editor__node-note{grid-column:1 / -1}.prx-rich-editor__field-group,.prx-rich-editor__nested-editor,.prx-rich-editor__nested-node{grid-column:1 / -1;display:grid;gap:12px}.prx-rich-editor__field-group,.prx-rich-editor__nested-node{margin:0;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:12px}.prx-rich-editor__field-group{grid-template-columns:repeat(2,minmax(0,1fr))}.prx-rich-editor__field-group legend{padding:0 4px;font-weight:700}.prx-rich-editor__nested-header,.prx-rich-editor__nested-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between}.prx-rich-editor__node-grid textarea{min-height:96px;font-family:inherit}label,.prx-rich-editor__document{display:grid;gap:7px;font-weight:700}input,select,textarea{box-sizing:border-box;width:100%;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:12px;padding:10px 12px;font:inherit;color:inherit;background:var(--md-sys-color-surface, #fff)}textarea{min-height:360px;resize:vertical;font-family:Cascadia Code,Fira Code,Consolas,monospace;font-size:.86rem;line-height:1.45}.prx-rich-editor__workbench{display:grid;gap:14px;grid-template-columns:minmax(0,1.2fr) minmax(260px,.8fr)}.prx-rich-editor__inspector{display:grid;align-content:start;gap:14px;min-width:0;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:12px;background:var(--md-sys-color-surface-container-lowest, #fff)}.prx-rich-editor__inspector h3{margin:0 0 8px;font-size:.95rem}.prx-rich-editor__inspector dl{display:grid;gap:8px;margin:0}.prx-rich-editor__inspector dl div{display:grid;gap:2px}.prx-rich-editor__inspector dt{color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.78rem;font-weight:700}.prx-rich-editor__inspector dd{margin:0;overflow-wrap:anywhere;font-weight:600}.prx-rich-editor__preview{max-height:260px;overflow:auto;border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:12px;background:var(--md-sys-color-surface, #fff)}.prx-rich-editor__help{margin:-8px 0 0;color:var(--md-sys-color-on-surface-variant, #5f6673);font-size:.85rem}.prx-rich-editor__error{margin:0;padding:10px 12px;border-radius:8px;color:#991b1b;background:color-mix(in srgb,#dc2626 9%,transparent)}.prx-rich-editor__error p{margin:0}.prx-rich-editor__error ul{display:grid;gap:6px;margin:8px 0 0;padding-inline-start:18px}.prx-rich-editor__error code{margin-right:4px;font-family:Cascadia Code,Fira Code,Consolas,monospace}.prx-rich-editor__actions{display:flex;flex-wrap:wrap;gap:10px}button{border:1px solid var(--md-sys-color-outline-variant, #d7dce5);border-radius:8px;padding:8px 14px;background:var(--md-sys-color-surface, #fff);color:var(--md-sys-color-primary, #3154e7);font-weight:700;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.55}@media(max-width:760px){.prx-rich-editor__header,.prx-rich-editor__section-header,.prx-rich-editor__node-header,.prx-rich-editor__grid,.prx-rich-editor__field-group,.prx-rich-editor__node-grid,.prx-rich-editor__workbench{grid-template-columns:1fr;display:grid}}\n"] }]
1048
3833
  }], propDecorators: { inputs: [{
1049
3834
  type: Input
1050
3835
  }] } });
@@ -1148,6 +3933,7 @@ const RICH_CONTENT_AI_CAPABILITIES = {
1148
3933
  'text',
1149
3934
  'icon',
1150
3935
  'image',
3936
+ 'link',
1151
3937
  'badge',
1152
3938
  'avatar',
1153
3939
  'metric',
@@ -1183,7 +3969,7 @@ const RICH_CONTENT_AI_CAPABILITIES = {
1183
3969
  { path: 'document.context', category: 'richContent', valueKind: 'object', description: 'Config de escopos e aliases de contexto para bindings.' },
1184
3970
  { path: 'document.nodes', category: 'richNode', valueKind: 'array', description: 'Lista declarativa de nodes ricos.' },
1185
3971
  { path: 'document.nodes[]', category: 'richNode', valueKind: 'object', description: 'Node rich-content; o shape depende de document.nodes[].type.' },
1186
- { path: 'document.nodes[].type', category: 'richNode', valueKind: 'enum', allowedValues: ['text', 'icon', 'image', 'badge', 'avatar', 'metric', 'progress', 'compose', 'card', 'mediaBlock', 'timeline', 'preset'], description: 'Tipo canonico do node.' },
3972
+ { path: 'document.nodes[].type', category: 'richNode', valueKind: 'enum', allowedValues: ['text', 'icon', 'image', 'link', 'badge', 'avatar', 'metric', 'progress', 'compose', 'card', 'mediaBlock', 'timeline', 'preset'], description: 'Tipo canonico do node.' },
1187
3973
  { path: 'document.nodes[].id', category: 'richNode', valueKind: 'string', description: 'Identificador estavel opcional para diffs, testes e telemetria.' },
1188
3974
  { path: 'document.nodes[].testId', category: 'richNode', valueKind: 'string', description: 'Test id opcional para E2E.' },
1189
3975
  { path: 'document.nodes[].className', category: 'richNode', valueKind: 'string', description: 'Classe CSS declarativa aplicada ao node.' },
@@ -1196,14 +3982,24 @@ const RICH_CONTENT_AI_CAPABILITIES = {
1196
3982
  { path: 'document.nodes[].icon', category: 'richNode', valueKind: 'string', description: 'Nome do icone Material Symbols para node icon ou badge.' },
1197
3983
  { path: 'document.nodes[].label', category: 'richNode', valueKind: 'string', description: 'Label literal para badge, metric ou progress.' },
1198
3984
  { path: 'document.nodes[].labelExpr', category: 'richNode', valueKind: 'string', description: 'Path simples resolvido contra o contexto para label.' },
3985
+ { path: 'document.nodes[].title', category: 'richNode', valueKind: 'string', description: 'Titulo literal para card, mediaBlock ou timeline.' },
3986
+ { path: 'document.nodes[].subtitle', category: 'richNode', valueKind: 'string', description: 'Subtitulo literal para card ou mediaBlock.' },
1199
3987
  { path: 'document.nodes[].src', category: 'richNode', valueKind: 'string', description: 'URL literal para image.' },
1200
3988
  { path: 'document.nodes[].srcExpr', category: 'richNode', valueKind: 'string', description: 'Path simples resolvido contra o contexto para image src.' },
1201
3989
  { path: 'document.nodes[].alt', category: 'richNode', valueKind: 'string', description: 'Texto alternativo literal para image.' },
3990
+ { path: 'document.nodes[].href', category: 'richNode', valueKind: 'string', description: 'URL segura para node link.' },
3991
+ { path: 'document.nodes[].target', category: 'richNode', valueKind: 'enum', allowedValues: ['_blank', '_self'], description: 'Destino permitido para node link.' },
3992
+ { path: 'document.nodes[].rel', category: 'richNode', valueKind: 'string', description: 'Atributo rel opcional para node link.' },
1202
3993
  { path: 'document.nodes[].name', category: 'richNode', valueKind: 'string', description: 'Nome literal para avatar.' },
1203
3994
  { path: 'document.nodes[].nameExpr', category: 'richNode', valueKind: 'string', description: 'Path simples resolvido contra o contexto para avatar.' },
1204
3995
  { path: 'document.nodes[].valueExpr', category: 'richNode', valueKind: 'string', description: 'Path simples resolvido contra o contexto para metric/progress.' },
1205
3996
  { path: 'document.nodes[].items', category: 'richNode', valueKind: 'array', description: 'Itens internos de compose.' },
3997
+ { path: 'document.nodes[].items[].title', category: 'richNode', valueKind: 'string', description: 'Titulo de item de timeline quando document.nodes[].type for timeline.' },
3998
+ { path: 'document.nodes[].items[].subtitle', category: 'richNode', valueKind: 'string', description: 'Subtitulo de item de timeline quando document.nodes[].type for timeline.' },
3999
+ { path: 'document.nodes[].items[].badge', category: 'richNode', valueKind: 'string', description: 'Badge de item de timeline quando document.nodes[].type for timeline.' },
4000
+ { path: 'document.nodes[].items[].icon', category: 'richNode', valueKind: 'string', description: 'Icone de item de timeline quando document.nodes[].type for timeline.' },
1206
4001
  { path: 'document.nodes[].content', category: 'richNode', valueKind: 'array', description: 'Conteudo interno de card.' },
4002
+ { path: 'document.nodes[].avatar', category: 'richNode', valueKind: 'object', description: 'Avatar estruturado de mediaBlock.' },
1207
4003
  { path: 'document.nodes[].trailing', category: 'richNode', valueKind: 'array', description: 'Conteudo trailing de mediaBlock.' },
1208
4004
  { path: 'document.nodes[].ref', category: 'richNode', valueKind: 'object', description: 'Referencia registravel de preset rich-block.' },
1209
4005
  { path: 'context', category: 'richContent', valueKind: 'object', description: 'Contexto externo injetado pelo host ou page-builder.' },
@@ -1213,6 +4009,417 @@ const RICH_CONTENT_AI_CAPABILITIES = {
1213
4009
  ],
1214
4010
  };
1215
4011
 
4012
+ const richContentDocumentSchema = {
4013
+ type: 'object',
4014
+ required: ['kind', 'version', 'nodes'],
4015
+ properties: {
4016
+ kind: { const: 'praxis.rich-content' },
4017
+ version: { const: '1.0.0' },
4018
+ context: { type: 'object' },
4019
+ nodes: { type: 'array', items: { type: 'object' } },
4020
+ },
4021
+ };
4022
+ const blockPatchSchema = {
4023
+ type: 'object',
4024
+ minProperties: 1,
4025
+ properties: {
4026
+ id: { type: 'string' },
4027
+ testId: { type: 'string' },
4028
+ className: { type: 'string' },
4029
+ style: { type: 'object' },
4030
+ visibleWhen: { type: 'object' },
4031
+ text: { type: 'string' },
4032
+ textExpr: { type: 'string' },
4033
+ label: { type: 'string' },
4034
+ labelExpr: { type: 'string' },
4035
+ href: { type: 'string' },
4036
+ target: { enum: ['_self', '_blank'] },
4037
+ rel: { type: 'string' },
4038
+ src: { type: 'string' },
4039
+ alt: { type: 'string' },
4040
+ title: { type: 'string' },
4041
+ subtitle: { type: 'string' },
4042
+ },
4043
+ };
4044
+ const PRAXIS_RICH_CONTENT_AUTHORING_MANIFEST = {
4045
+ schemaVersion: '1.0.0',
4046
+ componentId: 'praxis-rich-content',
4047
+ ownerPackage: '@praxisui/rich-content',
4048
+ configSchemaId: 'RichContentDocument',
4049
+ manifestVersion: '1.0.0',
4050
+ runtimeInputs: [
4051
+ { name: 'document', type: 'RichContentDocument | null', description: 'Canonical praxis.rich-content document rendered by the component.' },
4052
+ { name: 'nodes', type: 'RichBlockNode[] | null', description: 'Direct node list used only when the host controls the document envelope.' },
4053
+ { name: 'context', type: 'JsonLogicDataRecord | null', description: 'External context used by expression paths and Json Logic rules.' },
4054
+ { name: 'hostCapabilities', type: 'RichBlockHostCapabilities | null', description: 'Host-mediated preset, action, data and embed capabilities; functions are not serialized in public contracts.' },
4055
+ { name: 'layout', type: "'block' | 'inline'", allowedValues: ['block', 'inline'], description: 'Renderer layout mode.' },
4056
+ { name: 'rootClassName', type: 'string', description: 'CSS class applied to the renderer root.' },
4057
+ ],
4058
+ editableTargets: [
4059
+ { kind: 'document', resolver: 'rich-content-document-root', description: 'The canonical RichContentDocument input envelope.' },
4060
+ { kind: 'block', resolver: 'rich-block-by-id-or-index', description: 'A top-level document.nodes[] block with stable id preferred over index.' },
4061
+ { kind: 'text', resolver: 'rich-text-node-by-id-or-path', description: 'Text-bearing nodes, including text, badge, metric, progress, card and timeline labels.' },
4062
+ { kind: 'link', resolver: 'rich-link-node-by-id-or-path', description: 'Canonical link node with label, href, target and rel.' },
4063
+ { kind: 'media', resolver: 'rich-media-node-by-id-or-path', description: 'Image, avatar image and mediaBlock child nodes.' },
4064
+ { kind: 'timelineItem', resolver: 'rich-timeline-item-by-block-id-and-item-id', description: 'Timeline items nested inside timeline nodes; stable item id is preferred over index.' },
4065
+ { kind: 'metadata', resolver: 'rich-block-common-metadata', description: 'Common node metadata such as testId, className, visibleWhen and safe style.' },
4066
+ { kind: 'preset', resolver: 'rich-block-preset-ref', description: 'Preset nodes resolved through PRAXIS_RICH_BLOCK_PRESETS or hostCapabilities.resolvePreset.' },
4067
+ { kind: 'sanitizationPolicy', resolver: 'rich-content-validation-policy', description: 'Document validation policy enforced by validateRichContentDocument.' },
4068
+ { kind: 'display', resolver: 'rich-content-layout-and-root-class', description: 'Layout and rootClassName runtime inputs.' },
4069
+ ],
4070
+ operations: [
4071
+ {
4072
+ operationId: 'document.set',
4073
+ title: 'Set rich content document',
4074
+ scope: 'global',
4075
+ targetKind: 'document',
4076
+ target: { kind: 'document', resolver: 'rich-content-document-root', ambiguityPolicy: 'fail', required: false },
4077
+ inputSchema: richContentDocumentSchema,
4078
+ effects: [{ kind: 'set-value', path: 'document' }],
4079
+ destructive: false,
4080
+ requiresConfirmation: false,
4081
+ validators: ['document-shape-canonical', 'document-version-supported', 'node-types-supported', 'unsafe-url-rejected', 'unsafe-style-rejected', 'editor-runtime-round-trip'],
4082
+ affectedPaths: ['document'],
4083
+ submissionImpact: 'config-only',
4084
+ preconditions: ['config-initialized'],
4085
+ },
4086
+ {
4087
+ operationId: 'block.add',
4088
+ title: 'Add rich content block',
4089
+ scope: 'templating',
4090
+ targetKind: 'block',
4091
+ target: { kind: 'block', resolver: 'document-nodes-array', ambiguityPolicy: 'fail', required: false },
4092
+ inputSchema: {
4093
+ type: 'object',
4094
+ required: ['type'],
4095
+ properties: {
4096
+ type: { enum: ['text', 'icon', 'image', 'link', 'badge', 'avatar', 'metric', 'progress', 'compose', 'card', 'mediaBlock', 'timeline', 'preset'] },
4097
+ node: { type: 'object' },
4098
+ afterBlockId: { type: 'string' },
4099
+ beforeBlockId: { type: 'string' },
4100
+ },
4101
+ },
4102
+ effects: [{ kind: 'compile-domain-patch', handler: 'rich-content-block-add', handlerContract: {
4103
+ reads: ['document.nodes[]'],
4104
+ writes: ['document.nodes[]'],
4105
+ identityKeys: ['document.nodes[].id'],
4106
+ inputSchema: { type: 'object', required: ['type'], properties: { type: { enum: ['text', 'icon', 'image', 'link', 'badge', 'avatar', 'metric', 'progress', 'compose', 'card', 'mediaBlock', 'timeline', 'preset'] }, node: { type: 'object' }, afterBlockId: { type: 'string' }, beforeBlockId: { type: 'string' } } },
4107
+ failureModes: ['unsupported-node-type', 'duplicate-node-id', 'invalid-node-shape', 'unsafe-url', 'unsafe-style'],
4108
+ description: 'Creates a supported RichBlockNode, validates it with validateRichContentDocument semantics and inserts it by stable neighbor id when provided.',
4109
+ } }],
4110
+ destructive: false,
4111
+ requiresConfirmation: false,
4112
+ validators: ['node-types-supported', 'block-id-unique', 'document-shape-canonical', 'unsafe-url-rejected', 'unsafe-style-rejected', 'editor-runtime-round-trip'],
4113
+ affectedPaths: ['document.nodes[]'],
4114
+ submissionImpact: 'config-only',
4115
+ preconditions: ['config-initialized'],
4116
+ },
4117
+ {
4118
+ operationId: 'block.remove',
4119
+ title: 'Remove rich content block',
4120
+ scope: 'templating',
4121
+ targetKind: 'block',
4122
+ target: { kind: 'block', resolver: 'rich-block-by-id-or-index', ambiguityPolicy: 'fail', required: true },
4123
+ inputSchema: { type: 'object', properties: { blockId: { type: 'string' }, replacementBlockId: { type: 'string' } } },
4124
+ effects: [{ kind: 'remove-by-key', path: 'document.nodes[]', key: 'id' }],
4125
+ destructive: true,
4126
+ requiresConfirmation: true,
4127
+ validators: ['block-exists', 'destructive-removal-confirmed', 'document-not-empty-when-required', 'editor-runtime-round-trip'],
4128
+ affectedPaths: ['document.nodes[]'],
4129
+ submissionImpact: 'config-only',
4130
+ preconditions: ['config-initialized', 'target-block-exists', 'confirmation-collected'],
4131
+ },
4132
+ {
4133
+ operationId: 'block.order.set',
4134
+ title: 'Reorder rich content blocks',
4135
+ scope: 'templating',
4136
+ targetKind: 'block',
4137
+ target: { kind: 'block', resolver: 'rich-block-by-id-or-index', ambiguityPolicy: 'fail', required: true },
4138
+ inputSchema: { type: 'object', required: ['blockId'], properties: { blockId: { type: 'string' }, beforeBlockId: { type: 'string' }, afterBlockId: { type: 'string' } } },
4139
+ effects: [{ kind: 'reorder-by-key', path: 'document.nodes[]', key: 'id' }],
4140
+ destructive: false,
4141
+ requiresConfirmation: false,
4142
+ validators: ['block-exists', 'block-order-deterministic', 'document-shape-canonical', 'editor-runtime-round-trip'],
4143
+ affectedPaths: ['document.nodes[]'],
4144
+ submissionImpact: 'config-only',
4145
+ preconditions: ['config-initialized', 'target-block-exists'],
4146
+ },
4147
+ {
4148
+ operationId: 'text.update',
4149
+ title: 'Update rich content text',
4150
+ scope: 'templating',
4151
+ targetKind: 'text',
4152
+ target: { kind: 'text', resolver: 'rich-text-node-by-id-or-path', ambiguityPolicy: 'fail', required: true },
4153
+ inputSchema: { type: 'object', minProperties: 1, properties: { text: { type: 'string' }, textExpr: { type: 'string' }, label: { type: 'string' }, labelExpr: { type: 'string' }, title: { type: 'string' }, subtitle: { type: 'string' } } },
4154
+ effects: [{ kind: 'merge-by-key', path: 'document.nodes[]', key: 'id' }],
4155
+ destructive: false,
4156
+ requiresConfirmation: false,
4157
+ validators: ['block-exists', 'text-target-supports-field', 'expression-path-safe', 'document-shape-canonical', 'editor-runtime-round-trip'],
4158
+ affectedPaths: ['document.nodes[].text', 'document.nodes[].textExpr', 'document.nodes[].label', 'document.nodes[].labelExpr', 'document.nodes[].title', 'document.nodes[].subtitle'],
4159
+ submissionImpact: 'config-only',
4160
+ preconditions: ['config-initialized', 'target-block-exists'],
4161
+ },
4162
+ {
4163
+ operationId: 'link.add',
4164
+ title: 'Add link block',
4165
+ scope: 'templating',
4166
+ targetKind: 'link',
4167
+ target: { kind: 'link', resolver: 'document-nodes-array', ambiguityPolicy: 'fail', required: false },
4168
+ inputSchema: { type: 'object', required: ['label', 'href'], properties: { id: { type: 'string' }, label: { type: 'string' }, labelExpr: { type: 'string' }, href: { type: 'string' }, target: { enum: ['_self', '_blank'] }, rel: { type: 'string' } } },
4169
+ effects: [{ kind: 'append-unique', path: 'document.nodes[]', key: 'id' }],
4170
+ destructive: false,
4171
+ requiresConfirmation: false,
4172
+ validators: ['link-url-safe', 'link-policy-explicit', 'node-types-supported', 'block-id-unique', 'editor-runtime-round-trip'],
4173
+ affectedPaths: ['document.nodes[]', 'document.nodes[].href', 'document.nodes[].target', 'document.nodes[].rel'],
4174
+ submissionImpact: 'config-only',
4175
+ preconditions: ['config-initialized'],
4176
+ },
4177
+ {
4178
+ operationId: 'link.remove',
4179
+ title: 'Remove link from rich content',
4180
+ scope: 'templating',
4181
+ targetKind: 'link',
4182
+ target: { kind: 'link', resolver: 'rich-link-node-by-id-or-path', ambiguityPolicy: 'fail', required: true },
4183
+ inputSchema: { type: 'object', properties: { linkId: { type: 'string' }, preserveLabelAsText: { type: 'boolean' } } },
4184
+ effects: [{ kind: 'compile-domain-patch', handler: 'rich-content-link-remove', handlerContract: {
4185
+ reads: ['document.nodes[]', 'document.nodes[].id', 'document.nodes[].type', 'document.nodes[].label'],
4186
+ writes: ['document.nodes[]'],
4187
+ identityKeys: ['document.nodes[].id'],
4188
+ inputSchema: { type: 'object', properties: { linkId: { type: 'string' }, preserveLabelAsText: { type: 'boolean' } } },
4189
+ failureModes: ['link-not-found', 'ambiguous-link-target', 'replacement-text-invalid'],
4190
+ description: 'Removes a link node by stable id or path and optionally replaces it with a text node containing the prior link label.',
4191
+ } }],
4192
+ destructive: true,
4193
+ requiresConfirmation: true,
4194
+ validators: ['link-target-exists', 'destructive-removal-confirmed', 'document-shape-canonical', 'editor-runtime-round-trip'],
4195
+ affectedPaths: ['document.nodes[]'],
4196
+ submissionImpact: 'config-only',
4197
+ preconditions: ['config-initialized', 'target-link-exists', 'confirmation-collected'],
4198
+ },
4199
+ {
4200
+ operationId: 'preset.apply',
4201
+ title: 'Apply rich block preset',
4202
+ scope: 'templating',
4203
+ targetKind: 'preset',
4204
+ target: { kind: 'preset', resolver: 'rich-block-preset-ref', ambiguityPolicy: 'fail', required: false },
4205
+ inputSchema: { type: 'object', required: ['ref'], properties: { ref: { type: 'object' }, inputs: { type: 'object' }, replaceBlockId: { type: 'string' } } },
4206
+ effects: [{ kind: 'compile-domain-patch', handler: 'rich-content-preset-apply', handlerContract: {
4207
+ reads: ['PRAXIS_RICH_BLOCK_PRESETS', 'hostCapabilities.resolvePreset', 'document.nodes[]'],
4208
+ writes: ['document.nodes[]'],
4209
+ identityKeys: ['ref.kind', 'ref.namespace', 'ref.presetId', 'ref.version'],
4210
+ inputSchema: { type: 'object', required: ['ref'], properties: { ref: { type: 'object' }, inputs: { type: 'object' }, replaceBlockId: { type: 'string' } } },
4211
+ failureModes: ['preset-not-found', 'preset-kind-invalid', 'preset-document-invalid', 'preset-cycle-detected'],
4212
+ description: 'Resolves a rich-block preset through the governed registry/host mediation and inserts or replaces a preset reference without serializing functions.',
4213
+ } }],
4214
+ destructive: false,
4215
+ requiresConfirmation: false,
4216
+ validators: ['preset-ref-valid', 'preset-exists-or-host-mediated', 'host-capabilities-serializable', 'document-shape-canonical', 'editor-runtime-round-trip'],
4217
+ affectedPaths: ['document.nodes[]', 'document.nodes[].ref', 'document.nodes[].inputs'],
4218
+ submissionImpact: 'config-only',
4219
+ preconditions: ['config-initialized'],
4220
+ },
4221
+ {
4222
+ operationId: 'metadata.update',
4223
+ title: 'Update rich block metadata',
4224
+ scope: 'templating',
4225
+ targetKind: 'metadata',
4226
+ target: { kind: 'metadata', resolver: 'rich-block-by-id-or-index', ambiguityPolicy: 'fail', required: true },
4227
+ inputSchema: { type: 'object', minProperties: 1, properties: { blockId: { type: 'string' }, testId: { type: 'string' }, className: { type: 'string' }, visibleWhen: { type: 'object' }, style: { type: 'object' } } },
4228
+ effects: [{ kind: 'merge-by-key', path: 'document.nodes[]', key: 'id' }],
4229
+ destructive: false,
4230
+ requiresConfirmation: false,
4231
+ validators: ['block-exists', 'class-name-safe', 'visible-when-json-logic', 'unsafe-style-rejected', 'document-shape-canonical', 'editor-runtime-round-trip'],
4232
+ affectedPaths: ['document.nodes[].testId', 'document.nodes[].className', 'document.nodes[].visibleWhen', 'document.nodes[].style'],
4233
+ submissionImpact: 'config-only',
4234
+ preconditions: ['config-initialized', 'target-block-exists'],
4235
+ },
4236
+ {
4237
+ operationId: 'mediaBlock.update',
4238
+ title: 'Update media block fields',
4239
+ scope: 'templating',
4240
+ targetKind: 'media',
4241
+ target: { kind: 'media', resolver: 'rich-media-node-by-id-or-path', ambiguityPolicy: 'fail', required: true },
4242
+ inputSchema: { type: 'object', minProperties: 1, properties: { blockId: { type: 'string' }, avatarName: { type: 'string' }, avatarImageSrc: { type: 'string' }, title: { type: 'string' }, titleExpr: { type: 'string' }, subtitle: { type: 'string' }, subtitleExpr: { type: 'string' } } },
4243
+ effects: [{ kind: 'compile-domain-patch', handler: 'rich-content-media-block-update', handlerContract: {
4244
+ reads: ['document.nodes[]', 'document.nodes[].id', 'document.nodes[].type'],
4245
+ writes: ['document.nodes[].avatar', 'document.nodes[].title', 'document.nodes[].subtitle'],
4246
+ identityKeys: ['document.nodes[].id'],
4247
+ inputSchema: { type: 'object', minProperties: 1, properties: { blockId: { type: 'string' }, avatarName: { type: 'string' }, avatarImageSrc: { type: 'string' }, title: { type: 'string' }, titleExpr: { type: 'string' }, subtitle: { type: 'string' }, subtitleExpr: { type: 'string' } } },
4248
+ failureModes: ['media-block-not-found', 'target-not-media-block', 'unsafe-url', 'invalid-text-node'],
4249
+ description: 'Updates the structured mediaBlock avatar/title/subtitle fields while preserving meta and trailing content for advanced JSON authoring.',
4250
+ } }],
4251
+ destructive: false,
4252
+ requiresConfirmation: false,
4253
+ validators: ['block-exists', 'media-block-target-valid', 'unsafe-url-rejected', 'expression-path-safe', 'document-shape-canonical', 'editor-runtime-round-trip'],
4254
+ affectedPaths: ['document.nodes[].avatar', 'document.nodes[].title', 'document.nodes[].subtitle'],
4255
+ submissionImpact: 'config-only',
4256
+ preconditions: ['config-initialized', 'target-block-exists'],
4257
+ },
4258
+ {
4259
+ operationId: 'timeline.item.add',
4260
+ title: 'Add timeline item',
4261
+ scope: 'templating',
4262
+ targetKind: 'timelineItem',
4263
+ target: { kind: 'timelineItem', resolver: 'rich-timeline-node-by-id-or-path', ambiguityPolicy: 'fail', required: true },
4264
+ inputSchema: { type: 'object', required: ['timelineBlockId', 'item'], properties: { timelineBlockId: { type: 'string' }, item: { type: 'object' }, afterItemId: { type: 'string' }, beforeItemId: { type: 'string' } } },
4265
+ effects: [{ kind: 'compile-domain-patch', handler: 'rich-content-timeline-item-add', handlerContract: {
4266
+ reads: ['document.nodes[]', 'document.nodes[].items[]'],
4267
+ writes: ['document.nodes[].items[]'],
4268
+ identityKeys: ['document.nodes[].id', 'document.nodes[].items[].id'],
4269
+ inputSchema: { type: 'object', required: ['timelineBlockId', 'item'], properties: { timelineBlockId: { type: 'string' }, item: { type: 'object' }, afterItemId: { type: 'string' }, beforeItemId: { type: 'string' } } },
4270
+ failureModes: ['timeline-not-found', 'duplicate-timeline-item-id', 'invalid-timeline-item'],
4271
+ description: 'Adds a timeline item to a timeline node using stable timeline and item ids when present; array index is a resolver fallback only.',
4272
+ } }],
4273
+ destructive: false,
4274
+ requiresConfirmation: false,
4275
+ validators: ['block-exists', 'timeline-target-valid', 'timeline-item-valid', 'timeline-item-id-unique', 'document-shape-canonical', 'editor-runtime-round-trip'],
4276
+ affectedPaths: ['document.nodes[].items[]'],
4277
+ submissionImpact: 'config-only',
4278
+ preconditions: ['config-initialized', 'target-block-exists'],
4279
+ },
4280
+ {
4281
+ operationId: 'timeline.item.update',
4282
+ title: 'Update timeline item',
4283
+ scope: 'templating',
4284
+ targetKind: 'timelineItem',
4285
+ target: { kind: 'timelineItem', resolver: 'rich-timeline-item-by-block-id-and-item-id', ambiguityPolicy: 'fail', required: true },
4286
+ inputSchema: { type: 'object', required: ['timelineBlockId'], minProperties: 2, properties: { timelineBlockId: { type: 'string' }, itemId: { type: 'string' }, itemIndex: { type: 'number' }, field: { enum: ['title', 'titleExpr', 'subtitle', 'subtitleExpr', 'meta', 'metaExpr', 'icon', 'iconExpr', 'badge', 'badgeExpr'] }, value: { type: 'string' }, patch: { type: 'object' } } },
4287
+ effects: [{ kind: 'compile-domain-patch', handler: 'rich-content-timeline-item-update', handlerContract: {
4288
+ reads: ['document.nodes[]', 'document.nodes[].items[]'],
4289
+ writes: ['document.nodes[].items[]'],
4290
+ identityKeys: ['document.nodes[].id', 'document.nodes[].items[].id'],
4291
+ inputSchema: { type: 'object', required: ['timelineBlockId'], properties: { timelineBlockId: { type: 'string' }, itemId: { type: 'string' }, itemIndex: { type: 'number' }, field: { enum: ['title', 'titleExpr', 'subtitle', 'subtitleExpr', 'meta', 'metaExpr', 'icon', 'iconExpr', 'badge', 'badgeExpr'] }, value: { type: 'string' }, patch: { type: 'object' } } },
4292
+ failureModes: ['timeline-not-found', 'timeline-item-not-found', 'unsupported-timeline-field', 'invalid-timeline-item'],
4293
+ description: 'Updates a timeline item by stable item id when available, preserving the item object shape accepted by RichTimelineItem.',
4294
+ } }],
4295
+ destructive: false,
4296
+ requiresConfirmation: false,
4297
+ validators: ['block-exists', 'timeline-target-valid', 'timeline-item-exists', 'timeline-item-field-supported', 'document-shape-canonical', 'editor-runtime-round-trip'],
4298
+ affectedPaths: ['document.nodes[].items[].title', 'document.nodes[].items[].subtitle', 'document.nodes[].items[].meta', 'document.nodes[].items[].icon', 'document.nodes[].items[].badge'],
4299
+ submissionImpact: 'config-only',
4300
+ preconditions: ['config-initialized', 'target-block-exists', 'target-timeline-item-exists'],
4301
+ },
4302
+ {
4303
+ operationId: 'timeline.item.remove',
4304
+ title: 'Remove timeline item',
4305
+ scope: 'templating',
4306
+ targetKind: 'timelineItem',
4307
+ target: { kind: 'timelineItem', resolver: 'rich-timeline-item-by-block-id-and-item-id', ambiguityPolicy: 'fail', required: true },
4308
+ inputSchema: { type: 'object', required: ['timelineBlockId'], properties: { timelineBlockId: { type: 'string' }, itemId: { type: 'string' }, itemIndex: { type: 'number' } } },
4309
+ effects: [{ kind: 'compile-domain-patch', handler: 'rich-content-timeline-item-remove', handlerContract: {
4310
+ reads: ['document.nodes[]', 'document.nodes[].items[]'],
4311
+ writes: ['document.nodes[].items[]'],
4312
+ identityKeys: ['document.nodes[].id', 'document.nodes[].items[].id'],
4313
+ inputSchema: { type: 'object', required: ['timelineBlockId'], properties: { timelineBlockId: { type: 'string' }, itemId: { type: 'string' }, itemIndex: { type: 'number' } } },
4314
+ failureModes: ['timeline-not-found', 'timeline-item-not-found', 'ambiguous-timeline-item'],
4315
+ description: 'Removes a timeline item from a timeline node after explicit confirmation; stable item id is preferred over index.',
4316
+ } }],
4317
+ destructive: true,
4318
+ requiresConfirmation: true,
4319
+ validators: ['timeline-target-valid', 'timeline-item-exists', 'destructive-removal-confirmed', 'document-shape-canonical', 'editor-runtime-round-trip'],
4320
+ affectedPaths: ['document.nodes[].items[]'],
4321
+ submissionImpact: 'config-only',
4322
+ preconditions: ['config-initialized', 'target-timeline-item-exists', 'confirmation-collected'],
4323
+ },
4324
+ {
4325
+ operationId: 'sanitizationPolicy.set',
4326
+ title: 'Set rich content sanitization policy',
4327
+ scope: 'global',
4328
+ targetKind: 'sanitizationPolicy',
4329
+ target: { kind: 'sanitizationPolicy', resolver: 'rich-content-validation-policy', ambiguityPolicy: 'fail', required: false },
4330
+ inputSchema: { type: 'object', properties: { allowHtml: { const: false }, allowedUrlProtocols: { type: 'array', items: { type: 'string' } }, allowImageDataUrls: { type: 'boolean' }, maxNodeDepth: { type: 'number' }, maxNodeCount: { type: 'number' } } },
4331
+ effects: [{ kind: 'compile-domain-patch', handler: 'rich-content-sanitization-policy', handlerContract: {
4332
+ reads: ['document', 'validateRichContentDocument'],
4333
+ writes: ['document'],
4334
+ identityKeys: ['document.kind', 'document.version'],
4335
+ inputSchema: { type: 'object', properties: { allowHtml: { const: false }, allowedUrlProtocols: { type: 'array', items: { type: 'string' } }, allowImageDataUrls: { type: 'boolean' }, maxNodeDepth: { type: 'number' }, maxNodeCount: { type: 'number' } } },
4336
+ failureModes: ['html-not-supported', 'unsafe-protocol', 'max-depth-too-high', 'max-node-count-too-high'],
4337
+ description: 'Validates requested policy against the fixed validator guardrails; rich-content authoring remains structured JSON and does not allow arbitrary HTML.',
4338
+ } }],
4339
+ destructive: true,
4340
+ requiresConfirmation: true,
4341
+ validators: ['sanitization-policy-explicit', 'unsafe-html-rejected', 'unsafe-url-rejected', 'security-change-confirmed', 'editor-runtime-round-trip'],
4342
+ affectedPaths: ['document'],
4343
+ submissionImpact: 'config-only',
4344
+ preconditions: ['config-initialized', 'confirmation-collected'],
4345
+ },
4346
+ {
4347
+ operationId: 'display.mode.set',
4348
+ title: 'Set rich content display mode',
4349
+ scope: 'global',
4350
+ targetKind: 'display',
4351
+ target: { kind: 'display', resolver: 'rich-content-layout-and-root-class', ambiguityPolicy: 'fail', required: false },
4352
+ inputSchema: { type: 'object', properties: { layout: { enum: ['block', 'inline'] }, rootClassName: { type: 'string' } } },
4353
+ effects: [{ kind: 'set-value', path: 'layout' }, { kind: 'set-value', path: 'rootClassName' }],
4354
+ destructive: false,
4355
+ requiresConfirmation: false,
4356
+ validators: ['layout-valid', 'root-class-safe', 'editor-runtime-round-trip'],
4357
+ affectedPaths: ['layout', 'rootClassName'],
4358
+ submissionImpact: 'visual-only',
4359
+ preconditions: ['config-initialized'],
4360
+ },
4361
+ ],
4362
+ validators: [
4363
+ { validatorId: 'document-shape-canonical', level: 'error', code: 'PRC001', description: 'Documents must satisfy RichContentDocument with kind praxis.rich-content, version 1.0.0 and a nodes array.' },
4364
+ { validatorId: 'document-version-supported', level: 'error', code: 'PRC002', description: 'Only document version 1.0.0 is supported.' },
4365
+ { validatorId: 'node-types-supported', level: 'error', code: 'PRC003', description: 'Nodes must use supported rich-content node types, including link as a structured node rather than arbitrary HTML.' },
4366
+ { validatorId: 'block-id-unique', level: 'warning', code: 'PRC004', description: 'Stable block ids should be unique when present; operations should prefer ids over array indexes.' },
4367
+ { validatorId: 'block-exists', level: 'error', code: 'PRC005', description: 'Target block must exist before patching, reordering or removing it.' },
4368
+ { validatorId: 'block-order-deterministic', level: 'error', code: 'PRC006', description: 'Block ordering must use stable ids or explicit before/after placement and not rely on transient generated indexes.' },
4369
+ { validatorId: 'destructive-removal-confirmed', level: 'error', code: 'PRC007', description: 'Block and link removal are destructive and require confirmation.' },
4370
+ { validatorId: 'document-not-empty-when-required', level: 'warning', code: 'PRC008', description: 'Removal should not leave an empty document unless the user explicitly requested an empty rich content document.' },
4371
+ { validatorId: 'text-target-supports-field', level: 'error', code: 'PRC009', description: 'Text updates may only target fields supported by the selected node type.' },
4372
+ { validatorId: 'expression-path-safe', level: 'warning', code: 'PRC010', description: 'Expression paths must remain simple context paths or Json Logic expressions, not string DSL code.' },
4373
+ { validatorId: 'link-url-safe', level: 'error', code: 'PRC011', description: 'Link href values must reject javascript:, vbscript:, file: and unsafe data URLs.' },
4374
+ { validatorId: 'link-policy-explicit', level: 'error', code: 'PRC012', description: 'Links must declare safe target/rel behavior; _blank links default to noopener noreferrer.' },
4375
+ { validatorId: 'link-target-exists', level: 'error', code: 'PRC013', description: 'Link removal must resolve an existing link node by stable id or path.' },
4376
+ { validatorId: 'unsafe-url-rejected', level: 'error', code: 'PRC014', description: 'Image, avatar and link URL fields must reject unsafe protocols and null bytes.' },
4377
+ { validatorId: 'unsafe-style-rejected', level: 'error', code: 'PRC015', description: 'Inline style values must reject script, import, binding and data URL expressions.' },
4378
+ { validatorId: 'unsafe-html-rejected', level: 'error', code: 'PRC016', description: 'Rich-content authoring must remain structured document JSON and must not accept arbitrary HTML/script patches.' },
4379
+ { validatorId: 'sanitization-policy-explicit', level: 'error', code: 'PRC017', description: 'Security policy requests must keep HTML disabled and URL/style guardrails explicit.' },
4380
+ { validatorId: 'security-change-confirmed', level: 'error', code: 'PRC018', description: 'Sanitization policy changes require explicit confirmation.' },
4381
+ { validatorId: 'preset-ref-valid', level: 'error', code: 'PRC019', description: 'Preset refs must use kind rich-block plus namespace, presetId and optional version.' },
4382
+ { validatorId: 'preset-exists-or-host-mediated', level: 'error', code: 'PRC020', description: 'Preset application must resolve through PRAXIS_RICH_BLOCK_PRESETS or a host-mediated resolver.' },
4383
+ { validatorId: 'host-capabilities-serializable', level: 'error', code: 'PRC021', description: 'Public contracts must not serialize host capability functions; only refs and inputs are persisted.' },
4384
+ { validatorId: 'layout-valid', level: 'error', code: 'PRC022', description: 'Layout must be block or inline.' },
4385
+ { validatorId: 'root-class-safe', level: 'warning', code: 'PRC023', description: 'rootClassName must contain safe CSS class tokens.' },
4386
+ { validatorId: 'editor-runtime-round-trip', level: 'error', code: 'PRC024', description: 'Config editor, Settings Panel payload and runtime renderer must preserve document structure, layout and rootClassName.' },
4387
+ { validatorId: 'class-name-safe', level: 'error', code: 'PRC025', description: 'Node className values must contain safe CSS class tokens.' },
4388
+ { validatorId: 'visible-when-json-logic', level: 'error', code: 'PRC026', description: 'visibleWhen must be a Json Logic expression object and not a string DSL.' },
4389
+ { validatorId: 'media-block-target-valid', level: 'error', code: 'PRC027', description: 'Media operations must target a mediaBlock node and preserve structured avatar/text child nodes.' },
4390
+ { validatorId: 'timeline-target-valid', level: 'error', code: 'PRC028', description: 'Timeline item operations must target a timeline node with an items array.' },
4391
+ { validatorId: 'timeline-item-valid', level: 'error', code: 'PRC029', description: 'Timeline items must be objects matching RichTimelineItem fields.' },
4392
+ { validatorId: 'timeline-item-id-unique', level: 'warning', code: 'PRC030', description: 'Timeline item ids should be unique inside a timeline node when present.' },
4393
+ { validatorId: 'timeline-item-exists', level: 'error', code: 'PRC031', description: 'Timeline item updates and removals must resolve an existing item by stable id or explicit fallback index.' },
4394
+ { validatorId: 'timeline-item-field-supported', level: 'error', code: 'PRC032', description: 'Timeline item updates may only target supported RichTimelineItem fields.' },
4395
+ ],
4396
+ roundTripRequirements: [
4397
+ 'The editor must produce the same widget input envelope consumed by page-builder: inputs.document, inputs.layout and inputs.rootClassName.',
4398
+ 'Rich content must remain structured RichContentDocument JSON; arbitrary HTML and script URL patches are rejected before persistence.',
4399
+ 'Block identity should prefer document.nodes[].id; array index is a resolver fallback only and cannot be the canonical identity for generated patches.',
4400
+ 'Link authoring uses canonical link nodes with safe href/target/rel instead of markdown or raw HTML embedded in text nodes.',
4401
+ 'MediaBlock, timeline item, metadata, visibleWhen and safe style authoring use the same structured paths exposed by the visual config editor.',
4402
+ 'Preset authoring persists only rich-block refs and inputs; host capability functions remain runtime-mediated and are not serialized.',
4403
+ ],
4404
+ examples: [
4405
+ { id: 'set-empty-document', request: 'Start with an empty rich content document.', operationId: 'document.set', params: { kind: 'praxis.rich-content', version: '1.0.0', nodes: [] }, isPositive: true },
4406
+ { id: 'add-text-block', request: 'Add a text block saying Welcome.', operationId: 'block.add', params: { type: 'text', node: { id: 'welcome', type: 'text', text: 'Welcome' } }, isPositive: true },
4407
+ { id: 'remove-obsolete-block', request: 'Remove the obsolete block.', operationId: 'block.remove', target: 'obsolete', params: { blockId: 'obsolete' }, isPositive: true },
4408
+ { id: 'move-summary-before-details', request: 'Move summary before details.', operationId: 'block.order.set', target: 'summary', params: { blockId: 'summary', beforeBlockId: 'details' }, isPositive: true },
4409
+ { id: 'update-title-text', request: 'Change the hero title to Customer overview.', operationId: 'text.update', target: 'hero-title', params: { text: 'Customer overview' }, isPositive: true },
4410
+ { id: 'add-docs-link', request: 'Add a link to the documentation.', operationId: 'link.add', params: { id: 'docs-link', label: 'Documentation', href: '/docs', target: '_self' }, isPositive: true },
4411
+ { id: 'reject-script-link', request: 'Add a link to javascript:alert(1).', operationId: 'link.add', params: { label: 'Bad link', href: 'javascript:alert(1)' }, isPositive: false },
4412
+ { id: 'remove-docs-link', request: 'Remove the documentation link but keep its label as text.', operationId: 'link.remove', target: 'docs-link', params: { linkId: 'docs-link', preserveLabelAsText: true }, isPositive: true },
4413
+ { id: 'apply-profile-preset', request: 'Apply the profile summary rich-block preset.', operationId: 'preset.apply', params: { ref: { kind: 'rich-block', namespace: 'praxis.rich-content', presetId: 'profile-summary', version: '1.0.0' } }, isPositive: true },
4414
+ { id: 'show-only-active-row', request: 'Show the status block only when row.active is true.', operationId: 'metadata.update', target: 'status', params: { blockId: 'status', visibleWhen: { '===': [{ var: 'row.active' }, true] } }, isPositive: true },
4415
+ { id: 'update-media-title', request: 'Update the profile media block title and avatar name.', operationId: 'mediaBlock.update', target: 'profile-media', params: { blockId: 'profile-media', avatarName: 'Ana Silva', title: 'Profile summary' }, isPositive: true },
4416
+ { id: 'add-created-timeline-item', request: 'Add a Created item to the activity timeline.', operationId: 'timeline.item.add', target: 'activity', params: { timelineBlockId: 'activity', item: { id: 'created', title: 'Created', badge: 'Done' } }, isPositive: true },
4417
+ { id: 'reject-unknown-timeline-field', request: 'Set a custom script field on a timeline item.', operationId: 'timeline.item.update', target: 'activity.created', params: { timelineBlockId: 'activity', itemId: 'created', field: 'script', value: 'alert(1)' }, isPositive: false },
4418
+ { id: 'reject-html-policy', request: 'Allow arbitrary HTML in this rich content document.', operationId: 'sanitizationPolicy.set', params: { allowHtml: true }, isPositive: false },
4419
+ { id: 'compact-inline-layout', request: 'Render this rich content inline with root class status-line.', operationId: 'display.mode.set', params: { layout: 'inline', rootClassName: 'status-line' }, isPositive: true },
4420
+ ],
4421
+ };
4422
+
1216
4423
  /*
1217
4424
  * Public API Surface of praxis-rich-content
1218
4425
  */
@@ -1221,4 +4428,4 @@ const RICH_CONTENT_AI_CAPABILITIES = {
1221
4428
  * Generated bundle index. Do not edit.
1222
4429
  */
1223
4430
 
1224
- export { PRAXIS_RICH_BLOCK_PRESETS, PRAXIS_RICH_CONTENT_COMPONENT_METADATA, PraxisRichContent, PraxisRichContentConfigEditor, RICH_CONTENT_AI_CAPABILITIES, RichContentPresetRegistryService, createPraxisRichContentI18nConfig, providePraxisRichContentI18n, providePraxisRichContentMetadata, resolvePraxisRichContentText };
4431
+ export { PRAXIS_RICH_BLOCK_PRESETS, PRAXIS_RICH_CONTENT_AUTHORING_MANIFEST, PRAXIS_RICH_CONTENT_COMPONENT_METADATA, PraxisRichContent, PraxisRichContentConfigEditor, RICH_CONTENT_AI_CAPABILITIES, RichContentPresetRegistryService, createPraxisRichContentI18nConfig, isValidRichContentDocument, providePraxisRichContentI18n, providePraxisRichContentMetadata, resolvePraxisRichContentText, validateRichContentDocument };