@vectoriox/iox-ui 5.26.0 → 5.28.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.
Files changed (30) hide show
  1. package/design-core/index.d.ts +144 -4
  2. package/fesm2022/vectoriox-iox-ui-design-core.mjs +153 -5
  3. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  4. package/fesm2022/vectoriox-iox-ui-forms-core.mjs +40 -5
  5. package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +7018 -0
  7. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -0
  8. package/fesm2022/vectoriox-iox-ui.mjs +72 -14
  9. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  10. package/forms-core/index.d.ts +18 -2
  11. package/generation-core/index.d.ts +1397 -0
  12. package/generation-core/package.json +3 -0
  13. package/index.d.ts +13 -2
  14. package/package.json +5 -1
  15. package/src/design-core/flavor-spec.generated.ts +153 -5
  16. package/src/forms-core/collect.ts +3 -1
  17. package/src/forms-core/options.ts +29 -0
  18. package/src/forms-core/public-api.ts +1 -0
  19. package/src/forms-core/transport.ts +6 -2
  20. package/src/generation-core/compose-site.ts +474 -0
  21. package/src/generation-core/node-baseline.model.ts +10 -0
  22. package/src/generation-core/node-baselines.generated.ts +4841 -0
  23. package/src/generation-core/patterns.ts +125 -0
  24. package/src/generation-core/public-api.ts +20 -0
  25. package/src/generation-core/serialized-node.ts +91 -0
  26. package/src/generation-core/site-plan.model.ts +136 -0
  27. package/src/generation-core/site-spec.generated.ts +1111 -0
  28. package/src/generation-core/templates.ts +60 -0
  29. package/src/generation-core/validate-site-plan.ts +413 -0
  30. package/styles/iox-forms.css +136 -8
