@vectoriox/iox-ui 5.63.0 → 5.65.0

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.
@@ -1,5 +1,6 @@
1
1
  import { AnimateId, DesignChoices, DesignTokenLike } from '@vectoriox/iox-ui/design-core';
2
2
  import { SeoRawDoc } from '@vectoriox/iox-ui/seo-core';
3
+ import { ContentFieldLike } from '@vectoriox/iox-ui/composite-core';
3
4
 
4
5
  declare const SPEC_SITE_PLAN_SHAPE: {
5
6
  readonly plan: {
@@ -5744,6 +5745,10 @@ declare function buildStepFacts(plan: SitePlan, actions?: Readonly<Record<string
5744
5745
  * - **What the model does not decide is not on the wire** (`PLAN_WIRE_DERIVED`): the look (the owner's pick), the run,
5745
5746
  * the language (the reading's), the facts (the owner's own, verbatim), the version, the home page (the page at `/`).
5746
5747
  * So the format is the same for every studio and every run — one compiled grammar, cached by the provider.
5748
+ * - **What follows from the pages is derived, never asked** (`plan-derived.ts`, vectoriox/iox-cms#49): a per-item page
5749
+ * reading the next item gets its `$siblings.next` source — the structure, asked first and then fixed, cannot know the
5750
+ * pages need it, and the pages cannot add a source. `planToWire` writes every source the plan holds, a derived one
5751
+ * too (it is the plan's: a change's brief shows it, and a plan → wire → plan round trip stays exact).
5747
5752
  *
5748
5753
  * ONE source: every wire key names the plan keys it carries (`refs`) and is described in the plan table's own words, and
5749
5754
  * every plan key is either carried or derived (`planWireGaps()`); a key the plan shape gains breaks the builder until it
@@ -5827,7 +5832,7 @@ type PlanWireParts = Partial<Record<PlanPart, unknown>>;
5827
5832
  * A model's plan (its parts, on the wire) + what the generation decides (the look, the run, the language, the owner's
5828
5833
  * facts) → the Site Plan. Pure. Translates, never repairs: whatever an answer got wrong is still wrong, for
5829
5834
  * `validateSitePlan` — a part that is not an object is the result itself, a part not written yet leaves its keys out
5830
- * (the validator then names them, at their own paths).
5835
+ * (the validator then names them, at their own paths). What follows from the pages alone is added (`withDerivedSources`).
5831
5836
  */
5832
5837
  declare function expandPlanWire(parts: PlanWireParts, context: PlanWireContext): SitePlan | unknown;
5833
5838
  declare function fieldToWire(field: ContentFieldPlan): PlainObject;
@@ -6025,6 +6030,24 @@ declare enum AiCallStatus {
6025
6030
  Interrupted = "interrupted"
6026
6031
  }
6027
6032
 
6033
+ interface RecipeListBinds<T> {
6034
+ picks: T;
6035
+ /** What the fields lack (the recipe's `needs`), or null when they fill it. */
6036
+ gap: string | null;
6037
+ }
6038
+ /** The fields `recipe` binds from a listed type's `fields`, and what they lack. A recipe that lists nothing lacks nothing. */
6039
+ declare function recipeListBinds<T = Readonly<Record<string, string | null>>>(recipe: string | undefined, fields: ReadonlyArray<ContentFieldLike>): RecipeListBinds<T>;
6040
+ /** What a listed type with `fields` lacks for the pattern's recipes that repeat the section's binding — the first gap, or null. */
6041
+ declare function patternListGap(pattern: SitePattern, fields: ReadonlyArray<ContentFieldLike>): string | null;
6042
+
6043
+ /**
6044
+ * The data sources `plan` lacks and its pages need, in page order (appended after the plan's own). Tolerates any
6045
+ * value: what is not a well-formed plan gains nothing, for the validator to judge as written.
6046
+ */
6047
+ declare function derivedDataSources(plan: unknown): DataSourcePlan[];
6048
+ /** `plan` with the data sources its pages need appended (`derivedDataSources`); the same object when it lacks none. */
6049
+ declare function withDerivedSources<T>(plan: T): T;
6050
+
6028
6051
  /**
6029
6052
  * What a structured-output request costs the provider's grammar compiler, counted the way the Claude API counts it
6030
6053
  * (platform.claude.com/docs/en/build-with-claude/structured-outputs, "Schema complexity limits", read 2026-10-27):
@@ -6234,8 +6257,9 @@ declare function validatePlanEdits(edits: unknown): {
6234
6257
  };
6235
6258
  /**
6236
6259
  * Apply a set of edits to a plan — all of them, or none (any issue refuses the set and returns the plan unchanged). The
6237
- * sources only removed pages and sections read are dropped with them; sources nothing read before are kept.
6238
- * The result still has to pass `validateSitePlan`.
6260
+ * sources only removed pages and sections read are dropped with them; sources nothing read before are kept; the sources
6261
+ * the pages now need and no model writes are derived (`derivedDataSources` — a page edit adding `project.next`, as the
6262
+ * plan wire's expansion does, vectoriox/iox-cms#49). The result still has to pass `validateSitePlan`.
6239
6263
  */
6240
6264
  declare function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>): PlanEditResult;
6241
6265
  /** Each part's answer format name. */
@@ -6554,5 +6578,5 @@ declare function stockCreditNode(index: number, credit: StockLink): SerializedNo
6554
6578
  /** Append the credit lines to the footer root (once each). Returns how many it added. */
6555
6579
  declare function addStockCredits(footer: SerializedNode, credits: ReadonlyArray<StockLink>): number;
6556
6580
 
6557
- export { ACTIVE_BUILD_STATUSES, AI_REFUSAL_NEXT, AI_REFUSAL_STATUS, AI_STEPS, AI_STEP_LABELS, AI_STEP_NEEDS, AI_STEP_PART_TOKENS, AI_STEP_TYPICAL_TOKENS, AiCallStatus, AiCapability, AiFeature, AiGenerationStage, AiKeyMode, AiLimit, AiNextKind, AiProviderId, AiRefusalCode, AiStep, BAR_ITEMS_MAX, BAR_ITEMS_MIN, BASELINE_ROLES, BUILD_STEPS, BaselineRole, BindScope, BuildNoticeCode, BuildStep, CONTENT_ROLE_MEANINGS, CREDITS_PER_USD, CardBindTarget, CheckSeverity, ComposeError, ComposeIssueCode, ContentRole, ContentTypeAction, CreatedKind, DANGLING_LINK_CODES, DEFAULT_PHONE_MENU_BREAKPOINT, DEFAULT_SITE_LOCALE, ENTRANCE_EASING, ENTRANCE_ORIGIN_LOOK, ENTRANCE_TRIGGER, EntrancePart, EntranceSide, FACT_KEYS, FINAL_GENERATION_STAGES, FINDING_SEVERITY, GRAMMAR_TOO_LARGE_MESSAGE, GlobalSlot, HEBREW_PREFIXES, JSON_SCHEMA_BANNED_KEYWORDS, JsonSchemaShapeError, KeptReason, NAV_PART_LABELS, NAV_SHAPE_COPY, NEUTRAL_STYLE_PROPS, NODE_BASELINES, NO_TOKENS, NavPagesSource, NavRole, NavShape, NavShapeError, PAGE_ARCHETYPES, PLAN_EDITS_ANSWER_SHAPES, PLAN_EDITS_PART_SCHEMA_NAMES, PLAN_EDIT_OP_PART, PLAN_EDIT_SHAPE, PLAN_EDIT_WIRE_KEYS, PLAN_PARTS, PLAN_PART_KEYS, PLAN_WIRE_DERIVED, PLAN_WIRE_PART_SCHEMA_NAMES, PLAN_WIRE_SHAPE, PRICING_PATTERN, PUBLISH_BLOCKING_PLACEHOLDERS, PageDataKind, PathIssueCollector, PatternDirective, PatternGlobalPosition, PatternOpens, PatternPlaceholder, PatternRecipe, PhoneMenuBreakpoint, PlaceholderKind, PlanChangeAction, PlanChangeKind, PlanDataSourceMode, PlanDataSourceType, PlanEditIssueCode, PlanEditOp, PlanEditsOutcome, PlanPart, PlanWireEntry, PlanWireShapeError, PlanWordIssueCode, PreflightRefusalCode, PublishRefusalCode, REQUIRED_CONTENT_ROLES, RelativeOp, SAMPLE_IMAGE_REF, SITE_CHECKS, SITE_FACT_LABELS, SITE_HEADER_PATTERN, SITE_LIBRARY, SITE_LOCALES, SITE_NEED_IDS, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_ARCHETYPES, SPEC_SITE_COPY_LENGTHS, SPEC_SITE_DENSITY_COPY, SPEC_SITE_FACTS, SPEC_SITE_LIBRARY, SPEC_SITE_LOCALES, SPEC_SITE_NEED_IDS, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES, SPEC_SITE_TEMPLATES, STOCK_FAMILIES, STOCK_PROVIDER_TERMS, STRICT_SCHEMA_BUDGET, STRICT_SCHEMA_LIMITS, STUDIO_TEMPLATE, SiteBuildKind, SiteBuildStatus, SiteCheckId, SiteFindingCode, SiteLocale, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, StepState, StockProviderId, addBarItem, addStockCredits, aiStepParts, applyPlanEdits, applyStockPhotos, archetypeIssues, assertStyleSlots, assertValidSitePlan, auditSite, barIconForRoute, blocksPublish, buildKeys, buildStepFacts, builtHomePageId, composeNavHeader, composeSection, composeSite, contentTypePreviewFields, contentTypeToWire, copyLengthOf, costUsd, createSerializedNode, creditsOfUsd, dataSourceToWire, describePlanData, describeShapeKey, entranceAction, entranceInteraction, estimateStepCredits, expandPlanWire, expandWireContentType, expandWireDataSource, expandWireEdit, expandWireField, expandWirePage, expandWireSection, failedBuildStep, fieldToWire, findNavRoot, isActiveBuild, isNonEmptyString, isPlainObject, isRtlLocale, jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, jsonSchemaKeywords, jsonSchemaObjects, jsonShapedObject, jsonText, modelCanDo, moveBarItem, navPages, navShapeBreakpoint, navShapeOf, nextSourceOf, nodeBindPaths, pageToWire, parsePatternPath, parseSampleImageRef, pathAt, patternById, patternHasForm, patternLinkSlots, patternManifest, patternNeedsBinding, patternNodes, patternOptionalParts, patternRoleNeeds, patternScopes, patternSlotBudgets, patternSlots, patternTakesBinding, phoneMenuPages, planEditOps, planEditToWire, planEditWireGaps, planEditWireKeys, planEditWireOps, planEditWireShape, planEditsFromWire, planEditsJsonSchema, planEditsToWire, planEntrances, planFromTemplate, planNavShape, planPartOfPath, planPartToWire, planStockPhotos, planToWire, planWireGaps, planWirePartJsonSchema, planWirePartShape, planWordIssues, publishRefusal, publishVerdict, releasedLayoutDefaults, removeBarItem, requiredAcknowledgements, requiredShapeKeys, roleField, routeMatches, routeParams, sampleImageRef, sectionEntrances, sectionPatternIds, sectionToWire, setStyleProps, shapeKeys, showcaseGlobals, showcasePages, showcasePlans, siteNavPages, slotBudget, stockCopyKey, stockCreditLine, stockCreditLines, stockCreditNode, stockCreditNodeId, stockSlots, strictSchemaComplexity, strictSchemaRefusal, typicalStepTokens, validatePlanEdits, validateSitePlan, walkSerialized, wireContentTypeJsonSchema, wireDataSourceJsonSchema, wireFieldJsonSchema, wirePageJsonSchema, wireSectionJsonSchema, wordPattern, writtenTexts };
6558
- export type { AddBarItemResult, AiEntitlementsView, AiMeter, AiModelEntry, AiRefusal, AiTokenCounts, AiTokenPrice, AuditPage, AuditPlaceholder, BarItemsEditResult, BuildNotice, BuildStepError, BuildStepFacts, BuildStepRecord, CmsDataSourcePlan, ComposeContext, ComposeIssue, ComposedDataSource, ComposedItem, ComposedNavHeader, ComposedPage, ComposedPlaceholder, ComposedRoute, ComposedSection, ComposedSite, ContentFieldPlan, ContentRoles, ContentSeedPlan, ContentTypePlan, CreateNodeOptions, CreatedRecord, DataSourcePlan, DynamicKeysAsList, EntranceAction, EntranceActionOptions, EntranceInteraction, EntranceMark, EntranceSpec, EntranceStamp, FactKey, JsonSchema, NavHeaderInput, NavHeaderOptions, NavPage, NavShapeOptions, NavShapeResult, NodeBaseline, PageArchetype, PageArchetypeId, PageArchetypeSlot, PageData, PageDataLine, PagePlan, PageSectionPlan, PageSeoPlan, PathIssue, PatternManifestEntry, PatternNode, PatternPath, PatternRoleNeed, PlainObject, PlanChange, PlanEdit, PlanEditIssue, PlanEditResult, PlanEditsAnswer, PlanEntrancesInput, PlanWireContext, PlanWireParts, PlanWordContext, PlanWordIssue, PreflightRefusal, PublishRefusal, RelativeDataSourcePlan, SectionEntrances, SectionInput, SectionOptions, SerializedBinding, SerializedNode, SerializedResponsive, ShapeEntry, ShapeIssueCodes, ShowcaseInput, SiteAudit, SiteAuditOptions, SiteAuditSnapshot, SiteBuildRun, SiteBusiness, SiteCheck, SiteFinding, SiteNeedId, SitePageRef, SitePattern, SitePlan, SitePlanIssue, SitePlanSource, SitePlanValidation, SiteTemplate, StockCredit, StockLink, StockPhoto, StockPlacement, StockProviderTerms, StockSlot, StrictSchemaComplexity, StrictSchemaRefusal, TemplatePlanInput, UndoKept, UndoPreview, UndoRecord, WireOptions, WrittenText };
6581
+ export { ACTIVE_BUILD_STATUSES, AI_REFUSAL_NEXT, AI_REFUSAL_STATUS, AI_STEPS, AI_STEP_LABELS, AI_STEP_NEEDS, AI_STEP_PART_TOKENS, AI_STEP_TYPICAL_TOKENS, AiCallStatus, AiCapability, AiFeature, AiGenerationStage, AiKeyMode, AiLimit, AiNextKind, AiProviderId, AiRefusalCode, AiStep, BAR_ITEMS_MAX, BAR_ITEMS_MIN, BASELINE_ROLES, BUILD_STEPS, BaselineRole, BindScope, BuildNoticeCode, BuildStep, CONTENT_ROLE_MEANINGS, CREDITS_PER_USD, CardBindTarget, CheckSeverity, ComposeError, ComposeIssueCode, ContentRole, ContentTypeAction, CreatedKind, DANGLING_LINK_CODES, DEFAULT_PHONE_MENU_BREAKPOINT, DEFAULT_SITE_LOCALE, ENTRANCE_EASING, ENTRANCE_ORIGIN_LOOK, ENTRANCE_TRIGGER, EntrancePart, EntranceSide, FACT_KEYS, FINAL_GENERATION_STAGES, FINDING_SEVERITY, GRAMMAR_TOO_LARGE_MESSAGE, GlobalSlot, HEBREW_PREFIXES, JSON_SCHEMA_BANNED_KEYWORDS, JsonSchemaShapeError, KeptReason, NAV_PART_LABELS, NAV_SHAPE_COPY, NEUTRAL_STYLE_PROPS, NODE_BASELINES, NO_TOKENS, NavPagesSource, NavRole, NavShape, NavShapeError, PAGE_ARCHETYPES, PLAN_EDITS_ANSWER_SHAPES, PLAN_EDITS_PART_SCHEMA_NAMES, PLAN_EDIT_OP_PART, PLAN_EDIT_SHAPE, PLAN_EDIT_WIRE_KEYS, PLAN_PARTS, PLAN_PART_KEYS, PLAN_WIRE_DERIVED, PLAN_WIRE_PART_SCHEMA_NAMES, PLAN_WIRE_SHAPE, PRICING_PATTERN, PUBLISH_BLOCKING_PLACEHOLDERS, PageDataKind, PathIssueCollector, PatternDirective, PatternGlobalPosition, PatternOpens, PatternPlaceholder, PatternRecipe, PhoneMenuBreakpoint, PlaceholderKind, PlanChangeAction, PlanChangeKind, PlanDataSourceMode, PlanDataSourceType, PlanEditIssueCode, PlanEditOp, PlanEditsOutcome, PlanPart, PlanWireEntry, PlanWireShapeError, PlanWordIssueCode, PreflightRefusalCode, PublishRefusalCode, REQUIRED_CONTENT_ROLES, RelativeOp, SAMPLE_IMAGE_REF, SITE_CHECKS, SITE_FACT_LABELS, SITE_HEADER_PATTERN, SITE_LIBRARY, SITE_LOCALES, SITE_NEED_IDS, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_ARCHETYPES, SPEC_SITE_COPY_LENGTHS, SPEC_SITE_DENSITY_COPY, SPEC_SITE_FACTS, SPEC_SITE_LIBRARY, SPEC_SITE_LOCALES, SPEC_SITE_NEED_IDS, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES, SPEC_SITE_TEMPLATES, STOCK_FAMILIES, STOCK_PROVIDER_TERMS, STRICT_SCHEMA_BUDGET, STRICT_SCHEMA_LIMITS, STUDIO_TEMPLATE, SiteBuildKind, SiteBuildStatus, SiteCheckId, SiteFindingCode, SiteLocale, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, StepState, StockProviderId, addBarItem, addStockCredits, aiStepParts, applyPlanEdits, applyStockPhotos, archetypeIssues, assertStyleSlots, assertValidSitePlan, auditSite, barIconForRoute, blocksPublish, buildKeys, buildStepFacts, builtHomePageId, composeNavHeader, composeSection, composeSite, contentTypePreviewFields, contentTypeToWire, copyLengthOf, costUsd, createSerializedNode, creditsOfUsd, dataSourceToWire, derivedDataSources, describePlanData, describeShapeKey, entranceAction, entranceInteraction, estimateStepCredits, expandPlanWire, expandWireContentType, expandWireDataSource, expandWireEdit, expandWireField, expandWirePage, expandWireSection, failedBuildStep, fieldToWire, findNavRoot, isActiveBuild, isNonEmptyString, isPlainObject, isRtlLocale, jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, jsonSchemaKeywords, jsonSchemaObjects, jsonShapedObject, jsonText, modelCanDo, moveBarItem, navPages, navShapeBreakpoint, navShapeOf, nextSourceOf, nodeBindPaths, pageToWire, parsePatternPath, parseSampleImageRef, pathAt, patternById, patternHasForm, patternLinkSlots, patternListGap, patternManifest, patternNeedsBinding, patternNodes, patternOptionalParts, patternRoleNeeds, patternScopes, patternSlotBudgets, patternSlots, patternTakesBinding, phoneMenuPages, planEditOps, planEditToWire, planEditWireGaps, planEditWireKeys, planEditWireOps, planEditWireShape, planEditsFromWire, planEditsJsonSchema, planEditsToWire, planEntrances, planFromTemplate, planNavShape, planPartOfPath, planPartToWire, planStockPhotos, planToWire, planWireGaps, planWirePartJsonSchema, planWirePartShape, planWordIssues, publishRefusal, publishVerdict, recipeListBinds, releasedLayoutDefaults, removeBarItem, requiredAcknowledgements, requiredShapeKeys, roleField, routeMatches, routeParams, sampleImageRef, sectionEntrances, sectionPatternIds, sectionToWire, setStyleProps, shapeKeys, showcaseGlobals, showcasePages, showcasePlans, siteNavPages, slotBudget, stockCopyKey, stockCreditLine, stockCreditLines, stockCreditNode, stockCreditNodeId, stockSlots, strictSchemaComplexity, strictSchemaRefusal, typicalStepTokens, validatePlanEdits, validateSitePlan, walkSerialized, wireContentTypeJsonSchema, wireDataSourceJsonSchema, wireFieldJsonSchema, wirePageJsonSchema, wireSectionJsonSchema, withDerivedSources, wordPattern, writtenTexts };
6582
+ export type { AddBarItemResult, AiEntitlementsView, AiMeter, AiModelEntry, AiRefusal, AiTokenCounts, AiTokenPrice, AuditPage, AuditPlaceholder, BarItemsEditResult, BuildNotice, BuildStepError, BuildStepFacts, BuildStepRecord, CmsDataSourcePlan, ComposeContext, ComposeIssue, ComposedDataSource, ComposedItem, ComposedNavHeader, ComposedPage, ComposedPlaceholder, ComposedRoute, ComposedSection, ComposedSite, ContentFieldPlan, ContentRoles, ContentSeedPlan, ContentTypePlan, CreateNodeOptions, CreatedRecord, DataSourcePlan, DynamicKeysAsList, EntranceAction, EntranceActionOptions, EntranceInteraction, EntranceMark, EntranceSpec, EntranceStamp, FactKey, JsonSchema, NavHeaderInput, NavHeaderOptions, NavPage, NavShapeOptions, NavShapeResult, NodeBaseline, PageArchetype, PageArchetypeId, PageArchetypeSlot, PageData, PageDataLine, PagePlan, PageSectionPlan, PageSeoPlan, PathIssue, PatternManifestEntry, PatternNode, PatternPath, PatternRoleNeed, PlainObject, PlanChange, PlanEdit, PlanEditIssue, PlanEditResult, PlanEditsAnswer, PlanEntrancesInput, PlanWireContext, PlanWireParts, PlanWordContext, PlanWordIssue, PreflightRefusal, PublishRefusal, RecipeListBinds, RelativeDataSourcePlan, SectionEntrances, SectionInput, SectionOptions, SerializedBinding, SerializedNode, SerializedResponsive, ShapeEntry, ShapeIssueCodes, ShowcaseInput, SiteAudit, SiteAuditOptions, SiteAuditSnapshot, SiteBuildRun, SiteBusiness, SiteCheck, SiteFinding, SiteNeedId, SitePageRef, SitePattern, SitePlan, SitePlanIssue, SitePlanSource, SitePlanValidation, SiteTemplate, StockCredit, StockLink, StockPhoto, StockPlacement, StockProviderTerms, StockSlot, StrictSchemaComplexity, StrictSchemaRefusal, TemplatePlanInput, UndoKept, UndoPreview, UndoRecord, WireOptions, WrittenText };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.63.0",
3
+ "version": "5.65.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * How a dialog LEAVES (CC1 follow-up — the drawer that vanished). Opening is pure CSS (`@starting-style`
3
+ * in the shared `iox-design.css`); closing needs one beat of script, because `dialog.close()` drops the
4
+ * top layer at once and only some browsers can hold it for a transition (`overlay … allow-discrete`).
5
+ *
6
+ * The ONE exit every renderer runs: mark the element closing (`data-iox-closing` — the shared CSS gives it
7
+ * its closed look: the drawer back off its edge, the scrim clear), wait for the transitions that started,
8
+ * then finish. Reduced motion (or a stub with no animations) finishes now. Framework-free: the engine's
9
+ * every close path (actions, the X, the backdrop, Esc, the close-above watcher) and the builder canvas's
10
+ * stage share it.
11
+ */
12
+
13
+ export const DIALOG_CLOSING_ATTRIBUTE = 'data-iox-closing';
14
+
15
+ /** Longest an exit may hold the element before it finishes anyway — a transition that never ends (a
16
+ * value that did not change, a tab in the background) must not leave a dialog half-closed. */
17
+ export const DIALOG_EXIT_TIMEOUT_MS = 700;
18
+
19
+ export interface ExitAnimationLike {
20
+ finished: Promise<unknown>;
21
+ effect?: { getComputedTiming?: () => { endTime?: number | null } } | null;
22
+ }
23
+
24
+ export interface ExitMotionElement {
25
+ setAttribute(name: string, value: string): void;
26
+ removeAttribute(name: string): void;
27
+ hasAttribute(name: string): boolean;
28
+ getAnimations?: (options?: { subtree?: boolean }) => ExitAnimationLike[];
29
+ }
30
+
31
+ export interface DialogElementLike extends ExitMotionElement {
32
+ open: boolean;
33
+ close(): void;
34
+ }
35
+
36
+ export interface ExitMotionOptions {
37
+ /** False under reduced motion: finish at once (the caller asks `motionAllowed()`). */
38
+ animate: boolean;
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ /** True while an exit is running on the element (a second close waits on it; an open cancels it). */
43
+ export function isExiting(el: ExitMotionElement | null | undefined): boolean {
44
+ return !!el?.hasAttribute(DIALOG_CLOSING_ATTRIBUTE);
45
+ }
46
+
47
+ /** Stop an exit that is running (the dialog was opened again mid-close): it stays open. */
48
+ export function cancelExit(el: ExitMotionElement | null | undefined): void {
49
+ el?.removeAttribute(DIALOG_CLOSING_ATTRIBUTE);
50
+ }
51
+
52
+ const finite = (a: ExitAnimationLike) => {
53
+ const end = a.effect?.getComputedTiming?.().endTime;
54
+ return end === undefined || end === null || Number.isFinite(end);
55
+ };
56
+
57
+ /**
58
+ * Run the exit: mark the element, wait for its finite animations (its subtree's too — the drawer's links,
59
+ * the `::backdrop`) or the timeout. Resolves TRUE when the exit completed and the caller should finish
60
+ * (close / remove), FALSE when it was cancelled meanwhile. The mark stays on until the caller finishes.
61
+ */
62
+ export async function runExitMotion(el: ExitMotionElement, options: ExitMotionOptions): Promise<boolean> {
63
+ el.setAttribute(DIALOG_CLOSING_ATTRIBUTE, '');
64
+ if (!options.animate) return true;
65
+ // Reading the animations flushes style, so the transitions the mark just started are in the list.
66
+ const running = (el.getAnimations?.({ subtree: true }) ?? []).filter(finite);
67
+ if (running.length) {
68
+ const timeout = options.timeoutMs ?? DIALOG_EXIT_TIMEOUT_MS;
69
+ let timer: ReturnType<typeof setTimeout> | undefined;
70
+ await Promise.race([
71
+ Promise.all(running.map(a => a.finished.catch(() => undefined))),
72
+ new Promise<void>(resolve => { timer = setTimeout(resolve, timeout); }),
73
+ ]);
74
+ if (timer !== undefined) clearTimeout(timer);
75
+ }
76
+ return isExiting(el);
77
+ }
78
+
79
+ const pending = new WeakMap<object, Promise<void>>();
80
+
81
+ /**
82
+ * Close a live `<dialog>` with its exit motion. A second call while it is closing joins the first; an open
83
+ * in between (`cancelExit`) keeps it open. The dialog's own `close` event still fires once, after the
84
+ * motion — so whatever a runtime does on close (restart the page scroll, sync the openers, return focus)
85
+ * happens when the dialog is really gone.
86
+ */
87
+ export function closeDialogAnimated(el: DialogElementLike, options: ExitMotionOptions): Promise<void> {
88
+ if (!el.open) return Promise.resolve();
89
+ const running = pending.get(el);
90
+ if (running && isExiting(el)) return running;
91
+ const done = runExitMotion(el, options).then(finish => {
92
+ pending.delete(el);
93
+ if (!finish) return;
94
+ if (el.open) el.close();
95
+ cancelExit(el);
96
+ });
97
+ pending.set(el, done);
98
+ return done;
99
+ }
100
+
101
+ /** True when a toggle should CLOSE the dialog: it is open and not already on its way out. */
102
+ export function dialogTogglesClosed(el: Pick<DialogElementLike, 'open' | 'hasAttribute'>): boolean {
103
+ return el.open && !el.hasAttribute(DIALOG_CLOSING_ATTRIBUTE);
104
+ }
@@ -12,3 +12,4 @@ export * from './action.model';
12
12
  export * from './availability';
13
13
  export * from './element-actions';
14
14
  export * from './dialog-opener';
15
+ export * from './dialog-motion';
@@ -4,9 +4,9 @@ import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-u
4
4
  import {
5
5
  ACCORDION_BIND_PROP, ACCORDION_CONTENT_LIMIT, ACCORDION_WORDS, accordionAnswerOf, accordionItems, accordionQuestionOf, AccordionRole,
6
6
  buildAccordionRecipe, buildCardRecipe, buildCarouselRecipe, buildLightboxRecipe, CardLayout, CardPart, CardRole, CARD_LAYOUTS, CARD_PARTS, CompositeNode,
7
- CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, pickAccordionAutoBinds, pickTabsAutoBinds, SLIDE_TO_ROW, SLIDE_TYPE, buildTabsRecipe, tabLabelNodeOf,
7
+ AccordionAutoBinds, CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, SLIDE_TO_ROW, SLIDE_TYPE, buildTabsRecipe, tabLabelNodeOf,
8
8
  tabPanelImageOf, tabPanelTextOf, tabsListOf, tabsPanels, tabsPanelsOf, tabsTriggers, TABS_CONTENT_LIMIT, TABS_IMAGE_BIND_PROP, TABS_LABEL_BIND_PROP,
9
- TABS_TEXT_BIND_PROP, TABS_WORDS, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
9
+ TABS_TEXT_BIND_PROP, TABS_WORDS, TabsAutoBinds, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
10
10
  } from '@vectoriox/iox-ui/composite-core';
11
11
 
12
12
  import { EntranceMark, EntrancePart, planEntrances } from './entrances';
@@ -14,6 +14,7 @@ import { NODE_BASELINES } from './node-baselines.generated';
14
14
  import {
15
15
  BindScope, CardBindTarget, parsePatternPath, PatternDirective, PatternNode, PatternOpens, PatternPlaceholder, PatternRecipe, SitePattern, patternById,
16
16
  } from './patterns';
17
+ import { recipeListBinds } from './recipe-fields';
17
18
  import { assertStyleSlots, ComposeError, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps, walkSerialized } from './serialized-node';
18
19
  import {
19
20
  ContentRole, ContentTypePlan, DataSourcePlan, DEFAULT_SITE_LOCALE, FactKey, FACT_KEYS, GlobalSlot, isRtlLocale, PagePlan, PageSectionPlan, PlanDataSourceMode,
@@ -838,6 +839,15 @@ function composeSegmented(pn: PatternNode, id: string): SerializedNode {
838
839
  return root;
839
840
  }
840
841
 
842
+ /** The content type a section binding lists, through its data source. */
843
+ function boundContentType(binding: string, ctx: Ctx): ContentTypePlan | undefined {
844
+ const source = ctx.plan.dataSources.find(d => d.alias === binding);
845
+ return source && 'contentType' in source ? ctx.plan.contentTypes.find(t => t.name === source.contentType) : undefined;
846
+ }
847
+
848
+ /** A type's fields as composite-core's pickers read them. */
849
+ const fieldsOf = (type: ContentTypePlan | undefined) => (type?.fields ?? []).map(f => ({ name: f.name, type: f.type, controller: f.controller }));
850
+
841
851
  /**
842
852
  * An Accordion (CC2) — the composite-core recipe stamped with this composer's factory. Manual (no section
843
853
  * binding): three items whose questions and answers are placeholders the studio rewrites (`ph: 'copy'`), never
@@ -864,26 +874,17 @@ function composeAccordion(pn: PatternNode, id: string, ctx: Ctx): SerializedNode
864
874
  }
865
875
  return root;
866
876
  }
867
- const source = ctx.plan.dataSources.find(d => d.alias === binding);
868
- const type = source && 'contentType' in source ? ctx.plan.contentTypes.find(t => t.name === source.contentType) : undefined;
869
- const picks = pickAccordionAutoBinds((type?.fields ?? []).map(f => ({ name: f.name, type: f.type, controller: f.controller })));
870
- if (!picks.question || !picks.answer) {
871
- throw new ComposeError(`${id}: "${binding}" has no short-text field for the question and long-text field for the answer`);
872
- }
877
+ const { picks, gap } = recipeListBinds<AccordionAutoBinds>(pn.recipe, fieldsOf(boundContentType(binding, ctx)));
878
+ if (gap) throw new ComposeError(`${id}: "${binding}" has no ${gap}`);
873
879
  addBinding(root, { prop: 'source', source: binding, path: '' });
874
880
  setProp(root, 'limit', ACCORDION_CONTENT_LIMIT);
875
881
  const [template] = accordionItems(root as CompositeNode);
876
882
  delete template.itemKey;
877
- addBinding(accordionQuestionOf(template) as SerializedNode, { prop: ACCORDION_BIND_PROP, source: binding, path: picks.question });
878
- addBinding(accordionAnswerOf(template) as SerializedNode, { prop: ACCORDION_BIND_PROP, source: binding, path: picks.answer });
883
+ addBinding(accordionQuestionOf(template) as SerializedNode, { prop: ACCORDION_BIND_PROP, source: binding, path: picks.question! });
884
+ addBinding(accordionAnswerOf(template) as SerializedNode, { prop: ACCORDION_BIND_PROP, source: binding, path: picks.answer! });
879
885
  return root;
880
886
  }
881
887
 
882
- /** The content type a section binding lists, through its data source. */
883
- function boundContentType(binding: string, ctx: Ctx): ContentTypePlan | undefined {
884
- const source = ctx.plan.dataSources.find(d => d.alias === binding);
885
- return source && 'contentType' in source ? ctx.plan.contentTypes.find(t => t.name === source.contentType) : undefined;
886
- }
887
888
 
888
889
  const TABS_ROOT_KEY = 'tabs';
889
890
  const TABS_HOLDER_KEYS: ReadonlySet<string> = new Set(['list', 'panels']);
@@ -920,11 +921,8 @@ function composeTabs(pn: PatternNode, id: string, ctx: Ctx): SerializedNode {
920
921
  }
921
922
  return root;
922
923
  }
923
- const type = boundContentType(binding, ctx);
924
- const picks = pickTabsAutoBinds((type?.fields ?? []).map(f => ({ name: f.name, type: f.type, controller: f.controller })));
925
- if (!picks.label || !picks.text) {
926
- throw new ComposeError(`${id}: "${binding}" has no short-text field for the tab and long-text field for its panel`);
927
- }
924
+ const { picks, gap } = recipeListBinds<TabsAutoBinds>(pn.recipe, fieldsOf(boundContentType(binding, ctx)));
925
+ if (gap) throw new ComposeError(`${id}: "${binding}" has no ${gap}`);
928
926
  for (const holder of [tabsListOf(tree), tabsPanelsOf(tree)] as SerializedNode[]) {
929
927
  addBinding(holder, { prop: 'source', source: binding, path: '' });
930
928
  setProp(holder, 'limit', TABS_CONTENT_LIMIT);
@@ -933,12 +931,12 @@ function composeTabs(pn: PatternNode, id: string, ctx: Ctx): SerializedNode {
933
931
  const [panel] = tabsPanels(tree);
934
932
  delete tab.itemKey;
935
933
  delete panel.itemKey;
936
- addBinding(tabLabelNodeOf(tab) as SerializedNode, { prop: TABS_LABEL_BIND_PROP, source: binding, path: picks.label });
937
- addBinding(tabPanelTextOf(panel) as SerializedNode, { prop: TABS_TEXT_BIND_PROP, source: binding, path: picks.text });
934
+ addBinding(tabLabelNodeOf(tab) as SerializedNode, { prop: TABS_LABEL_BIND_PROP, source: binding, path: picks.label! });
935
+ addBinding(tabPanelTextOf(panel) as SerializedNode, { prop: TABS_TEXT_BIND_PROP, source: binding, path: picks.text! });
938
936
  const image = tabPanelImageOf(panel) as SerializedNode | undefined;
939
937
  if (image && picks.image) {
940
938
  addBinding(image, { prop: TABS_IMAGE_BIND_PROP, source: binding, path: picks.image });
941
- addBinding(image, { prop: 'alt', source: binding, path: picks.label });
939
+ addBinding(image, { prop: 'alt', source: binding, path: picks.label! });
942
940
  }
943
941
  return root;
944
942
  }
@@ -252,8 +252,18 @@ function makeKit(globals: ReadonlyArray<SerializedNode>, rootId: string, newId?:
252
252
 
253
253
  /** Point an opener's dialog actions at `dialogId` (a drawer rebuilt under a new id keeps its openers working). */
254
254
  function retarget(node: SerializedNode, dialogId: string): SerializedNode {
255
- for (const ix of node.interactions as Array<{ actions?: Array<{ type?: string; target?: string }> }>) {
256
- for (const a of ix.actions ?? []) if (a.type === 'openDialog' || a.type === 'toggleDialog') a.target = dialogId;
255
+ for (const ix of (node.interactions ?? []) as Array<{ actions?: Array<{ type?: string; target?: string }> }>) {
256
+ for (const a of ix.actions ?? []) if (a.type === NavDialogAction.Open || a.type === NavDialogAction.Toggle) a.target = dialogId;
257
+ }
258
+ return asToggle(node);
259
+ }
260
+
261
+ /** The menu button and More are TOGGLES (a second click closes what the first opened): a part saved before that
262
+ * rule opened only — kept through a change of shape, it toggles from then on. */
263
+ function asToggle(node: SerializedNode): SerializedNode {
264
+ for (const ix of (node.interactions ?? []) as Array<{ trigger?: string; actions?: Array<{ type?: string }> }>) {
265
+ if (ix.trigger !== CLICK) continue;
266
+ for (const a of ix.actions ?? []) if (a.type === NavDialogAction.Open) a.type = NavDialogAction.Toggle;
257
267
  }
258
268
  return node;
259
269
  }
@@ -267,7 +277,14 @@ function part(kit: Kit, suffix: string, type: string, props: Record<string, unkn
267
277
  return node;
268
278
  }
269
279
 
270
- function clickTo(kit: Kit, suffix: string, type: 'openDialog' | 'closeDialog', target: string) {
280
+ /** The dialog actions a nav part runs on click. */
281
+ enum NavDialogAction {
282
+ Open = 'openDialog',
283
+ Close = 'closeDialog',
284
+ Toggle = 'toggleDialog',
285
+ }
286
+
287
+ function clickTo(kit: Kit, suffix: string, type: NavDialogAction, target: string) {
271
288
  return { id: kit.id(`${suffix}-click`), trigger: CLICK, actions: [{ type, target, duration: 0, delay: 0, easing: 'ease' }] };
272
289
  }
273
290
 
@@ -302,11 +319,15 @@ function labelText(kit: Kit, suffix: string, words: string): SerializedNode {
302
319
  return part(kit, suffix, TEXT, { content: words, tag: 'span' }, 'button-label');
303
320
  }
304
321
 
322
+ /** The menu button and the drawer's Close are ONE size — and the shared CSS pads the drawer like the header
323
+ * (`.iox-role-nav-drawer` re-declares dialog-pad as nav-pad) — so the Close lands where the menu button is and
324
+ * reads as the same button turned into an X. */
325
+ const MENU_BUTTON_LAY = { width: '48px', height: '48px', alignItems: 'center', justifyContent: 'center' };
326
+
305
327
  function menuButton(kit: Kit, drawerId: string, at: string): SerializedNode {
306
- const btn = part(kit, 'menu', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.menu }, 'button-ghost', NavRole.MenuButton,
307
- { width: '48px', height: '48px', alignItems: 'center', justifyContent: 'center' });
328
+ const btn = part(kit, 'menu', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.menu }, 'button-ghost', NavRole.MenuButton, { ...MENU_BUTTON_LAY });
308
329
  btn.children = [iconNode(kit, 'menu-icon', MENU_ICON)];
309
- btn.interactions = [clickTo(kit, 'menu', 'openDialog', drawerId)];
330
+ btn.interactions = [clickTo(kit, 'menu', NavDialogAction.Toggle, drawerId)];
310
331
  phoneOnly(btn, at, 'inline-flex');
311
332
  return btn;
312
333
  }
@@ -322,10 +343,9 @@ function drawer(kit: Kit, root: SerializedNode, pages: NavPage[], at: string): S
322
343
  const brandWords = textOf(find([root], NavRole.Brand) ?? root) || NAV_PART_LABELS[NavRole.Root];
323
344
  const head = part(kit, 'drawer-head', CONTAINER, {}, 'box', NavRole.DrawerHead,
324
345
  { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px' });
325
- const close = part(kit, 'drawer-close', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.closeMenu }, 'button-ghost', NavRole.DrawerClose,
326
- { width: '44px', height: '44px', alignItems: 'center', justifyContent: 'center' });
346
+ const close = part(kit, 'drawer-close', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.closeMenu }, 'button-ghost', NavRole.DrawerClose, { ...MENU_BUTTON_LAY });
327
347
  close.children = [iconNode(kit, 'drawer-close-icon', CLOSE_ICON)];
328
- close.interactions = [clickTo(kit, 'drawer-close', 'closeDialog', dr.id)];
348
+ close.interactions = [clickTo(kit, 'drawer-close', NavDialogAction.Close, dr.id)];
329
349
  head.children = [part(kit, 'drawer-brand', HEADING, { content: brandWords, level: 'h3' }, 'h3'), close];
330
350
  const list = part(kit, 'drawer-links', CONTAINER, {}, 'box', NavRole.DrawerLinks,
331
351
  { display: 'flex', flexDirection: 'column', gap: '4px' });
@@ -364,7 +384,7 @@ const BAR_ITEM_LAY = {
364
384
  function moreItem(kit: Kit, drawerId: string): SerializedNode {
365
385
  const more = part(kit, 'bar-more', BUTTON, { tag: 'button' }, 'bottom-bar-item', NavRole.More, BAR_ITEM_LAY);
366
386
  more.children = [iconNode(kit, 'bar-more-icon', MORE_ICON), labelText(kit, 'bar-more-label', NAV_SHAPE_COPY.more)];
367
- more.interactions = [clickTo(kit, 'bar-more', 'openDialog', drawerId)];
387
+ more.interactions = [clickTo(kit, 'bar-more', NavDialogAction.Toggle, drawerId)];
368
388
  return more;
369
389
  }
370
390
 
@@ -472,7 +492,7 @@ export function planNavShape(globals: ReadonlyArray<SerializedNode>, shape: NavS
472
492
 
473
493
  // The menu button: Drawer only (Q4: with a bar, the phone header keeps only the brand).
474
494
  const existingMenu = removeRole(root, NavRole.MenuButton);
475
- const menu = wantMenuButton ? (existingMenu ?? menuButton(kit, dr!.id, at)) : null;
495
+ const menu = wantMenuButton ? (existingMenu ? asToggle(existingMenu) : menuButton(kit, dr!.id, at)) : null;
476
496
 
477
497
  for (const p of phoneHiddenParts(root)) { moveDisplayOverride(p, at); hideAt(p, at); }
478
498
  centerBrandAt(root, at, wantBar);
@@ -0,0 +1,84 @@
1
+ import { BindScope, patternById, patternScopes } from './patterns';
2
+ import { DataSourcePlan, PlanDataSourceMode, PlanDataSourceType, RelativeOp } from './site-plan.model';
3
+ import { isNonEmptyString, isPlainObject, PlainObject } from './shape-check';
4
+ import { nextSourceOf } from './validate-site-plan';
5
+
6
+ /**
7
+ * What a plan reads but no model is asked to write (vectoriox/iox-cms#49): the data sources that follow from the pages
8
+ * alone. A per-item page whose sections read the NEXT item (`project.next`) needs a `$siblings.next` source of its route
9
+ * source — a fact of the pages, while data sources are the plan's STRUCTURE, asked first and fixed once accepted. Asking
10
+ * the structure for it made the pages' answer unfixable by construction (a re-ask of the pages cannot add a source), so
11
+ * it is derived, the same way for the plan wire's expansion (`expandPlanWire`) and for a sentence's page edits
12
+ * (`applyPlanEdits`). Pure and idempotent: a plan that already holds the source gains nothing; a source is derived only
13
+ * where it is valid by construction (its subject a single cms source of a type of the plan), so it never adds an issue
14
+ * at a structure path. Its siblings are walked within an unlimited list of the same type — the structure's first, else a
15
+ * derived one.
16
+ */
17
+
18
+ const NEXT_SUFFIX = 'Next';
19
+ const LIST_SUFFIX = 'All';
20
+
21
+ interface SourceView { type: string; mode?: string; contentType?: string; limit?: unknown; of?: string; op?: string; }
22
+
23
+ /** The sources by alias, as the validator reads them (the first of an alias wins). */
24
+ function sourceViews(list: ReadonlyArray<unknown>): Map<string, SourceView> {
25
+ const views = new Map<string, SourceView>();
26
+ for (const raw of list) {
27
+ if (isPlainObject(raw) && isNonEmptyString(raw['alias']) && !views.has(raw['alias'])) views.set(raw['alias'], raw as unknown as SourceView);
28
+ }
29
+ return views;
30
+ }
31
+
32
+ const readsNext = (page: PlainObject) => Array.isArray(page['sections']) && page['sections'].some(s => {
33
+ const pattern = isPlainObject(s) && typeof s['pattern'] === 'string' ? patternById(s['pattern']) : undefined;
34
+ return !!pattern && patternScopes(pattern).has(BindScope.Next);
35
+ });
36
+
37
+ /** The alias itself, else the first of `<alias>2`, `<alias>3`… no source has. */
38
+ function freeAlias(base: string, taken: ReadonlyMap<string, unknown>): string {
39
+ let alias = base;
40
+ for (let n = 2; taken.has(alias); n++) alias = `${base}${n}`;
41
+ return alias;
42
+ }
43
+
44
+ /** The unlimited list of `contentType` the plan holds (its first), or null. */
45
+ function fullListOf(views: ReadonlyMap<string, SourceView>, contentType: string): string | null {
46
+ for (const [alias, s] of views) {
47
+ if (s.type === PlanDataSourceType.Cms && s.mode === PlanDataSourceMode.Query && s.contentType === contentType && s.limit === undefined) return alias;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * The data sources `plan` lacks and its pages need, in page order (appended after the plan's own). Tolerates any
54
+ * value: what is not a well-formed plan gains nothing, for the validator to judge as written.
55
+ */
56
+ export function derivedDataSources(plan: unknown): DataSourcePlan[] {
57
+ if (!isPlainObject(plan) || !Array.isArray(plan['dataSources']) || !Array.isArray(plan['pages']) || !Array.isArray(plan['contentTypes'])) return [];
58
+ const types = new Set(plan['contentTypes'].map(t => (isPlainObject(t) ? t['name'] : undefined)).filter(isNonEmptyString));
59
+ const views = sourceViews(plan['dataSources']);
60
+ const derived: DataSourcePlan[] = [];
61
+ const add = (source: DataSourcePlan) => { derived.push(source); views.set(source.alias, source); };
62
+ for (const page of plan['pages']) {
63
+ if (!isPlainObject(page) || !isNonEmptyString(page['routeSource']) || !readsNext(page)) continue;
64
+ const subject = page['routeSource'];
65
+ const route = views.get(subject);
66
+ if (!route || route.type !== PlanDataSourceType.Cms || route.mode !== PlanDataSourceMode.Single) continue;
67
+ if (!isNonEmptyString(route.contentType) || !types.has(route.contentType) || nextSourceOf(views, subject)) continue;
68
+ let within = fullListOf(views, route.contentType);
69
+ if (!within) {
70
+ within = freeAlias(`${subject}${LIST_SUFFIX}`, views);
71
+ add({ alias: within, type: PlanDataSourceType.Cms, mode: PlanDataSourceMode.Query, contentType: route.contentType });
72
+ }
73
+ add({ alias: freeAlias(`${subject}${NEXT_SUFFIX}`, views), type: PlanDataSourceType.Relative, of: subject, op: RelativeOp.SiblingsNext, within });
74
+ }
75
+ return derived;
76
+ }
77
+
78
+ /** `plan` with the data sources its pages need appended (`derivedDataSources`); the same object when it lacks none. */
79
+ export function withDerivedSources<T>(plan: T): T {
80
+ const derived = derivedDataSources(plan);
81
+ if (!derived.length) return plan;
82
+ const own = plan as unknown as PlainObject;
83
+ return { ...own, dataSources: [...(own['dataSources'] as unknown[]), ...derived] } as T;
84
+ }
@@ -2,6 +2,7 @@ import {
2
2
  jsonAnyOf, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText,
3
3
  } from './json-schema';
4
4
  import { patternById, patternOptionalParts, patternSlots, patternTakesBinding } from './patterns';
5
+ import { derivedDataSources } from './plan-derived';
5
6
  import {
6
7
  contentTypeToWire, dataSourceToWire, expandWireContentType, expandWireDataSource, expandWireField, expandWirePage, expandWireSection, fieldToWire,
7
8
  pageToWire, PLAN_PARTS, PlanPart, PlanWireShapeError, sectionPatternIds, sectionToWire, wireContentTypeJsonSchema, wireDataSourceJsonSchema,
@@ -367,8 +368,9 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
367
368
 
368
369
  /**
369
370
  * Apply a set of edits to a plan — all of them, or none (any issue refuses the set and returns the plan unchanged). The
370
- * sources only removed pages and sections read are dropped with them; sources nothing read before are kept.
371
- * The result still has to pass `validateSitePlan`.
371
+ * sources only removed pages and sections read are dropped with them; sources nothing read before are kept; the sources
372
+ * the pages now need and no model writes are derived (`derivedDataSources` — a page edit adding `project.next`, as the
373
+ * plan wire's expansion does, vectoriox/iox-cms#49). The result still has to pass `validateSitePlan`.
372
374
  */
373
375
  export function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>): PlanEditResult {
374
376
  const structure = validatePlanEdits(edits);
@@ -384,8 +386,11 @@ export function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>):
384
386
  const added = new Set(changed.filter(ch => ch.kind === PlanChangeKind.DataSource).map(ch => ch.name));
385
387
  const dataSources = draft.dataSources.filter(s => after.has(s.alias) || !before.has(s.alias) || added.has(s.alias));
386
388
  for (const s of draft.dataSources) if (!dataSources.includes(s)) changed.push({ kind: PlanChangeKind.DataSource, action: PlanChangeAction.Removed, name: s.alias });
389
+ const edited = { ...plan, pages: draft.pages, contentTypes: draft.contentTypes, dataSources };
390
+ const derived = derivedDataSources(edited);
391
+ for (const s of derived) changed.push({ kind: PlanChangeKind.DataSource, action: PlanChangeAction.Added, name: s.alias });
387
392
 
388
- return { plan: { ...plan, pages: draft.pages, contentTypes: draft.contentTypes, dataSources }, changed, issues: [] };
393
+ return { plan: derived.length ? { ...edited, dataSources: [...dataSources, ...derived] } : edited, changed, issues: [] };
389
394
  }
390
395
 
391
396
  // ── The format a model writes edits in ─────────────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@ import { FORM_PRESETS } from '@vectoriox/iox-ui/forms-core';
3
3
 
4
4
  import { describeShapeKey, jsonAnyOf, jsonBoolean, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText } from './json-schema';
5
5
  import { patternLinkSlots, patternOptionalParts, patternSlots, PatternGlobalPosition, SITE_PATTERNS } from './patterns';
6
+ import { withDerivedSources } from './plan-derived';
6
7
  import {
7
8
  ContentFieldPlan, ContentTypePlan, DataSourcePlan, FactKey, GlobalSlot, PAGE_ARCHETYPES, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType,
8
9
  RelativeOp, SITE_NEED_IDS, SITE_PLAN_VERSION, SitePlan, SitePlanSource, SiteLocale,
@@ -30,6 +31,10 @@ import { isPlainObject, PlainObject, ShapeEntry, shapeKeys } from './shape-check
30
31
  * - **What the model does not decide is not on the wire** (`PLAN_WIRE_DERIVED`): the look (the owner's pick), the run,
31
32
  * the language (the reading's), the facts (the owner's own, verbatim), the version, the home page (the page at `/`).
32
33
  * So the format is the same for every studio and every run — one compiled grammar, cached by the provider.
34
+ * - **What follows from the pages is derived, never asked** (`plan-derived.ts`, vectoriox/iox-cms#49): a per-item page
35
+ * reading the next item gets its `$siblings.next` source — the structure, asked first and then fixed, cannot know the
36
+ * pages need it, and the pages cannot add a source. `planToWire` writes every source the plan holds, a derived one
37
+ * too (it is the plan's: a change's brief shows it, and a plan → wire → plan round trip stays exact).
33
38
  *
34
39
  * ONE source: every wire key names the plan keys it carries (`refs`) and is described in the plan table's own words, and
35
40
  * every plan key is either carried or derived (`planWireGaps()`); a key the plan shape gains breaks the builder until it
@@ -91,7 +96,7 @@ const WIRE_KEYS: Readonly<Record<`${PlanWireEntry}`, Readonly<Record<string, Wir
91
96
  plan: {
92
97
  business: inPart(PlanPart.Structure, key('plan.business')),
93
98
  contentTypes: inPart(PlanPart.Structure, key('plan.contentTypes')),
94
- dataSources: inPart(PlanPart.Structure, { refs: ['plan.dataSources'], text: 'dataSource[] — merged by alias into the org library' }),
99
+ dataSources: inPart(PlanPart.Structure, { refs: ['plan.dataSources'], text: "dataSource[] — merged by alias into the org library; a per-item page's $siblings.next source (what a section reading the next item needs) is added for you" }),
95
100
  pages: inPart(PlanPart.Pages, key('plan.pages')),
96
101
  globals: inPart(PlanPart.Pages, key('plan.globals')),
97
102
  },
@@ -465,7 +470,7 @@ export type PlanWireParts = Partial<Record<PlanPart, unknown>>;
465
470
  * A model's plan (its parts, on the wire) + what the generation decides (the look, the run, the language, the owner's
466
471
  * facts) → the Site Plan. Pure. Translates, never repairs: whatever an answer got wrong is still wrong, for
467
472
  * `validateSitePlan` — a part that is not an object is the result itself, a part not written yet leaves its keys out
468
- * (the validator then names them, at their own paths).
473
+ * (the validator then names them, at their own paths). What follows from the pages alone is added (`withDerivedSources`).
469
474
  */
470
475
  export function expandPlanWire(parts: PlanWireParts, context: PlanWireContext): SitePlan | unknown {
471
476
  const written = PLAN_PARTS.filter(part => parts[part] !== undefined).map(part => parts[part]);
@@ -473,7 +478,7 @@ export function expandPlanWire(parts: PlanWireParts, context: PlanWireContext):
473
478
  if (odd !== undefined) return odd;
474
479
  const wire: PlainObject = Object.assign({}, ...(written as PlainObject[]));
475
480
  const { business, contentTypes, dataSources, pages, ...rest } = wire;
476
- return {
481
+ return withDerivedSources({
477
482
  ...rest,
478
483
  version: SITE_PLAN_VERSION,
479
484
  source: { ...context.source },
@@ -483,7 +488,7 @@ export function expandPlanWire(parts: PlanWireParts, context: PlanWireContext):
483
488
  contentTypes: each(contentTypes, expandWireContentType),
484
489
  dataSources: each(dataSources, expandWireDataSource),
485
490
  pages: each(pages, expandWirePage),
486
- };
491
+ });
487
492
  }
488
493
 
489
494
  // ── The plan → the wire ────────────────────────────────────────────────────────────────────────────────────────────
@@ -27,6 +27,8 @@ export * from './plan-data';
27
27
  export * from './showcase';
28
28
  export * from './site-build-run';
29
29
  export * from './ai-run';
30
+ export * from './recipe-fields';
31
+ export * from './plan-derived';
30
32
  export * from './plan-wire';
31
33
  export * from './strict-schema';
32
34
  export * from './plan-edits';
@@ -0,0 +1,49 @@
1
+ import { ContentFieldLike, pickAccordionAutoBinds, pickTabsAutoBinds } from '@vectoriox/iox-ui/composite-core';
2
+
3
+ import { PatternDirective, patternNodes, PatternRecipe, SitePattern } from './patterns';
4
+
5
+ /**
6
+ * The fields a recipe repeating a section's list binds (Accordion, Tabs — R3's From content): composite-core's pickers,
7
+ * the builder switch's rule, decide them. ONE rule for the composer (which binds them) and the gate (which refuses,
8
+ * at the section's `binding`, a list whose type cannot fill the recipe — vectoriox/iox-cms#49: it used to surface only
9
+ * as the composer's error at the plan's root, where the pages part could not tell which section to change).
10
+ */
11
+
12
+ interface ListRecipe {
13
+ picks(fields: ReadonlyArray<ContentFieldLike>): Readonly<Record<string, string | null>>;
14
+ /** The picks the recipe cannot go without. */
15
+ required: ReadonlyArray<string>;
16
+ /** What the listed type must have, in words. */
17
+ needs: string;
18
+ }
19
+
20
+ const LIST_RECIPES: Readonly<Partial<Record<`${PatternRecipe}`, ListRecipe>>> = {
21
+ [PatternRecipe.Accordion]: { picks: f => ({ ...pickAccordionAutoBinds(f) }), required: ['question', 'answer'], needs: 'short-text field for the question and long-text field for the answer' },
22
+ [PatternRecipe.Tabs]: { picks: f => ({ ...pickTabsAutoBinds(f) }), required: ['label', 'text'], needs: 'short-text field for the tab and long-text field for its panel' },
23
+ };
24
+
25
+ export interface RecipeListBinds<T> {
26
+ picks: T;
27
+ /** What the fields lack (the recipe's `needs`), or null when they fill it. */
28
+ gap: string | null;
29
+ }
30
+
31
+ /** The fields `recipe` binds from a listed type's `fields`, and what they lack. A recipe that lists nothing lacks nothing. */
32
+ export function recipeListBinds<T = Readonly<Record<string, string | null>>>(recipe: string | undefined, fields: ReadonlyArray<ContentFieldLike>): RecipeListBinds<T> {
33
+ const spec = recipe ? LIST_RECIPES[recipe as `${PatternRecipe}`] : undefined;
34
+ if (!spec) return { picks: {} as T, gap: null };
35
+ const picks = spec.picks(fields);
36
+ return { picks: picks as unknown as T, gap: spec.required.every(key => picks[key]) ? null : spec.needs };
37
+ }
38
+
39
+ const listsBinding = (src: string | undefined) => src === PatternDirective.Binding || src === PatternDirective.OptionalBinding;
40
+
41
+ /** What a listed type with `fields` lacks for the pattern's recipes that repeat the section's binding — the first gap, or null. */
42
+ export function patternListGap(pattern: SitePattern, fields: ReadonlyArray<ContentFieldLike>): string | null {
43
+ for (const node of patternNodes(pattern)) {
44
+ if (!listsBinding(node.src)) continue;
45
+ const { gap } = recipeListBinds(node.recipe, fields);
46
+ if (gap) return gap;
47
+ }
48
+ return null;
49
+ }