@vectoriox/iox-ui 5.54.0 → 5.56.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.
@@ -0,0 +1,198 @@
1
+ import { parseHexColor, rgbToOklab, toHexColor } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { StyleReadGround } from './rank';
4
+
5
+ /**
6
+ * The palette MEASURE (AI plan §3k, R-D2: a colour is measured, never asked of a model) — ONE function for every
7
+ * reader: the browser measures a picture at upload (canvas pixels → `pixelSamples`), the server measures a picture it
8
+ * reads and a website's CSS (declared colours, weighted by where they paint). Weighted samples in, ≤ 6 colours out,
9
+ * by a deterministic median cut: the same input gives the same hexes on every run, in every runtime. Pure.
10
+ */
11
+
12
+ /** One colour seen, and how much of the reference it covers (pixels, or a CSS colour's weight). */
13
+ export interface ColourSample {
14
+ /** 0–255 each. */
15
+ r: number;
16
+ g: number;
17
+ b: number;
18
+ weight: number;
19
+ }
20
+
21
+ export interface PaletteColour {
22
+ hex: string;
23
+ /** Its part of the whole, 0–1; the palette's shares add up to 1. */
24
+ share: number;
25
+ }
26
+
27
+ export interface PaletteReading {
28
+ palette: PaletteColour[];
29
+ ground: `${StyleReadGround}`;
30
+ /** The most frequent saturated colour — a CANDIDATE seed (the AA audit decides, `referenceSeed`). */
31
+ brand?: string;
32
+ }
33
+
34
+ /** At most this many colours (the StyleRead's palette). */
35
+ export const PALETTE_SIZE = 6;
36
+ /** Channels are bucketed to this many bits before the cut, so noise never decides a colour. */
37
+ const BUCKET_BITS = 5;
38
+ const BUCKET_SHIFT = 8 - BUCKET_BITS;
39
+ /** The sampling ceiling for a picture — every n-th pixel, so a 12 MP photo costs what a thumbnail does. */
40
+ export const MAX_PIXEL_SAMPLES = 20_000;
41
+ /** A pixel under this alpha is not part of the picture. */
42
+ const MIN_ALPHA = 128;
43
+
44
+ /** OKLab lightness / chroma thresholds the reading speaks in. */
45
+ export const PALETTE_THRESHOLDS = {
46
+ /** Above: a light colour. */
47
+ light: 0.72,
48
+ /** Below: a dark colour. */
49
+ dark: 0.38,
50
+ /** At least this share of light (or dark) makes the ground light (or dark); otherwise mixed. */
51
+ groundShare: 0.55,
52
+ /** A brand candidate is at least this colourful… */
53
+ brandChroma: 0.08,
54
+ /** …and neither near-white nor near-black. */
55
+ brandMinL: 0.25,
56
+ brandMaxL: 0.92,
57
+ /** Two palette colours closer than this (OKLab distance) are one colour. */
58
+ mergeDistance: 0.035,
59
+ } as const;
60
+
61
+ /** Every n-th opaque pixel of an RGBA buffer (canvas `ImageData.data`, a decoder's pixels), weight 1 each. */
62
+ export function pixelSamples(rgba: ArrayLike<number>, width: number, height: number, max = MAX_PIXEL_SAMPLES): ColourSample[] {
63
+ const pixels = Math.max(0, Math.floor(width) * Math.floor(height));
64
+ const step = Math.max(1, Math.ceil(pixels / max));
65
+ const out: ColourSample[] = [];
66
+ for (let p = 0; p < pixels; p += step) {
67
+ const i = p * 4;
68
+ if (rgba[i + 3] < MIN_ALPHA) continue;
69
+ out.push({ r: rgba[i], g: rgba[i + 1], b: rgba[i + 2], weight: 1 });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** A palette (hex + share) back to samples — what merging two readings measures. */
75
+ export function paletteSamples(palette: ReadonlyArray<PaletteColour>, weight = 1): ColourSample[] {
76
+ return palette.flatMap(c => {
77
+ const rgb = parseHexColor(c.hex);
78
+ return rgb ? [{ r: Math.round(rgb.r * 255), g: Math.round(rgb.g * 255), b: Math.round(rgb.b * 255), weight: c.share * weight }] : [];
79
+ });
80
+ }
81
+
82
+ type Channel = 'r' | 'g' | 'b';
83
+ const CHANNELS: ReadonlyArray<Channel> = ['r', 'g', 'b'];
84
+
85
+ interface Box { colours: ColourSample[]; weight: number; }
86
+
87
+ const boxOf = (colours: ColourSample[]): Box => ({ colours, weight: colours.reduce((s, c) => s + c.weight, 0) });
88
+
89
+ function range(box: Box, ch: Channel): number {
90
+ let lo = 255;
91
+ let hi = 0;
92
+ for (const c of box.colours) { lo = Math.min(lo, c[ch]); hi = Math.max(hi, c[ch]); }
93
+ return hi - lo;
94
+ }
95
+
96
+ /** The channel a box is widest in (ties: r, then g, then b). */
97
+ function widest(box: Box): { ch: Channel; span: number } {
98
+ return CHANNELS.reduce<{ ch: Channel; span: number }>((best, ch) => {
99
+ const span = range(box, ch);
100
+ return span > best.span ? { ch, span } : best;
101
+ }, { ch: 'r', span: -1 });
102
+ }
103
+
104
+ /** Split at the weighted median of its widest channel (a stable sort, so ties never depend on the runtime). */
105
+ function split(box: Box): [Box, Box] | null {
106
+ const { ch, span } = widest(box);
107
+ if (span <= 0 || box.colours.length < 2) return null;
108
+ const sorted = [...box.colours].sort((a, b) => a[ch] - b[ch] || a.r - b.r || a.g - b.g || a.b - b.b);
109
+ let acc = 0;
110
+ let cut = 1;
111
+ for (let i = 0; i < sorted.length - 1; i++) {
112
+ acc += sorted[i].weight;
113
+ cut = i + 1;
114
+ if (acc >= box.weight / 2) break;
115
+ }
116
+ return [boxOf(sorted.slice(0, cut)), boxOf(sorted.slice(cut))];
117
+ }
118
+
119
+ /** Samples bucketed (BUCKET_BITS per channel), each bucket's weighted mean colour kept. */
120
+ function bucket(samples: ReadonlyArray<ColourSample>): ColourSample[] {
121
+ const buckets = new Map<number, { r: number; g: number; b: number; weight: number }>();
122
+ for (const s of samples) {
123
+ if (!(s.weight > 0)) continue;
124
+ const key = ((s.r >> BUCKET_SHIFT) << (2 * BUCKET_BITS)) | ((s.g >> BUCKET_SHIFT) << BUCKET_BITS) | (s.b >> BUCKET_SHIFT);
125
+ const b = buckets.get(key) ?? { r: 0, g: 0, b: 0, weight: 0 };
126
+ b.r += s.r * s.weight; b.g += s.g * s.weight; b.b += s.b * s.weight; b.weight += s.weight;
127
+ buckets.set(key, b);
128
+ }
129
+ return [...buckets.entries()].sort(([a], [b]) => a - b).map(([, b]) => ({ r: b.r / b.weight, g: b.g / b.weight, b: b.b / b.weight, weight: b.weight }));
130
+ }
131
+
132
+ function meanHex(box: Box): string {
133
+ const sum = box.colours.reduce((s, c) => ({ r: s.r + c.r * c.weight, g: s.g + c.g * c.weight, b: s.b + c.b * c.weight }), { r: 0, g: 0, b: 0 });
134
+ return toHexColor({ r: sum.r / box.weight / 255, g: sum.g / box.weight / 255, b: sum.b / box.weight / 255 }).toUpperCase();
135
+ }
136
+
137
+ const oklabOf = (hex: string) => rgbToOklab(parseHexColor(hex)!);
138
+ const distance = (a: string, b: string) => { const x = oklabOf(a); const y = oklabOf(b); return Math.hypot(x.L - y.L, x.a - y.a, x.b - y.b); };
139
+ const SHARE_PRECISION = 1e4;
140
+
141
+ /** Weighted samples → ≤ 6 colours, largest share first (ties by hex), shares adding up to 1. */
142
+ export function measurePalette(samples: ReadonlyArray<ColourSample>, size = PALETTE_SIZE): PaletteColour[] {
143
+ const colours = bucket(samples);
144
+ if (!colours.length) return [];
145
+ let boxes: Box[] = [boxOf(colours)];
146
+ while (boxes.length < size) {
147
+ // The box to cut: the most weight × span (ties: the earlier box).
148
+ let best = -1;
149
+ let bestScore = 0;
150
+ boxes.forEach((box, i) => {
151
+ const score = box.weight * widest(box).span;
152
+ if (score > bestScore) { best = i; bestScore = score; }
153
+ });
154
+ const halves = best >= 0 ? split(boxes[best]) : null;
155
+ if (!halves) break;
156
+ boxes = [...boxes.slice(0, best), ...halves, ...boxes.slice(best + 1)];
157
+ }
158
+ const total = boxes.reduce((s, b) => s + b.weight, 0);
159
+ const merged: Array<{ hex: string; weight: number }> = [];
160
+ for (const box of [...boxes].sort((a, b) => b.weight - a.weight)) {
161
+ const hex = meanHex(box);
162
+ const near = merged.find(m => distance(m.hex, hex) < PALETTE_THRESHOLDS.mergeDistance);
163
+ if (near) near.weight += box.weight;
164
+ else merged.push({ hex, weight: box.weight });
165
+ }
166
+ return merged
167
+ .map(m => ({ hex: m.hex, share: Math.round((m.weight / total) * SHARE_PRECISION) / SHARE_PRECISION }))
168
+ .sort((a, b) => b.share - a.share || a.hex.localeCompare(b.hex));
169
+ }
170
+
171
+ /** Light, dark or mixed: the share of the palette that is light (or dark). */
172
+ export function paletteGround(palette: ReadonlyArray<PaletteColour>): `${StyleReadGround}` {
173
+ let light = 0;
174
+ let dark = 0;
175
+ for (const c of palette) {
176
+ const { L } = oklabOf(c.hex);
177
+ if (L >= PALETTE_THRESHOLDS.light) light += c.share;
178
+ else if (L <= PALETTE_THRESHOLDS.dark) dark += c.share;
179
+ }
180
+ if (light >= PALETTE_THRESHOLDS.groundShare) return StyleReadGround.Light;
181
+ if (dark >= PALETTE_THRESHOLDS.groundShare) return StyleReadGround.Dark;
182
+ return StyleReadGround.Mixed;
183
+ }
184
+
185
+ /** The most frequent saturated colour that is neither near-white nor near-black, or undefined. */
186
+ export function paletteBrand(palette: ReadonlyArray<PaletteColour>): string | undefined {
187
+ return palette.find(c => {
188
+ const { L, a, b } = oklabOf(c.hex);
189
+ return Math.hypot(a, b) >= PALETTE_THRESHOLDS.brandChroma && L >= PALETTE_THRESHOLDS.brandMinL && L <= PALETTE_THRESHOLDS.brandMaxL;
190
+ })?.hex;
191
+ }
192
+
193
+ /** The whole reading of a set of samples: the palette, its ground, its brand candidate. */
194
+ export function readPalette(samples: ReadonlyArray<ColourSample>): PaletteReading {
195
+ const palette = measurePalette(samples);
196
+ const brand = paletteBrand(palette);
197
+ return { palette, ground: paletteGround(palette), ...(brand ? { brand } : {}) };
198
+ }
@@ -18,3 +18,8 @@ export * from './site-analysis';
18
18
  export * from './prompt-context';
19
19
  export * from './intake';
20
20
  export * from './structure';
21
+ export * from './palette';
22
+ export * from './reference-colour';
23
+ export * from './site-reading';
24
+ export * from './generation-view';
25
+ export * from './starting-design';
@@ -0,0 +1,45 @@
1
+ import { auditDesignContrast, designTokenRows, parseHexColor, presetChoices, stylePresetById } from '@vectoriox/iox-ui/design-core';
2
+ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
3
+
4
+ import { StyleRead } from './rank';
5
+
6
+ /**
7
+ * Which brand colour a style starts from when the owner gave references (AI plan §3k, R4 "the AA gate decides the
8
+ * colour"): the reading's measured `brand` — ONLY when the preset, worn in EVERY mode it offers with that colour as its
9
+ * seed, passes the same contrast audit the contract gates every preset with (`auditDesignContrast`: every element
10
+ * style in every section scheme, form parts, focus rings). Otherwise the preset's own seed stays and the reference
11
+ * colour is a swatch the Colour tab marks "doesn't pass contrast here". The model never sees, picks or changes a hex.
12
+ * Pure; the wizard (Design ▸ Colour) and the server ("Build with these") ask the same question.
13
+ */
14
+
15
+ export interface ReferenceSeed {
16
+ /** The seed to start from: the reference's when it passed, else the preset's. */
17
+ seed: string;
18
+ /** The reference colour measured, or null when there is none (no reading, nothing saturated). */
19
+ reference: string | null;
20
+ /** True when `seed` is the reference colour. */
21
+ used: boolean;
22
+ /** The modes the reference colour failed in (empty when used, or when there is no reference). */
23
+ failsIn: Array<'light' | 'dark'>;
24
+ }
25
+
26
+ /** Every mode `presetId` offers passes the audit with `seed`. */
27
+ export function seedPassesPreset(presetId: string, seed: string): { passes: boolean; failsIn: Array<'light' | 'dark'> } {
28
+ const preset = stylePresetById(presetId);
29
+ if (!preset || !parseHexColor(seed)) return { passes: false, failsIn: [] };
30
+ const failsIn = preset.modes.filter(mode => {
31
+ const choices = presetChoices(preset.id, mode) as DesignChoices;
32
+ return auditDesignContrast(designTokenRows({ ...choices, seed }), mode).some(f => !f.pass);
33
+ });
34
+ return { passes: failsIn.length === 0, failsIn: [...failsIn] };
35
+ }
36
+
37
+ /** The seed a preset starts from, given a reading (or none). Unknown preset → null. */
38
+ export function referenceSeed(read: Pick<StyleRead, 'brand'> | null | undefined, presetId: string): ReferenceSeed | null {
39
+ const preset = stylePresetById(presetId);
40
+ if (!preset) return null;
41
+ const reference = read?.brand && parseHexColor(read.brand) ? read.brand : null;
42
+ if (!reference) return { seed: preset.seed, reference: null, used: false, failsIn: [] };
43
+ const { passes, failsIn } = seedPassesPreset(preset.id, reference);
44
+ return passes ? { seed: reference, reference, used: true, failsIn: [] } : { seed: preset.seed, reference, used: false, failsIn };
45
+ }
@@ -1,7 +1,7 @@
1
1
  import { MOOD_IDS, MoodId } from '@vectoriox/iox-ui/design-core';
2
2
  import {
3
- FACT_KEYS, FactKey, isPlainObject, PathIssue, PathIssueCollector, pathAt, PlainObject, requiredShapeKeys, ShapeEntry,
4
- shapeKeys,
3
+ describeShapeKey, FACT_KEYS, FactKey, isPlainObject, jsonClosedObject, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText,
4
+ PathIssue, PathIssueCollector, pathAt, PlainObject, requiredShapeKeys, ShapeEntry, shapeKeys,
5
5
  } from '@vectoriox/iox-ui/generation-core';
6
6
 
7
7
  import { BUSINESS_TYPE_IDS, NEED_IDS, USER_AVOID_IDS, UserAvoidId } from './business-types';
@@ -189,42 +189,21 @@ export function validateSiteAnalysis(value: unknown, ctx: AnalysisContext): Anal
189
189
  * schema is a hint the provider enforces; `validateSiteAnalysis` stays the gate.
190
190
  */
191
191
 
192
- export type JsonSchema = { [key: string]: unknown };
192
+ export type { JsonSchema } from '@vectoriox/iox-ui/generation-core';
193
193
 
194
194
  /** The name a provider shows the format under (`json_schema.name`). */
195
195
  export const SITE_ANALYSIS_SCHEMA_NAME = 'site_analysis';
196
196
 
197
- const OPTIONAL_MARK = '?';
198
-
199
- const text = (): JsonSchema => ({ type: 'string' });
200
- const oneOf = (values: ReadonlyArray<string>): JsonSchema => ({ type: 'string', enum: [...values] });
201
- const listOf = (items: JsonSchema): JsonSchema => ({ type: 'array', items });
202
-
203
- /** The shape table's own words for a key — the description the model reads. */
204
- function describe(shape: string, key: string): string {
205
- const entry = SHAPE[shape];
206
- return String(entry[key] ?? entry[`${key}${OPTIONAL_MARK}`] ?? '');
207
- }
197
+ const text = jsonText;
198
+ const oneOf = jsonOneOf;
199
+ const listOf = jsonListOf;
208
200
 
209
201
  /** An object of `shape`: exactly its keys, its required ones required, each described by the table. */
210
- function shaped(shape: string, properties: Record<string, JsonSchema>): JsonSchema {
211
- return {
212
- type: 'object',
213
- properties: Object.fromEntries(Object.entries(properties).map(([key, schema]) => [key, { ...schema, description: describe(shape, key) }])),
214
- required: requiredShapeKeys(SHAPE[shape]),
215
- additionalProperties: false,
216
- };
217
- }
202
+ const shaped = (shape: string, properties: Record<string, JsonSchema>): JsonSchema => jsonShapedObject(SHAPE[shape], properties, shape);
218
203
 
219
204
  /** `{ [fact]: factRead }`: the closed fact keys, each optional — a fact the prompt does not state is left out. */
220
205
  function factsSchema(factRead: JsonSchema): JsonSchema {
221
- return {
222
- type: 'object',
223
- properties: Object.fromEntries(FACT_KEYS.map(key => [key, factRead])),
224
- required: [],
225
- additionalProperties: false,
226
- description: describe('analysis', 'facts'),
227
- };
206
+ return jsonClosedObject(Object.fromEntries(FACT_KEYS.map(key => [key, factRead])), [], describeShapeKey(SHAPE['analysis'], 'facts'));
228
207
  }
229
208
 
230
209
  export function siteAnalysisJsonSchema(): JsonSchema {
@@ -0,0 +1,214 @@
1
+ import { DensityId, MOOD_IDS, MoodId, ShapeId, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
2
+ import {
3
+ isPlainObject, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonInteger, PathIssue, PathIssueCollector, pathAt, PlainObject, ShapeEntry,
4
+ shapeKeys, requiredShapeKeys,
5
+ } from '@vectoriox/iox-ui/generation-core';
6
+
7
+ import { paletteSamples, readPalette } from './palette';
8
+ import { StyleRead, StyleReadGround, StyleReadHeadings, StyleReadSourceKind } from './rank';
9
+ import { AnalysisIssue, MAX_ANALYSIS_MOODS, SiteAnalysis, siteAnalysisJsonSchema, validateSiteAnalysis } from './site-analysis';
10
+
11
+ /**
12
+ * The READING call (AI plan §3d step 1, §3k R2, M1-1): ONE `text.fast` call reads the owner's words AND the pictures
13
+ * they attached (image input) and answers with the SiteAnalysis and — when there are pictures — which ones are the
14
+ * owner's own (Your photos) and which are pictures they like (references, style only), plus what the liked pictures
15
+ * FEEL like: moods, the lettering, corners, spacing. Never a colour: the palette is MEASURED (`palette.ts`, R-D2).
16
+ * The format is built from `READING_SHAPE` (keys + their explanations), the gate reads the same table. Pure.
17
+ */
18
+
19
+ /** Which group a picture belongs to (R-D3: from the words; own when the words don't say). */
20
+ export enum PictureRole {
21
+ Own = 'own',
22
+ Reference = 'reference',
23
+ }
24
+
25
+ /** At most this many pictures go to the reading call as image input (§3d: "up to 6 pictures"). */
26
+ export const MAX_READ_PICTURES = 6;
27
+
28
+ export const READING_SHAPE: Readonly<Record<string, ShapeEntry>> = {
29
+ reading: {
30
+ analysis: 'analysis — the reading of the words',
31
+ pictures: 'pictures — the pictures attached, in order',
32
+ },
33
+ pictures: {
34
+ files: 'fileRole[] — one per attached picture, in the order given',
35
+ style: 'pictureStyle — what the pictures the owner LIKES (role reference) feel like; empty moods when there are none',
36
+ },
37
+ fileRole: {
38
+ index: 'integer — the picture, 0-based, in the order given',
39
+ role: "'own' | 'reference' — own: the owner's work, place, products or people, for the site; reference: a picture they like, for style only (\"a site like these\"); own when the words don't say",
40
+ },
41
+ pictureStyle: {
42
+ moods: 'string[] — ≤ 3 moods the liked pictures feel like',
43
+ 'headings?': "'serif' | 'sans' | 'display' | 'mono' — the lettering they show, when they show some",
44
+ 'shape?': 'string — design axes.shapes: their corners',
45
+ 'density?': 'string — design axes.densities: how much room things have',
46
+ },
47
+ };
48
+
49
+ export interface PictureStyle {
50
+ moods: MoodId[];
51
+ headings?: `${StyleReadHeadings}`;
52
+ shape?: ShapeId;
53
+ density?: DensityId;
54
+ }
55
+
56
+ export interface SiteReading {
57
+ analysis: SiteAnalysis;
58
+ /** Present when pictures were attached. */
59
+ pictures?: { files: Array<{ index: number; role: `${PictureRole}` }>; style: PictureStyle };
60
+ }
61
+
62
+ export const SITE_READING_SCHEMA_NAME = 'site_reading';
63
+
64
+ /** The reading's format: the analysis alone when no picture was attached, else the analysis + the pictures. */
65
+ export function siteReadingJsonSchema(pictureCount: number): JsonSchema {
66
+ if (pictureCount <= 0) return siteAnalysisJsonSchema();
67
+ const fileRole = jsonShapedObject(READING_SHAPE['fileRole'], { index: jsonInteger(), role: jsonOneOf(Object.values(PictureRole)) }, 'fileRole');
68
+ const style = jsonShapedObject(READING_SHAPE['pictureStyle'], {
69
+ moods: jsonListOf(jsonOneOf(MOOD_IDS)),
70
+ headings: jsonOneOf(Object.values(StyleReadHeadings)),
71
+ shape: jsonOneOf(Object.keys(SPEC_AXES.shapes)),
72
+ density: jsonOneOf(Object.keys(SPEC_AXES.densities)),
73
+ }, 'pictureStyle');
74
+ const pictures = jsonShapedObject(READING_SHAPE['pictures'], { files: jsonListOf(fileRole), style }, 'pictures');
75
+ return jsonShapedObject(READING_SHAPE['reading'], { analysis: siteAnalysisJsonSchema(), pictures }, 'reading');
76
+ }
77
+
78
+ export enum ReadingIssueCode {
79
+ Shape = 'shape',
80
+ UnknownKey = 'unknown-key',
81
+ Duplicate = 'duplicate',
82
+ InvalidValue = 'invalid-value',
83
+ /** A picture named twice, or one left out. */
84
+ Pictures = 'pictures',
85
+ }
86
+
87
+ export type ReadingIssue = PathIssue<ReadingIssueCode> | AnalysisIssue;
88
+
89
+ class Collector extends PathIssueCollector<ReadingIssueCode> {
90
+ constructor() {
91
+ super('reading', { shape: ReadingIssueCode.Shape, unknownKey: ReadingIssueCode.UnknownKey, duplicate: ReadingIssueCode.Duplicate });
92
+ }
93
+ object(value: unknown, shape: string, path: string): PlainObject | null {
94
+ if (!isPlainObject(value)) { this.add(ReadingIssueCode.Shape, path, 'must be an object'); return null; }
95
+ this.keys(value, shapeKeys(READING_SHAPE[shape]), path);
96
+ for (const key of requiredShapeKeys(READING_SHAPE[shape])) if (value[key] === undefined) this.add(ReadingIssueCode.Shape, pathAt(path, key), 'is required');
97
+ return value;
98
+ }
99
+ oneOf(obj: PlainObject, key: string, allowed: ReadonlyArray<string>, path: string): void {
100
+ const v = obj[key];
101
+ if (v !== undefined && (typeof v !== 'string' || !allowed.includes(v))) this.add(ReadingIssueCode.InvalidValue, pathAt(path, key), `must be one of ${allowed.join(', ')}`);
102
+ }
103
+ }
104
+
105
+ /** The pictures' part: every picture named once, a role each, moods from the closed list. */
106
+ function validatePictures(c: Collector, raw: unknown, count: number): void {
107
+ const pictures = c.object(raw, 'pictures', 'pictures');
108
+ if (!pictures) return;
109
+ const files = c.array(pictures, 'files', 'pictures');
110
+ const seen = new Set<number>();
111
+ files.forEach((f, i) => {
112
+ const path = `pictures.files[${i}]`;
113
+ const file = c.object(f, 'fileRole', path);
114
+ if (!file) return;
115
+ c.oneOf(file, 'role', Object.values(PictureRole), path);
116
+ const index = file['index'];
117
+ if (!Number.isInteger(index) || (index as number) < 0 || (index as number) >= count) c.add(ReadingIssueCode.Pictures, `${path}.index`, `must be a picture 0–${count - 1}`);
118
+ else if (seen.has(index as number)) c.add(ReadingIssueCode.Pictures, `${path}.index`, `picture ${index} is named twice`);
119
+ else seen.add(index as number);
120
+ });
121
+ if (Array.isArray(pictures['files']) && seen.size !== count) c.add(ReadingIssueCode.Pictures, 'pictures.files', `name each of the ${count} pictures once`);
122
+ const style = c.object(pictures['style'], 'pictureStyle', 'pictures.style');
123
+ if (!style) return;
124
+ const moods = c.array(style, 'moods', 'pictures.style');
125
+ if (moods.length > MAX_ANALYSIS_MOODS) c.add(ReadingIssueCode.InvalidValue, 'pictures.style.moods', `at most ${MAX_ANALYSIS_MOODS}`);
126
+ moods.forEach((m, i) => { if (!(MOOD_IDS as ReadonlyArray<unknown>).includes(m)) c.add(ReadingIssueCode.InvalidValue, `pictures.style.moods[${i}]`, `"${String(m)}" is not a mood`); });
127
+ c.oneOf(style, 'headings', Object.values(StyleReadHeadings), 'pictures.style');
128
+ c.oneOf(style, 'shape', Object.keys(SPEC_AXES.shapes), 'pictures.style');
129
+ c.oneOf(style, 'density', Object.keys(SPEC_AXES.densities), 'pictures.style');
130
+ }
131
+
132
+ /** The reading's gate: the analysis by `validateSiteAnalysis` (paths under `analysis.` when pictures came too), the pictures by `READING_SHAPE`. */
133
+ export function validateSiteReading(value: unknown, ctx: { prompt: string; pictureCount: number }): { valid: boolean; issues: ReadingIssue[] } {
134
+ if (ctx.pictureCount <= 0) return validateSiteAnalysis(value, { prompt: ctx.prompt });
135
+ const c = new Collector();
136
+ const reading = c.object(value, 'reading', '');
137
+ if (!reading) return { valid: false, issues: c.issues };
138
+ const analysis = validateSiteAnalysis(reading['analysis'], { prompt: ctx.prompt });
139
+ const issues: ReadingIssue[] = analysis.issues.map(i => ({ ...i, path: pathAt('analysis', i.path) }));
140
+ validatePictures(c, reading['pictures'], ctx.pictureCount);
141
+ issues.push(...c.issues);
142
+ return { valid: issues.length === 0, issues };
143
+ }
144
+
145
+ /** A valid reading's answer → `SiteReading` (the analysis alone answers `{ analysis }`). */
146
+ export function decodeSiteReading(value: unknown, pictureCount: number): SiteReading {
147
+ if (pictureCount <= 0) return { analysis: value as SiteAnalysis };
148
+ return value as SiteReading;
149
+ }
150
+
151
+ // ── Readings → ONE StyleRead ───────────────────────────────────────────────────────────────────────────
152
+
153
+ /** A reference picture as the server read it: its measured palette (and where it is). */
154
+ export interface MeasuredPicture {
155
+ ref: string;
156
+ palette: StyleRead['palette'];
157
+ }
158
+
159
+ /** The liked pictures as a StyleRead: their palettes measured together, what the model saw of them. */
160
+ export function pictureStyleRead(pictures: ReadonlyArray<MeasuredPicture>, style: PictureStyle | null | undefined): StyleRead | null {
161
+ if (!pictures.length) return null;
162
+ const reading = readPalette(pictures.flatMap(p => paletteSamples(p.palette)));
163
+ return {
164
+ sources: pictures.map(p => ({ kind: StyleReadSourceKind.Image, ref: p.ref })),
165
+ palette: reading.palette,
166
+ ...(reading.brand ? { brand: reading.brand } : {}),
167
+ ground: reading.ground,
168
+ moods: [...(style?.moods ?? [])].slice(0, MAX_ANALYSIS_MOODS),
169
+ ...(style?.headings ? { headings: style.headings } : {}),
170
+ ...(style?.shape ? { shape: style.shape } : {}),
171
+ ...(style?.density ? { density: style.density } : {}),
172
+ };
173
+ }
174
+
175
+ /** The most frequent defined value (ties: the first reading's). */
176
+ function mostCommon<T>(values: ReadonlyArray<T | undefined>): T | undefined {
177
+ const counts = new Map<T, number>();
178
+ for (const v of values) if (v !== undefined) counts.set(v, (counts.get(v) ?? 0) + 1);
179
+ let best: T | undefined;
180
+ let n = 0;
181
+ for (const [v, k] of counts) if (k > n) { best = v; n = k; }
182
+ return best;
183
+ }
184
+
185
+ /**
186
+ * Every reading of one generation (the pictures', each site's) → ONE StyleRead: palettes measured together (each
187
+ * reading weighs the same), the ground re-read from that palette, moods by how often they were seen (≤ 3), the rest the
188
+ * most common answer. Null when nothing was read.
189
+ */
190
+ export function mergeStyleReads(reads: ReadonlyArray<StyleRead | null | undefined>): StyleRead | null {
191
+ const all = reads.filter((r): r is StyleRead => !!r);
192
+ if (!all.length) return null;
193
+ if (all.length === 1) return all[0];
194
+ const reading = readPalette(all.flatMap(r => paletteSamples(r.palette)));
195
+ const moodCount = new Map<MoodId, number>();
196
+ all.forEach(r => r.moods.forEach(m => moodCount.set(m, (moodCount.get(m) ?? 0) + 1)));
197
+ const moods = [...moodCount.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m).slice(0, MAX_ANALYSIS_MOODS);
198
+ const pick = <K extends keyof StyleRead>(key: K) => mostCommon(all.map(r => r[key]));
199
+ const headings = pick('headings');
200
+ const shape = pick('shape');
201
+ const density = pick('density');
202
+ const motion = pick('motion');
203
+ return {
204
+ sources: all.flatMap(r => r.sources),
205
+ palette: reading.palette,
206
+ ...(reading.brand ? { brand: reading.brand } : {}),
207
+ ground: reading.palette.length ? reading.ground : StyleReadGround.Mixed,
208
+ moods,
209
+ ...(headings ? { headings } : {}),
210
+ ...(shape ? { shape } : {}),
211
+ ...(density ? { density } : {}),
212
+ ...(motion ? { motion } : {}),
213
+ } as StyleRead;
214
+ }
@@ -0,0 +1,44 @@
1
+ import { DesignChoices, presetChoices } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { motionFor, MotionInput } from './motion';
4
+ import { RankInput, rankWithReferences, StyleRead } from './rank';
5
+ import { referenceSeed } from './reference-colour';
6
+
7
+ /**
8
+ * The look a business STARTS from (AI plan §3d "Build with these", §3o, MO-D3) — ONE rule for the wizard's Design step
9
+ * (a Look pick) and the server's "Build with these": the preset at its defaults, Scrolling · How animated · Between
10
+ * pages started from the kind of business (and the references), the owner's own Motion choices kept, and — given
11
+ * references — the best-ranked style with the reference colour as its seed when that colour passes AA in every mode
12
+ * the style offers (`referenceSeed`). Pure.
13
+ */
14
+
15
+ /** A preset for this business: its choices, Motion from the business (`motionFor`), what the owner already set kept. */
16
+ export function presetDesign(
17
+ presetId: string,
18
+ input: MotionInput | string | null,
19
+ kept: Partial<Pick<DesignChoices, 'scroll' | 'animate' | 'between' | 'motion'>> = {},
20
+ read: StyleRead | null = null,
21
+ ): DesignChoices | null {
22
+ const base = presetChoices(presetId);
23
+ if (!base) return null;
24
+ const motionInput: MotionInput = typeof input === 'string' || input === null ? { businessType: input } : input;
25
+ const start = motionFor(motionInput, presetId, read);
26
+ return { ...base, scroll: start.scroll, animate: start.animate, between: start.between, ...kept };
27
+ }
28
+
29
+ export interface StartingDesign {
30
+ presetId: string;
31
+ design: DesignChoices;
32
+ /** The reference colour was used as the seed (it passed AA in every mode the style offers). */
33
+ referenceColour: boolean;
34
+ }
35
+
36
+ /** The look a business starts from: its best-ranked style (with the references' boost), at its defaults. */
37
+ export function startingDesign(input: (RankInput & MotionInput) | string | null, read: StyleRead | null = null): StartingDesign | null {
38
+ const rankInput: RankInput & MotionInput = typeof input === 'string' || input === null ? { businessType: input } : input;
39
+ const top = rankWithReferences(rankInput, read)[0];
40
+ const design = top ? presetDesign(top.id, rankInput, {}, read) : null;
41
+ if (!top || !design) return null;
42
+ const seed = referenceSeed(read, top.id);
43
+ return seed?.used ? { presetId: top.id, design: { ...design, seed: seed.seed }, referenceColour: true } : { presetId: top.id, design, referenceColour: false };
44
+ }