@@ -0,0 +1,60 @@
1
+ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { ContentTypePlan, DataSourcePlan, GlobalSlot, PagePlan, SiteBusiness, SitePlan, SitePlanSourceKind, SITE_PLAN_VERSION } from './site-plan.model';
4
+ import { SPEC_SITE_TEMPLATES } from './site-spec.generated';
5
+
6
+ /** A site structure the wizard offers (contract `site.templates`). A template is a Site Plan without the
7
+ * customer's part — the business and the look — which `planFromTemplate` adds. */
8
+ export interface SiteTemplate {
9
+ readonly id: string;
10
+ readonly version: number;
11
+ readonly label: string;
12
+ readonly blurb: string;
13
+ readonly bestFor: ReadonlyArray<string>;
14
+ readonly contentTypes: ReadonlyArray<ContentTypePlan>;
15
+ readonly dataSources: ReadonlyArray<DataSourcePlan>;
16
+ readonly pages: ReadonlyArray<PagePlan>;
17
+ readonly globals: Readonly<Record<`${GlobalSlot}`, string>>;
18
+ }
19
+
20
+ export const SITE_TEMPLATES: Readonly<Record<string, SiteTemplate>> = SPEC_SITE_TEMPLATES;
21
+
22
+ export enum SiteTemplateId {
23
+ Studio = 'studio',
24
+ }
25
+
26
+ export const STUDIO_TEMPLATE: SiteTemplate = SITE_TEMPLATES[SiteTemplateId.Studio];
27
+
28
+ export interface TemplatePlanInput {
29
+ business: SiteBusiness;
30
+ design: DesignChoices;
31
+ /** Keys of the template's OPTIONAL pages to leave out (the wizard's Pages step). */
32
+ omitPages?: ReadonlyArray<string>;
33
+ }
34
+
35
+ export class SiteTemplateError extends Error {}
36
+
37
+ const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
38
+
39
+ /**
40
+ * The template as a Site Plan (`source.kind: template`), with the customer's business and look. A
41
+ * required page cannot be left out, and an unknown key is refused — nothing is guessed.
42
+ */
43
+ export function planFromTemplate(template: SiteTemplate, input: TemplatePlanInput): SitePlan {
44
+ const omit = new Set(input.omitPages ?? []);
45
+ for (const key of omit) {
46
+ const page = template.pages.find(p => p.key === key);
47
+ if (!page) throw new SiteTemplateError(`template ${template.id} has no page "${key}"`);
48
+ if (page.required) throw new SiteTemplateError(`page "${key}" of template ${template.id} is required`);
49
+ }
50
+ return {
51
+ version: SITE_PLAN_VERSION,
52
+ source: { kind: SitePlanSourceKind.Template, templateId: template.id, templateVersion: template.version },
53
+ business: clone(input.business),
54
+ design: clone(input.design),
55
+ contentTypes: clone(template.contentTypes),
56
+ dataSources: clone(template.dataSources),
57
+ pages: clone(template.pages.filter(p => !omit.has(p.key))),
58
+ globals: clone(template.globals),
59
+ };
60
+ }
@@ -0,0 +1,413 @@
1
+ import { flavorPackById, parseHexColor, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import {
4
+ BindScope, patternById, patternNeedsBinding, patternOptionalParts, patternScopes, patternSlots, PatternGlobalPosition,
5
+ } from './patterns';
6
+ import {
7
+ FACT_KEYS, GlobalSlot, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlanSourceKind, SITE_PLAN_VERSION,
8
+ } from './site-plan.model';
9
+
10
+ /**
11
+ * The Site Plan validator — the gate in front of the composer. The wizard's plan and (later) the AI's
12
+ * plan go through the SAME checks: an unknown pattern, a slot the pattern does not declare, a source
13
+ * that does not exist, a page whose route item cannot be resolved — all refused, with a path, before a
14
+ * single page is written. It never repairs: a model gets the issues back and rewrites.
15
+ */
16
+
17
+ export enum SitePlanIssueCode {
18
+ /** A value of the wrong type, or a required value missing. */
19
+ Shape = 'shape',
20
+ /** A key the plan shape does not have. */
21
+ UnknownKey = 'unknown-key',
22
+ Version = 'version',
23
+ UnknownFlavor = 'unknown-flavor',
24
+ InvalidDesign = 'invalid-design',
25
+ MissingBusinessName = 'missing-business-name',
26
+ UnknownFact = 'unknown-fact',
27
+ Duplicate = 'duplicate',
28
+ ReservedAlias = 'reserved-alias',
29
+ UnknownContentType = 'unknown-content-type',
30
+ UnknownField = 'unknown-field',
31
+ UnknownDataSource = 'unknown-data-source',
32
+ InvalidDataSource = 'invalid-data-source',
33
+ InvalidRoute = 'invalid-route',
34
+ Home = 'home',
35
+ RouteSource = 'route-source',
36
+ UnknownPattern = 'unknown-pattern',
37
+ GlobalPattern = 'global-pattern',
38
+ UnknownSlot = 'unknown-slot',
39
+ UnknownOptionalPart = 'unknown-optional-part',
40
+ Binding = 'binding',
41
+ MissingScope = 'missing-scope',
42
+ }
43
+
44
+ export interface SitePlanIssue {
45
+ code: SitePlanIssueCode;
46
+ /** Where, as a dotted path into the plan (`pages[2].sections[0].binding`). */
47
+ path: string;
48
+ message: string;
49
+ }
50
+
51
+ export interface SitePlanValidation {
52
+ valid: boolean;
53
+ issues: SitePlanIssue[];
54
+ }
55
+
56
+ export class SitePlanError extends Error {
57
+ constructor(readonly issues: ReadonlyArray<SitePlanIssue>) {
58
+ super(`invalid site plan: ${issues.map(i => `${i.path}: ${i.message}`).join('; ')}`);
59
+ }
60
+ }
61
+
62
+ /** The alias of the static source the composer writes for the navigation (`$navPages`). A plan source
63
+ * may not take it. */
64
+ export const NAV_PAGES_ALIAS = 'navPages';
65
+ /** Route-scope aliases (`$route`, `$current`, …) are platform-owned. */
66
+ const RESERVED_PREFIX = '$';
67
+ const ID_FIELD = '_id';
68
+
69
+ const PLAN_KEYS = ['version', 'source', 'business', 'design', 'contentTypes', 'dataSources', 'pages', 'globals'];
70
+ const BUSINESS_KEYS = ['name', 'tagline', 'facts'];
71
+ const DESIGN_REQUIRED = ['flavorId', 'seed', 'pairId', 'shape', 'density', 'motion', 'pillButtons', 'mode'];
72
+ const DESIGN_KEYS = [...DESIGN_REQUIRED, 'tint', 'fonts'];
73
+ const CONTENT_TYPE_KEYS = ['name', 'label', 'previewTextField', 'previewImageField', 'fields', 'seed'];
74
+ const FIELD_KEYS = ['name', 'type', 'controller', 'required', 'note'];
75
+ const SEED_FIXED_KEYS = ['count', 'sample', 'isPublished'];
76
+ const CMS_SOURCE_KEYS = ['alias', 'type', 'mode', 'contentType', 'limit', 'filterByRouteParam'];
77
+ const RELATIVE_SOURCE_KEYS = ['alias', 'type', 'of', 'op', 'within'];
78
+ const PAGE_KEYS = ['key', 'name', 'route', 'isHome', 'required', 'inNav', 'template', 'routeSource', 'sections'];
79
+ const SECTION_KEYS = ['pattern', 'slots', 'binding', 'without'];
80
+ const GLOBAL_POSITION: Readonly<Record<`${GlobalSlot}`, `${PatternGlobalPosition}`>> = {
81
+ [GlobalSlot.Header]: PatternGlobalPosition.Before,
82
+ [GlobalSlot.Footer]: PatternGlobalPosition.After,
83
+ };
84
+ const ROUTE = /^\/(?:(?:[a-z0-9]+(?:-[a-z0-9]+)*|:[a-zA-Z_][a-zA-Z0-9_]*)(?:\/(?:[a-z0-9]+(?:-[a-z0-9]+)*|:[a-zA-Z_][a-zA-Z0-9_]*))*)?$/;
85
+ const ROUTE_PARAM = /:([a-zA-Z_][a-zA-Z0-9_]*)/g;
86
+
87
+ type Obj = Record<string, unknown>;
88
+
89
+ const isObject = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v);
90
+ const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.trim() !== '';
91
+ const has = (table: object, key: string) => Object.prototype.hasOwnProperty.call(table, key);
92
+ const at = (path: string, key: string) => (path ? `${path}.${key}` : key);
93
+
94
+ /** The `:param` names of a route, in order. */
95
+ export function routeParams(route: string): string[] {
96
+ return [...route.matchAll(ROUTE_PARAM)].map(m => m[1]);
97
+ }
98
+
99
+ class Collector {
100
+ readonly issues: SitePlanIssue[] = [];
101
+ add(code: SitePlanIssueCode, path: string, message: string): void {
102
+ this.issues.push({ code, path, message });
103
+ }
104
+ keys(obj: Obj, allowed: ReadonlyArray<string>, path: string): void {
105
+ for (const key of Object.keys(obj)) if (!allowed.includes(key)) this.add(SitePlanIssueCode.UnknownKey, at(path, key), `"${key}" is not part of the plan shape`);
106
+ }
107
+ string(obj: Obj, key: string, path: string, required = true): string | null {
108
+ const v = obj[key];
109
+ if (v === undefined && !required) return null;
110
+ if (!isNonEmptyString(v)) {
111
+ this.add(SitePlanIssueCode.Shape, at(path, key), 'must be a non-empty string');
112
+ return null;
113
+ }
114
+ return v;
115
+ }
116
+ boolean(obj: Obj, key: string, path: string): void {
117
+ if (obj[key] !== undefined && typeof obj[key] !== 'boolean') this.add(SitePlanIssueCode.Shape, at(path, key), 'must be a boolean');
118
+ }
119
+ array(obj: Obj, key: string, path: string): unknown[] {
120
+ const v = obj[key];
121
+ if (!Array.isArray(v)) {
122
+ this.add(SitePlanIssueCode.Shape, at(path, key), 'must be an array');
123
+ return [];
124
+ }
125
+ return v;
126
+ }
127
+ unique(values: Array<string | null>, path: (i: number) => string, what: string): void {
128
+ const seen = new Set<string>();
129
+ values.forEach((v, i) => {
130
+ if (v === null) return;
131
+ if (seen.has(v)) this.add(SitePlanIssueCode.Duplicate, path(i), `${what} "${v}" is used twice`);
132
+ seen.add(v);
133
+ });
134
+ }
135
+ }
136
+
137
+ function validateSource(c: Collector, source: unknown): void {
138
+ if (!isObject(source)) return c.add(SitePlanIssueCode.Shape, 'source', 'must be an object');
139
+ if (source['kind'] === SitePlanSourceKind.Template) {
140
+ c.keys(source, ['kind', 'templateId', 'templateVersion'], 'source');
141
+ c.string(source, 'templateId', 'source');
142
+ if (!Number.isInteger(source['templateVersion'])) c.add(SitePlanIssueCode.Shape, 'source.templateVersion', 'must be an integer');
143
+ } else if (source['kind'] === SitePlanSourceKind.Ai) {
144
+ c.keys(source, ['kind', 'runId'], 'source');
145
+ c.string(source, 'runId', 'source');
146
+ } else {
147
+ c.add(SitePlanIssueCode.Shape, 'source.kind', `must be "${SitePlanSourceKind.Template}" or "${SitePlanSourceKind.Ai}"`);
148
+ }
149
+ }
150
+
151
+ function validateBusiness(c: Collector, business: unknown): void {
152
+ if (!isObject(business)) return c.add(SitePlanIssueCode.Shape, 'business', 'must be an object');
153
+ c.keys(business, BUSINESS_KEYS, 'business');
154
+ if (!isNonEmptyString(business['name'])) c.add(SitePlanIssueCode.MissingBusinessName, 'business.name', 'the business name is required');
155
+ if (business['tagline'] !== undefined && typeof business['tagline'] !== 'string') c.add(SitePlanIssueCode.Shape, 'business.tagline', 'must be a string');
156
+ const facts = business['facts'];
157
+ if (facts === undefined) return;
158
+ if (!isObject(facts)) return c.add(SitePlanIssueCode.Shape, 'business.facts', 'must be an object');
159
+ for (const [key, value] of Object.entries(facts)) {
160
+ if (!(FACT_KEYS as ReadonlyArray<string>).includes(key)) c.add(SitePlanIssueCode.UnknownFact, `business.facts.${key}`, `"${key}" is not a fact (${FACT_KEYS.join(', ')})`);
161
+ else if (typeof value !== 'string') c.add(SitePlanIssueCode.Shape, `business.facts.${key}`, 'must be a string');
162
+ }
163
+ }
164
+
165
+ function validateDesign(c: Collector, design: unknown): void {
166
+ if (!isObject(design)) return c.add(SitePlanIssueCode.Shape, 'design', 'must be an object');
167
+ c.keys(design, DESIGN_KEYS, 'design');
168
+ for (const key of DESIGN_REQUIRED) if (design[key] === undefined) c.add(SitePlanIssueCode.Shape, `design.${key}`, 'is required');
169
+ const pack = flavorPackById(typeof design['flavorId'] === 'string' ? design['flavorId'] : null);
170
+ if (design['flavorId'] !== undefined && (!pack || pack.id !== design['flavorId'])) {
171
+ c.add(SitePlanIssueCode.UnknownFlavor, 'design.flavorId', `"${String(design['flavorId'])}" is not a flavor`);
172
+ }
173
+ const bad = (key: string, why: string) => c.add(SitePlanIssueCode.InvalidDesign, `design.${key}`, why);
174
+ if (design['seed'] !== undefined && (typeof design['seed'] !== 'string' || !parseHexColor(design['seed']))) bad('seed', 'must be a #rrggbb colour');
175
+ if (design['pairId'] !== undefined && pack && !pack.pairs.some(p => p.id === design['pairId'])) bad('pairId', `is not a font pairing of ${pack.id}`);
176
+ const axis = (key: string, table: object) => {
177
+ if (design[key] !== undefined && (typeof design[key] !== 'string' || !has(table, design[key] as string))) bad(key, `must be one of ${Object.keys(table).join(', ')}`);
178
+ };
179
+ axis('shape', SPEC_AXES.shapes);
180
+ axis('density', SPEC_AXES.densities);
181
+ axis('motion', SPEC_AXES.motions);
182
+ axis('tint', SPEC_AXES.tints);
183
+ if (design['mode'] !== undefined && !(SPEC_AXES.modes as ReadonlyArray<unknown>).includes(design['mode'])) bad('mode', `must be one of ${SPEC_AXES.modes.join(', ')}`);
184
+ if (design['pillButtons'] !== undefined && typeof design['pillButtons'] !== 'boolean') bad('pillButtons', 'must be a boolean');
185
+ if (design['fonts'] !== undefined && !isObject(design['fonts'])) bad('fonts', 'must be an object');
186
+ }
187
+
188
+ interface ContentTypeInfo { fields: Set<string>; }
189
+
190
+ function validateContentTypes(c: Collector, list: unknown[]): Map<string, ContentTypeInfo> {
191
+ const types = new Map<string, ContentTypeInfo>();
192
+ list.forEach((raw, i) => {
193
+ const path = `contentTypes[${i}]`;
194
+ if (!isObject(raw)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
195
+ c.keys(raw, CONTENT_TYPE_KEYS, path);
196
+ const name = c.string(raw, 'name', path);
197
+ c.string(raw, 'label', path);
198
+ const fields = c.array(raw, 'fields', path);
199
+ const names: Array<string | null> = fields.map((f, j) => {
200
+ const fp = `${path}.fields[${j}]`;
201
+ if (!isObject(f)) { c.add(SitePlanIssueCode.Shape, fp, 'must be an object'); return null; }
202
+ c.keys(f, FIELD_KEYS, fp);
203
+ c.string(f, 'type', fp);
204
+ c.string(f, 'controller', fp);
205
+ c.boolean(f, 'required', fp);
206
+ return c.string(f, 'name', fp);
207
+ });
208
+ c.unique(names, j => `${path}.fields[${j}].name`, 'field');
209
+ const fieldSet = new Set(names.filter((n): n is string => !!n));
210
+ for (const key of ['previewTextField', 'previewImageField']) {
211
+ const v = c.string(raw, key, path, false);
212
+ if (v && !fieldSet.has(v)) c.add(SitePlanIssueCode.UnknownField, `${path}.${key}`, `"${v}" is not a field of ${name}`);
213
+ }
214
+ const seed = raw['seed'];
215
+ if (seed !== undefined) {
216
+ if (!isObject(seed)) c.add(SitePlanIssueCode.Shape, `${path}.seed`, 'must be an object');
217
+ else {
218
+ if (!Number.isInteger(seed['count']) || (seed['count'] as number) < 0) c.add(SitePlanIssueCode.Shape, `${path}.seed.count`, 'must be a whole number');
219
+ for (const key of ['sample', 'isPublished']) if (typeof seed[key] !== 'boolean') c.add(SitePlanIssueCode.Shape, `${path}.seed.${key}`, 'must be a boolean');
220
+ for (const key of Object.keys(seed)) {
221
+ if (!SEED_FIXED_KEYS.includes(key) && !fieldSet.has(key)) c.add(SitePlanIssueCode.UnknownField, `${path}.seed.${key}`, `"${key}" is not a field of ${name}`);
222
+ }
223
+ }
224
+ }
225
+ if (name && types.has(name)) c.add(SitePlanIssueCode.Duplicate, `${path}.name`, `content type "${name}" is used twice`);
226
+ else if (name) types.set(name, { fields: fieldSet });
227
+ });
228
+ return types;
229
+ }
230
+
231
+ interface SourceInfo { type: string; mode?: string; contentType?: string; routeParam?: string; of?: string; op?: string; }
232
+
233
+ function validateDataSources(c: Collector, list: unknown[], types: Map<string, ContentTypeInfo>): Map<string, SourceInfo> {
234
+ const sources = new Map<string, SourceInfo>();
235
+ list.forEach((raw, i) => {
236
+ const path = `dataSources[${i}]`;
237
+ if (!isObject(raw)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
238
+ const alias = c.string(raw, 'alias', path);
239
+ if (alias && (alias.startsWith(RESERVED_PREFIX) || alias === NAV_PAGES_ALIAS)) {
240
+ c.add(SitePlanIssueCode.ReservedAlias, `${path}.alias`, `"${alias}" is reserved for the platform`);
241
+ }
242
+ if (raw['type'] === PlanDataSourceType.Cms) {
243
+ c.keys(raw, CMS_SOURCE_KEYS, path);
244
+ const mode = raw['mode'];
245
+ if (mode !== PlanDataSourceMode.Query && mode !== PlanDataSourceMode.Single) c.add(SitePlanIssueCode.InvalidDataSource, `${path}.mode`, `must be "${PlanDataSourceMode.Query}" or "${PlanDataSourceMode.Single}"`);
246
+ const contentType = c.string(raw, 'contentType', path);
247
+ const type = contentType ? types.get(contentType) : undefined;
248
+ if (contentType && !type) c.add(SitePlanIssueCode.UnknownContentType, `${path}.contentType`, `no content type "${contentType}" in the plan`);
249
+ if (raw['limit'] !== undefined && (!Number.isInteger(raw['limit']) || (raw['limit'] as number) < 1)) c.add(SitePlanIssueCode.InvalidDataSource, `${path}.limit`, 'must be a positive whole number');
250
+ let routeParam: string | undefined;
251
+ const byRoute = raw['filterByRouteParam'];
252
+ if (byRoute !== undefined) {
253
+ if (!isObject(byRoute)) c.add(SitePlanIssueCode.Shape, `${path}.filterByRouteParam`, 'must be an object');
254
+ else {
255
+ c.keys(byRoute, ['field', 'routeParam'], `${path}.filterByRouteParam`);
256
+ const field = c.string(byRoute, 'field', `${path}.filterByRouteParam`);
257
+ routeParam = c.string(byRoute, 'routeParam', `${path}.filterByRouteParam`) ?? undefined;
258
+ if (field && type && field !== ID_FIELD && !type.fields.has(field)) c.add(SitePlanIssueCode.UnknownField, `${path}.filterByRouteParam.field`, `"${field}" is not a field of ${contentType}`);
259
+ if (mode !== PlanDataSourceMode.Single) c.add(SitePlanIssueCode.InvalidDataSource, `${path}.filterByRouteParam`, 'only a single source is looked up by a route param');
260
+ }
261
+ }
262
+ if (alias && !sources.has(alias)) sources.set(alias, { type: PlanDataSourceType.Cms, mode: mode as string, contentType: contentType ?? undefined, routeParam });
263
+ } else if (raw['type'] === PlanDataSourceType.Relative) {
264
+ c.keys(raw, RELATIVE_SOURCE_KEYS, path);
265
+ c.string(raw, 'of', path);
266
+ c.string(raw, 'within', path);
267
+ if (!(Object.values(RelativeOp) as unknown[]).includes(raw['op'])) c.add(SitePlanIssueCode.InvalidDataSource, `${path}.op`, `must be one of ${Object.values(RelativeOp).join(', ')}`);
268
+ if (alias && !sources.has(alias)) sources.set(alias, { type: PlanDataSourceType.Relative, of: raw['of'] as string, op: raw['op'] as string });
269
+ } else {
270
+ c.add(SitePlanIssueCode.InvalidDataSource, `${path}.type`, `must be "${PlanDataSourceType.Cms}" or "${PlanDataSourceType.Relative}"`);
271
+ }
272
+ });
273
+ c.unique(list.map(r => (isObject(r) && isNonEmptyString(r['alias']) ? r['alias'] : null)), i => `dataSources[${i}].alias`, 'alias');
274
+ // A relative source locates its subject (a single item) within a collection of the SAME type.
275
+ list.forEach((raw, i) => {
276
+ if (!isObject(raw) || raw['type'] !== PlanDataSourceType.Relative) return;
277
+ const of = sources.get(raw['of'] as string);
278
+ const within = sources.get(raw['within'] as string);
279
+ if (!of || of.type !== PlanDataSourceType.Cms || of.mode !== PlanDataSourceMode.Single) c.add(SitePlanIssueCode.UnknownDataSource, `dataSources[${i}].of`, 'must name a single cms source of the plan');
280
+ if (!within || within.type !== PlanDataSourceType.Cms || within.mode !== PlanDataSourceMode.Query) c.add(SitePlanIssueCode.UnknownDataSource, `dataSources[${i}].within`, 'must name a list (query) cms source of the plan');
281
+ if (of?.contentType && within?.contentType && of.contentType !== within.contentType) c.add(SitePlanIssueCode.InvalidDataSource, `dataSources[${i}]`, `${raw['of']} and ${raw['within']} are different content types`);
282
+ });
283
+ return sources;
284
+ }
285
+
286
+ function validatePages(c: Collector, list: unknown[], types: Map<string, ContentTypeInfo>, sources: Map<string, SourceInfo>): void {
287
+ const homes: number[] = [];
288
+ list.forEach((raw, i) => {
289
+ const path = `pages[${i}]`;
290
+ if (!isObject(raw)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
291
+ c.keys(raw, PAGE_KEYS, path);
292
+ c.string(raw, 'key', path);
293
+ c.string(raw, 'name', path);
294
+ for (const key of ['isHome', 'required', 'inNav']) c.boolean(raw, key, path);
295
+ const route = c.string(raw, 'route', path);
296
+ if (route && !ROUTE.test(route)) c.add(SitePlanIssueCode.InvalidRoute, `${path}.route`, `"${route}" is not a route (/lowercase-words, :params)`);
297
+ if (raw['isHome'] === true) {
298
+ homes.push(i);
299
+ if (route !== '/') c.add(SitePlanIssueCode.Home, `${path}.route`, 'the home page lives at /');
300
+ } else if (route === '/') c.add(SitePlanIssueCode.Home, `${path}.isHome`, 'the page at / is the home page');
301
+
302
+ const params = route ? routeParams(route) : [];
303
+ const template = c.string(raw, 'template', path, false);
304
+ const routeSourceAlias = c.string(raw, 'routeSource', path, false);
305
+ const routeSource = routeSourceAlias ? sources.get(routeSourceAlias) : undefined;
306
+ const routeSourceOk = !!routeSource && routeSource.type === PlanDataSourceType.Cms && routeSource.mode === PlanDataSourceMode.Single;
307
+ if (template && !types.has(template)) c.add(SitePlanIssueCode.UnknownContentType, `${path}.template`, `no content type "${template}" in the plan`);
308
+ if (params.length > 1) c.add(SitePlanIssueCode.InvalidRoute, `${path}.route`, 'a template page has one route param');
309
+ if (params.length && !routeSourceAlias) c.add(SitePlanIssueCode.RouteSource, `${path}.routeSource`, `a route with :${params[0]} names the source that reads it`);
310
+ if (routeSourceAlias) {
311
+ if (!routeSourceOk) {
312
+ c.add(SitePlanIssueCode.RouteSource, `${path}.routeSource`, `"${routeSourceAlias}" must be a single cms source of the plan`);
313
+ } else {
314
+ if (!params.length || routeSource!.routeParam !== params[0]) c.add(SitePlanIssueCode.RouteSource, `${path}.routeSource`, `"${routeSourceAlias}" is not looked up by this route's param`);
315
+ if (template && routeSource!.contentType !== template) c.add(SitePlanIssueCode.RouteSource, `${path}.template`, `"${routeSourceAlias}" reads ${routeSource!.contentType}, not ${template}`);
316
+ }
317
+ }
318
+
319
+ c.array(raw, 'sections', path).forEach((s, j) => {
320
+ const sp = `${path}.sections[${j}]`;
321
+ if (!isObject(s)) return c.add(SitePlanIssueCode.Shape, sp, 'must be an object');
322
+ c.keys(s, SECTION_KEYS, sp);
323
+ const patternId = c.string(s, 'pattern', sp);
324
+ const pattern = patternById(patternId);
325
+ if (patternId && !pattern) return c.add(SitePlanIssueCode.UnknownPattern, `${sp}.pattern`, `no pattern "${patternId}"`);
326
+ if (!pattern) return;
327
+ if (pattern.global) c.add(SitePlanIssueCode.GlobalPattern, `${sp}.pattern`, `"${patternId}" is a header/footer pattern (globals)`);
328
+ const slots = s['slots'];
329
+ if (slots !== undefined) {
330
+ if (!isObject(slots)) c.add(SitePlanIssueCode.Shape, `${sp}.slots`, 'must be an object');
331
+ else {
332
+ const declared = patternSlots(pattern);
333
+ for (const [slot, value] of Object.entries(slots)) {
334
+ if (!declared.includes(slot)) c.add(SitePlanIssueCode.UnknownSlot, `${sp}.slots.${slot}`, `"${patternId}" has no slot "${slot}" (${declared.join(', ') || 'none'})`);
335
+ else if (typeof value !== 'string') c.add(SitePlanIssueCode.Shape, `${sp}.slots.${slot}`, 'must be a string');
336
+ }
337
+ }
338
+ }
339
+ const without = s['without'];
340
+ if (without !== undefined) {
341
+ const parts = patternOptionalParts(pattern);
342
+ if (!Array.isArray(without)) c.add(SitePlanIssueCode.Shape, `${sp}.without`, 'must be an array');
343
+ else without.forEach((w, k) => {
344
+ if (!parts.includes(w as string)) c.add(SitePlanIssueCode.UnknownOptionalPart, `${sp}.without[${k}]`, `"${patternId}" has no optional part "${String(w)}"`);
345
+ });
346
+ }
347
+ const binding = c.string(s, 'binding', sp, false);
348
+ if (patternNeedsBinding(pattern)) {
349
+ if (!binding) c.add(SitePlanIssueCode.Binding, `${sp}.binding`, `"${patternId}" repeats the section's binding — name a list source`);
350
+ else {
351
+ const src = sources.get(binding);
352
+ if (!src) c.add(SitePlanIssueCode.UnknownDataSource, `${sp}.binding`, `no data source "${binding}"`);
353
+ else if (src.type !== PlanDataSourceType.Cms || src.mode !== PlanDataSourceMode.Query) c.add(SitePlanIssueCode.Binding, `${sp}.binding`, `"${binding}" is not a list source`);
354
+ }
355
+ } else if (binding) c.add(SitePlanIssueCode.Binding, `${sp}.binding`, `"${patternId}" repeats nothing — it takes no binding`);
356
+
357
+ // A pattern reading the route item needs a routeSource (an INVALID one is reported above, once).
358
+ const scopes = patternScopes(pattern);
359
+ const readsRoute = scopes.has(BindScope.Route) || scopes.has(BindScope.Next);
360
+ if (readsRoute && !routeSourceAlias) {
361
+ c.add(SitePlanIssueCode.MissingScope, `${sp}.pattern`, `"${patternId}" reads the route item — the page needs a routeSource`);
362
+ }
363
+ if (scopes.has(BindScope.Next) && routeSourceOk && !nextSourceOf(sources, routeSourceAlias as string)) {
364
+ c.add(SitePlanIssueCode.MissingScope, `${sp}.pattern`, `"${patternId}" reads the next item — the plan needs a ${RelativeOp.SiblingsNext} source of ${routeSourceAlias}`);
365
+ }
366
+ });
367
+ });
368
+ c.unique(list.map(p => (isObject(p) && isNonEmptyString(p['key']) ? p['key'] : null)), i => `pages[${i}].key`, 'page key');
369
+ c.unique(list.map(p => (isObject(p) && isNonEmptyString(p['route']) ? p['route'] : null)), i => `pages[${i}].route`, 'route');
370
+ if (homes.length !== 1) c.add(SitePlanIssueCode.Home, 'pages', `exactly one page is the home page (found ${homes.length})`);
371
+ }
372
+
373
+ /** The plan's `$siblings.next` source of `routeSource` — what a pattern's `next.*` reads. */
374
+ export function nextSourceOf(sources: Map<string, { type: string; of?: string; op?: string }>, routeSource: string): string | null {
375
+ for (const [alias, s] of sources) if (s.type === PlanDataSourceType.Relative && s.of === routeSource && s.op === RelativeOp.SiblingsNext) return alias;
376
+ return null;
377
+ }
378
+
379
+ function validateGlobals(c: Collector, globals: unknown): void {
380
+ if (!isObject(globals)) return c.add(SitePlanIssueCode.Shape, 'globals', 'must be an object');
381
+ c.keys(globals, Object.values(GlobalSlot), 'globals');
382
+ for (const slot of Object.values(GlobalSlot)) {
383
+ const id = c.string(globals, slot, 'globals');
384
+ const pattern = patternById(id);
385
+ if (id && !pattern) c.add(SitePlanIssueCode.UnknownPattern, `globals.${slot}`, `no pattern "${id}"`);
386
+ else if (pattern && pattern.global !== GLOBAL_POSITION[slot]) c.add(SitePlanIssueCode.GlobalPattern, `globals.${slot}`, `"${id}" is not a ${slot} pattern`);
387
+ }
388
+ }
389
+
390
+ /** Check a Site Plan against the plan shape and the pattern library. Never throws, never repairs. */
391
+ export function validateSitePlan(plan: unknown): SitePlanValidation {
392
+ const c = new Collector();
393
+ if (!isObject(plan)) {
394
+ c.add(SitePlanIssueCode.Shape, '', 'a site plan is an object');
395
+ return { valid: false, issues: c.issues };
396
+ }
397
+ c.keys(plan, PLAN_KEYS, '');
398
+ if (plan['version'] !== SITE_PLAN_VERSION) c.add(SitePlanIssueCode.Version, 'version', `must be "${SITE_PLAN_VERSION}"`);
399
+ validateSource(c, plan['source']);
400
+ validateBusiness(c, plan['business']);
401
+ validateDesign(c, plan['design']);
402
+ const types = validateContentTypes(c, c.array(plan, 'contentTypes', ''));
403
+ const sources = validateDataSources(c, c.array(plan, 'dataSources', ''), types);
404
+ validatePages(c, c.array(plan, 'pages', ''), types, sources);
405
+ validateGlobals(c, plan['globals']);
406
+ return { valid: c.issues.length === 0, issues: c.issues };
407
+ }
408
+
409
+ /** Throw a `SitePlanError` carrying every issue unless the plan is valid. */
410
+ export function assertValidSitePlan(plan: unknown): void {
411
+ const result = validateSitePlan(plan);
412
+ if (!result.valid) throw new SitePlanError(result.issues);
413
+ }
@@ -119,7 +119,8 @@
119
119
  .iox-es-field .iox-field__control:focus-visible {
120
120
  border-color: var(--iox-accent-text);
121
121
  box-shadow: var(--iox-field-focus);
122
- outline: none;
122
+ outline: 2px solid transparent;
123
+ outline-offset: 2px;
123
124
  }
124
125
  .iox-es-field .iox-field__label {
125
126
  font-family: var(--iox-font-body);
@@ -127,6 +128,13 @@
127
128
  font-weight: var(--iox-field-label-weight);
128
129
  color: var(--iox-text);
129
130
  }
131
+ /* A radio's choice reads like the label beside it: the look's body type in the look's text. */
132
+ .iox-es-field .iox-field__option {
133
+ font-family: var(--iox-font-body);
134
+ font-size: var(--iox-size-body);
135
+ color: var(--iox-text);
136
+ cursor: pointer;
137
+ }
130
138
  .iox-es-field .iox-field__help {
131
139
  font-size: var(--iox-size-small);
132
140
  color: var(--iox-text-muted);
@@ -139,21 +147,141 @@
139
147
  border-color: var(--iox-danger);
140
148
  }
141
149
  /* DS9 (contrast audit): the placeholder is themed (the UA's #757575 read 1:1 in a Brand section); the
142
- native controls, the focus edge and the switch read `accent-text` — the accent GATED as text on every
150
+ focus edge, the switch and the range thumb read `accent-text` — the accent GATED as text on every
143
151
  ground — never the `accent` fill (Pop's orange on its yellow panel: 2.13:1); the off track is
144
- `text-muted` under a `bg` thumb (the decorative `line` it wore read 1.06:1). WCAG 1.4.11: 3:1. */
152
+ `text-muted` under a `bg` thumb (the decorative `line` it wore read 1.06:1). WCAG 1.4.11: 3:1.
153
+ Focus: the transparent outline is what forced-colors mode paints once it drops the box-shadow ring. */
145
154
  .iox-es-field .iox-field__control::placeholder {
146
155
  color: var(--iox-text-muted);
147
156
  opacity: 1;
148
157
  }
149
- /* Native-appearance controls (checkbox, radio, range — and the switch, a checkbox) draw themselves:
150
- the box baseline must not frame them. `:where()` keeps this at the baseline's specificity, so the
151
- switch rules below still paint the track. */
152
- .iox-es-field .iox-field__control:where([type='checkbox'], [type='radio'], [type='range']) {
158
+ .iox-es-field .iox-field__control:disabled {
159
+ opacity: 0.55;
160
+ cursor: not-allowed;
161
+ }
162
+ /* The switch and the range draw themselves: the box baseline must not frame them. `:where()` keeps this
163
+ at the baseline's specificity, so the switch rules below still paint the track. */
164
+ .iox-es-field .iox-field__control:where(.iox-field__control--switch, [type='range']) {
153
165
  border: none;
154
166
  padding: 0;
155
167
  background: transparent;
156
- accent-color: var(--iox-accent-text);
168
+ }
169
+ /* Checkbox and radio, drawn by the look (2026-10-19 — `accent-color` only TINTED the browser's own box,
170
+ so every flavor showed a browser default). 1.25em of the control's own size (the body step) · the
171
+ field's edge width in `line-strong` (≥ 3:1 on every ground) · the surface fill · `radius-xs` (a radio
172
+ is round) · checked: the `accent` fill, an `accent-text` edge and a mark in `accent-ink` (≥ 4.5:1 on
173
+ the fill). The mark is a BORDER — a rotated corner, a radio's dot a zero-size circle of border — so
174
+ forced-colors mode repaints it in the system ink (a background mark would vanish). Not mirrored in
175
+ RTL: a check mark is not a direction. Every motion reads dur-fast, which reduced motion sets to 0ms. */
176
+ .iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']) {
177
+ appearance: none;
178
+ -webkit-appearance: none;
179
+ display: inline-grid;
180
+ place-content: center;
181
+ flex-shrink: 0;
182
+ margin: 0;
183
+ padding: 0;
184
+ inline-size: 1.25em;
185
+ block-size: 1.25em;
186
+ vertical-align: middle;
187
+ cursor: pointer;
188
+ background: var(--iox-surface);
189
+ border-color: var(--iox-line-strong);
190
+ border-radius: var(--iox-radius-xs);
191
+ transition: background var(--iox-dur-fast) var(--iox-ease-standard), border-color var(--iox-dur-fast) var(--iox-ease-standard), box-shadow var(--iox-dur-fast) var(--iox-ease-standard);
192
+ }
193
+ .iox-es-field .iox-field__control[type='radio'] {
194
+ border-radius: 50%;
195
+ }
196
+ .iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio'])::before {
197
+ content: '';
198
+ inline-size: 0.28em;
199
+ block-size: 0.56em;
200
+ border-style: solid;
201
+ border-color: var(--iox-accent-ink);
202
+ border-width: 0 0.14em 0.14em 0;
203
+ transform: translateY(-0.08em) rotate(45deg) scale(0);
204
+ transition: transform var(--iox-dur-fast) var(--iox-ease-standard);
205
+ }
206
+ .iox-es-field .iox-field__control[type='radio']::before {
207
+ inline-size: 0;
208
+ block-size: 0;
209
+ border-width: 0.25em;
210
+ border-radius: 50%;
211
+ transform: scale(0);
212
+ }
213
+ .iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):hover {
214
+ border-color: var(--iox-accent-text);
215
+ }
216
+ .iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):checked {
217
+ background: var(--iox-accent);
218
+ border-color: var(--iox-accent-text);
219
+ }
220
+ .iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):checked:hover {
221
+ background: var(--iox-accent-hover);
222
+ }
223
+ .iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):checked::before {
224
+ transform: translateY(-0.08em) rotate(45deg) scale(1);
225
+ }
226
+ .iox-es-field .iox-field__control[type='radio']:checked::before {
227
+ transform: scale(1);
228
+ }
229
+ .iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):focus-visible {
230
+ box-shadow: var(--iox-focus-ring);
231
+ }
232
+ /* Range: the track is `line-strong`, the thumb `accent-text` on a `bg` ring, the focus ring on the THUMB.
233
+ One rule per vendor pseudo-element — a selector list mixing -webkit- and -moz- is dropped whole by
234
+ the engine that does not know the other. */
235
+ .iox-es-field .iox-field__control:where([type='range']) {
236
+ appearance: none;
237
+ -webkit-appearance: none;
238
+ inline-size: 100%;
239
+ block-size: 1.25em;
240
+ cursor: pointer;
241
+ }
242
+ .iox-es-field .iox-field__control:where([type='range'])::-webkit-slider-runnable-track {
243
+ block-size: 0.25em;
244
+ border-radius: var(--iox-radius-pill);
245
+ background: var(--iox-line-strong);
246
+ outline: 1px solid transparent;
247
+ }
248
+ .iox-es-field .iox-field__control:where([type='range'])::-moz-range-track {
249
+ block-size: 0.25em;
250
+ border-radius: var(--iox-radius-pill);
251
+ background: var(--iox-line-strong);
252
+ outline: 1px solid transparent;
253
+ }
254
+ .iox-es-field .iox-field__control:where([type='range'])::-webkit-slider-thumb {
255
+ appearance: none;
256
+ -webkit-appearance: none;
257
+ inline-size: 1.25em;
258
+ block-size: 1.25em;
259
+ border-radius: 50%;
260
+ background: var(--iox-accent-text);
261
+ border: 2px solid var(--iox-bg);
262
+ box-sizing: border-box;
263
+ cursor: pointer;
264
+ margin-block-start: -0.5em;
265
+ }
266
+ .iox-es-field .iox-field__control:where([type='range'])::-moz-range-thumb {
267
+ appearance: none;
268
+ -webkit-appearance: none;
269
+ inline-size: 1.25em;
270
+ block-size: 1.25em;
271
+ border-radius: 50%;
272
+ background: var(--iox-accent-text);
273
+ border: 2px solid var(--iox-bg);
274
+ box-sizing: border-box;
275
+ cursor: pointer;
276
+ }
277
+ .iox-es-field .iox-field__control:where([type='range']):focus-visible {
278
+ box-shadow: none;
279
+ }
280
+ .iox-es-field .iox-field__control:where([type='range']):focus-visible::-webkit-slider-thumb {
281
+ box-shadow: var(--iox-focus-ring);
282
+ }
283
+ .iox-es-field .iox-field__control:where([type='range']):focus-visible::-moz-range-thumb {
284
+ box-shadow: var(--iox-focus-ring);
157
285
  }
158
286
  .iox-es-field .iox-field__control--switch {
159
287
  background: var(--iox-text-muted);