@vectoriox/iox-ui 5.53.0 → 5.55.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,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
+ }