@vectoriox/iox-ui 5.54.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,239 @@
1
+ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import {
4
+ jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText,
5
+ } from './json-schema';
6
+ import { patternOptionalParts, patternSlots, patternTakesBinding, PatternGlobalPosition, SITE_PATTERNS, SitePattern } from './patterns';
7
+ import {
8
+ FACT_KEYS, GlobalSlot, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlan, SitePlanSource, SitePlanSourceKind, SITE_PLAN_VERSION,
9
+ } from './site-plan.model';
10
+ import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
11
+ import { isPlainObject, PlainObject, ShapeEntry } from './shape-check';
12
+
13
+ /**
14
+ * The structured-output format a model writes a Site Plan in (AI plan §3d step 4, M1-3) — built from the SAME shape
15
+ * table `validateSitePlan` reads (`SPEC_SITE_PLAN_SHAPE`) and the SAME pattern library, so the plan a model is told
16
+ * about is the plan the gate refuses. ONE source: a key the table gains breaks this builder until it is written here.
17
+ *
18
+ * Two maps of the shape are written as LISTS on the wire, because no strict mode can say "any key": a section's
19
+ * `slots` (`[{ slot, text }]`, the slot names closed per pattern) and a seed's field values (`values: [{ field,
20
+ * value }]`). `sitePlanFromWire` turns them back; nothing else differs. Every section is one `anyOf` branch per
21
+ * pattern — its id, its declared slots, its optional parts, a binding only when it takes one — and the design, the
22
+ * source and the version are PINNED to the owner's pick and this run (one-value enums), so the provider cannot let
23
+ * a model change the look. The gate still checks both (`plan.design` deep-equals the pick).
24
+ */
25
+
26
+ export const SITE_PLAN_SCHEMA_NAME = 'site_plan';
27
+
28
+ /** The wire keys of the two maps written as lists. */
29
+ export enum SitePlanWireKey {
30
+ SlotName = 'slot',
31
+ SlotText = 'text',
32
+ SeedValues = 'values',
33
+ SeedField = 'field',
34
+ SeedValue = 'value',
35
+ }
36
+
37
+ const SHAPE: Readonly<Record<string, ShapeEntry>> = SPEC_SITE_PLAN_SHAPE;
38
+
39
+ export interface SitePlanSchemaPin {
40
+ /** The owner's pick, verbatim — every design key becomes a one-value enum. */
41
+ design: DesignChoices;
42
+ /** This generation. */
43
+ source: SitePlanSource;
44
+ }
45
+
46
+ const one = (value: string): JsonSchema => jsonOneOf([value]);
47
+ const oneBoolean = (value: boolean): JsonSchema => ({ type: 'boolean', enum: [value] });
48
+
49
+ /** The pick, key by key, as one-value enums; an optional key the pick leaves out may only be left out (or empty). */
50
+ function pinnedDesign(design: DesignChoices): JsonSchema {
51
+ const entry = SHAPE['design'];
52
+ const values = design as unknown as Record<string, unknown>;
53
+ const properties: Record<string, JsonSchema> = {};
54
+ for (const key of Object.keys(entry).map(k => k.replace(/\?$/, ''))) {
55
+ const v = values[key];
56
+ if (typeof v === 'string') properties[key] = one(v);
57
+ else if (typeof v === 'boolean') properties[key] = oneBoolean(v);
58
+ else if (isPlainObject(v)) properties[key] = jsonClosedObject(
59
+ Object.fromEntries(Object.entries(v).map(([role, family]) => [role, one(String(family))])), Object.keys(v));
60
+ else properties[key] = jsonClosedObject({}, []);
61
+ }
62
+ const shaped = jsonShapedObject(entry, properties, 'design');
63
+ // Everything the pick carries is required: a model may not leave a choice out and let a default stand in.
64
+ const given = Object.keys(values).filter(k => values[k] !== undefined && k in properties);
65
+ return { ...shaped, required: [...new Set([...(shaped['required'] as string[]), ...given])] };
66
+ }
67
+
68
+ function sourceSchema(source: SitePlanSource): JsonSchema {
69
+ if (source.kind === SitePlanSourceKind.Ai) return jsonShapedObject(SHAPE['sourceAi'], { kind: one(source.kind), runId: one(source.runId) }, 'sourceAi');
70
+ return jsonShapedObject(SHAPE['sourceTemplate'], {
71
+ kind: one(source.kind), templateId: one(source.templateId), templateVersion: { type: 'integer', enum: [source.templateVersion] },
72
+ }, 'sourceTemplate');
73
+ }
74
+
75
+ function businessSchema(): JsonSchema {
76
+ const facts = jsonClosedObject(Object.fromEntries(FACT_KEYS.map(key => [key, jsonText()])), []);
77
+ return jsonShapedObject(SHAPE['business'], { name: jsonText(), tagline: jsonText(), facts }, 'business');
78
+ }
79
+
80
+ /** A content type's field. */
81
+ export function fieldJsonSchema(): JsonSchema {
82
+ return jsonShapedObject(SHAPE['field'], { name: jsonText(), type: jsonText(), controller: jsonText(), required: jsonBoolean(), note: jsonText() }, 'field');
83
+ }
84
+
85
+ /** A content type, its seed's field values as a list (`values: [{ field, value }]`). */
86
+ export function contentTypeJsonSchema(): JsonSchema {
87
+ const value = jsonAnyOf([jsonText(), jsonListOf(jsonText())]);
88
+ const values = jsonListOf(jsonClosedObject({ [SitePlanWireKey.SeedField]: jsonText(), [SitePlanWireKey.SeedValue]: value }, [SitePlanWireKey.SeedField, SitePlanWireKey.SeedValue]));
89
+ const seed = jsonShapedObject(SHAPE['seed'], { count: jsonInteger(), sample: jsonBoolean(), isPublished: jsonBoolean() }, 'seed',
90
+ { key: SitePlanWireKey.SeedValues, schema: values });
91
+ return jsonShapedObject(SHAPE['contentType'], {
92
+ name: jsonText(), label: jsonText(), previewTextField: jsonText(), previewImageField: jsonText(), fields: jsonListOf(fieldJsonSchema()), seed,
93
+ }, 'contentType');
94
+ }
95
+
96
+ /** A data source: a cms list / single item, or a relative one. */
97
+ export function dataSourceJsonSchema(): JsonSchema {
98
+ const byRoute = jsonClosedObject({ field: jsonText(), routeParam: jsonText() }, ['field', 'routeParam']);
99
+ const cms = jsonShapedObject(SHAPE['cmsDataSource'], {
100
+ alias: jsonText(), type: one(PlanDataSourceType.Cms), mode: jsonOneOf(Object.values(PlanDataSourceMode)),
101
+ contentType: jsonText(), limit: jsonInteger(), filterByRouteParam: byRoute,
102
+ }, 'cmsDataSource');
103
+ const relative = jsonShapedObject(SHAPE['relativeDataSource'], {
104
+ alias: jsonText(), type: one(PlanDataSourceType.Relative), of: jsonText(), op: jsonOneOf(Object.values(RelativeOp)), within: jsonText(),
105
+ }, 'relativeDataSource');
106
+ return jsonAnyOf([cms, relative]);
107
+ }
108
+
109
+ /** The patterns a page section may use (every non-global one), in library order. */
110
+ export function sectionPatternIds(): string[] {
111
+ return Object.entries(SITE_PATTERNS).filter(([, p]) => !p.global).map(([id]) => id);
112
+ }
113
+
114
+ /** A key a pattern has nothing for: only `{}` (or leaving it out) — the decoder drops it. */
115
+ const NOTHING = (): JsonSchema => jsonClosedObject({}, []);
116
+
117
+ /** One section branch: this pattern's id, its declared slots (a list), its optional parts, a binding when it takes one. */
118
+ function sectionBranch(id: string, pattern: SitePattern): JsonSchema {
119
+ const slots = patternSlots(pattern);
120
+ const parts = patternOptionalParts(pattern);
121
+ const slotList = slots.length
122
+ ? jsonListOf(jsonClosedObject({ [SitePlanWireKey.SlotName]: jsonOneOf(slots), [SitePlanWireKey.SlotText]: jsonText() }, [SitePlanWireKey.SlotName, SitePlanWireKey.SlotText]))
123
+ : NOTHING();
124
+ const branch = jsonShapedObject(SHAPE['section'], {
125
+ pattern: one(id),
126
+ slots: slotList,
127
+ binding: patternTakesBinding(pattern) ? jsonText() : NOTHING(),
128
+ without: parts.length ? jsonListOf(jsonOneOf(parts)) : NOTHING(),
129
+ }, 'section');
130
+ return { ...branch, description: `${id}: ${pattern.label}` };
131
+ }
132
+
133
+ /** A page section: one branch per pattern. */
134
+ export function sectionJsonSchema(): JsonSchema {
135
+ return jsonAnyOf(sectionPatternIds().map(id => sectionBranch(id, SITE_PATTERNS[id])));
136
+ }
137
+
138
+ /** A page. */
139
+ export function pageJsonSchema(): JsonSchema {
140
+ return jsonShapedObject(SHAPE['page'], {
141
+ key: jsonText(), name: jsonText(), route: jsonText(), isHome: jsonBoolean(), required: jsonBoolean(), inNav: jsonBoolean(),
142
+ template: jsonText(), routeSource: jsonText(), sections: jsonListOf(sectionJsonSchema()),
143
+ }, 'page');
144
+ }
145
+
146
+ const globalIds = (position: `${PatternGlobalPosition}`) => Object.entries(SITE_PATTERNS).filter(([, p]) => p.global === position).map(([id]) => id);
147
+
148
+ /** The Site Plan format, pinned to the owner's pick and this run. */
149
+ export function sitePlanJsonSchema(pin: SitePlanSchemaPin): JsonSchema {
150
+ const globals = jsonShapedObject(SHAPE['globals'], {
151
+ [GlobalSlot.Header]: jsonOneOf(globalIds(PatternGlobalPosition.Before)),
152
+ [GlobalSlot.Footer]: jsonOneOf(globalIds(PatternGlobalPosition.After)),
153
+ }, 'globals');
154
+ return jsonShapedObject(SHAPE['plan'], {
155
+ version: one(SITE_PLAN_VERSION),
156
+ source: sourceSchema(pin.source),
157
+ business: businessSchema(),
158
+ design: pinnedDesign(pin.design),
159
+ contentTypes: jsonListOf(contentTypeJsonSchema()),
160
+ dataSources: jsonListOf(dataSourceJsonSchema()),
161
+ pages: jsonListOf(pageJsonSchema()),
162
+ globals,
163
+ }, 'plan');
164
+ }
165
+
166
+ // ── The wire → the plan ─────────────────────────────────────────────────────────────────────────────
167
+
168
+ const isEmptyObject = (v: unknown) => isPlainObject(v) && Object.keys(v).length === 0;
169
+
170
+ /** `[{ slot, text }]` → `{ slot: text }`; anything that is not that list is left for the gate to refuse. */
171
+ function slotsFromWire(slots: unknown): unknown {
172
+ if (!Array.isArray(slots)) return slots;
173
+ const out: PlainObject = {};
174
+ for (const entry of slots) {
175
+ if (!isPlainObject(entry) || typeof entry[SitePlanWireKey.SlotName] !== 'string') return slots;
176
+ out[entry[SitePlanWireKey.SlotName] as string] = entry[SitePlanWireKey.SlotText];
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** A section off the wire: its slots back to a map; a key the pattern has nothing for (`{}`) dropped. */
182
+ export function sectionFromWire(section: unknown): unknown {
183
+ if (!isPlainObject(section)) return section;
184
+ const out: PlainObject = { ...section };
185
+ for (const key of ['slots', 'binding', 'without']) if (isEmptyObject(out[key])) delete out[key];
186
+ if ('slots' in out) out['slots'] = slotsFromWire(out['slots']);
187
+ return out;
188
+ }
189
+
190
+ /** A content type off the wire: its seed's `values` list back to field keys. */
191
+ export function contentTypeFromWire(type: unknown): unknown {
192
+ if (!isPlainObject(type) || !isPlainObject(type['seed'])) return type;
193
+ const { [SitePlanWireKey.SeedValues]: values, ...seed } = type['seed'] as PlainObject;
194
+ if (Array.isArray(values)) {
195
+ for (const entry of values) {
196
+ if (isPlainObject(entry) && typeof entry[SitePlanWireKey.SeedField] === 'string') seed[entry[SitePlanWireKey.SeedField] as string] = entry[SitePlanWireKey.SeedValue];
197
+ }
198
+ } else if (values !== undefined) seed[SitePlanWireKey.SeedValues] = values;
199
+ return { ...type, seed };
200
+ }
201
+
202
+ function pageFromWire(page: unknown): unknown {
203
+ if (!isPlainObject(page) || !Array.isArray(page['sections'])) return page;
204
+ return { ...page, sections: (page['sections'] as unknown[]).map(sectionFromWire) };
205
+ }
206
+
207
+ /** The design off the wire: an empty optional object (a choice the pick does not carry) dropped. */
208
+ function designFromWire(design: unknown): unknown {
209
+ if (!isPlainObject(design)) return design;
210
+ return Object.fromEntries(Object.entries(design).filter(([, v]) => !isEmptyObject(v)));
211
+ }
212
+
213
+ /**
214
+ * A model's answer → a Site Plan: the two listed maps back to maps, nothing else touched. Never repairs — whatever the
215
+ * answer got wrong is still wrong, for `validateSitePlan` to refuse with a path.
216
+ */
217
+ export function sitePlanFromWire(wire: unknown): SitePlan | unknown {
218
+ if (!isPlainObject(wire)) return wire;
219
+ const out: PlainObject = { ...wire };
220
+ if (Array.isArray(out['contentTypes'])) out['contentTypes'] = (out['contentTypes'] as unknown[]).map(contentTypeFromWire);
221
+ if (Array.isArray(out['pages'])) out['pages'] = (out['pages'] as unknown[]).map(pageFromWire);
222
+ if ('design' in out) out['design'] = designFromWire(out['design']);
223
+ return out;
224
+ }
225
+
226
+ /** The plan → the wire (what a re-ask shows the model, and what the plan edits' prompt carries). */
227
+ export function sitePlanToWire(plan: SitePlan): PlainObject {
228
+ const slotsToWire = (slots: Readonly<Record<string, string>> | undefined) =>
229
+ Object.entries(slots ?? {}).map(([slot, text]) => ({ [SitePlanWireKey.SlotName]: slot, [SitePlanWireKey.SlotText]: text }));
230
+ return {
231
+ ...plan,
232
+ contentTypes: plan.contentTypes.map(t => {
233
+ if (!t.seed) return t;
234
+ const { count, sample, isPublished, ...values } = t.seed;
235
+ return { ...t, seed: { count, sample, isPublished, [SitePlanWireKey.SeedValues]: Object.entries(values).map(([field, value]) => ({ [SitePlanWireKey.SeedField]: field, [SitePlanWireKey.SeedValue]: value })) } };
236
+ }),
237
+ pages: plan.pages.map(p => ({ ...p, sections: p.sections.map(s => (s.slots ? { ...s, slots: slotsToWire(s.slots) } : s)) })),
238
+ };
239
+ }
@@ -0,0 +1,115 @@
1
+ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+ import type { AiGenerationStage, AiStep, PlanChange, PlanEdit, SitePlan } from '@vectoriox/iox-ui/generation-core';
3
+
4
+ import type { StyleRead } from './rank';
5
+ import type { SiteAnalysis } from './site-analysis';
6
+ import type { PictureRole } from './site-reading';
7
+
8
+ /**
9
+ * A generation as the screens read it (AI plan "M1 pipeline — the screens contract"): what iox-cms's
10
+ * `GET …/ai/generations/:id` answers and the Thinking · Analysis · Design · Plan · Build screens render. ONE
11
+ * definition: iox-cms builds it, cms-client types its client with it. Types and enums only — no transport.
12
+ */
13
+
14
+ /** Where one reader is (Thinking's three lines). */
15
+ export enum ReaderState {
16
+ Wait = 'wait',
17
+ Run = 'run',
18
+ Done = 'done',
19
+ /** Nothing to read (no pictures, no address). */
20
+ Skipped = 'skipped',
21
+ /** Refused on purpose (robots.txt, a private address…) — said in one plain line, never blocks the reading. */
22
+ Refused = 'refused',
23
+ Failed = 'failed',
24
+ }
25
+
26
+ /** Why a website address was not read (the screens' i18n `ai.siteRead.<reason>`). */
27
+ export enum SiteReadRefusal {
28
+ /** robots.txt disallows our user agent (`IOX-Import`). */
29
+ Robots = 'robots',
30
+ InvalidAddress = 'invalid-address',
31
+ PrivateAddress = 'private-address',
32
+ Unreachable = 'unreachable',
33
+ Timeout = 'timeout',
34
+ TooLarge = 'too-large',
35
+ NotHtml = 'not-html',
36
+ HttpError = 'http-error',
37
+ TooManyRedirects = 'redirects',
38
+ }
39
+
40
+ /** What a sentence in the Plan prompt box did. */
41
+ export enum PlanChangeOutcome {
42
+ Applied = 'applied',
43
+ /** The closed operations cannot say it — one line, the map unchanged (the call still cost). */
44
+ Cannot = 'cannot',
45
+ /** Its edits did not make a valid plan, twice — the map unchanged. */
46
+ Invalid = 'invalid',
47
+ }
48
+
49
+ export interface GenerationFileView {
50
+ key: string;
51
+ url: string;
52
+ role: `${PictureRole}`;
53
+ }
54
+
55
+ export interface SiteReaderView {
56
+ address: string;
57
+ state: `${ReaderState}`;
58
+ reason?: `${SiteReadRefusal}`;
59
+ }
60
+
61
+ export interface GenerationReadersView {
62
+ words: { state: `${ReaderState}` };
63
+ pictures: { state: `${ReaderState}`; count: number };
64
+ site: { state: `${ReaderState}`; addresses: SiteReaderView[] };
65
+ }
66
+
67
+ export interface PlanRevisionView {
68
+ plan: SitePlan;
69
+ at: string;
70
+ sentence?: string;
71
+ edits?: PlanEdit[];
72
+ changed?: PlanChange[];
73
+ }
74
+
75
+ export interface AiGenerationView {
76
+ _id: string;
77
+ organization: string;
78
+ stage: `${AiGenerationStage}`;
79
+ /** The model step the runner is working on; null = the owner's move. */
80
+ task: `${AiStep}` | null;
81
+ intake: { prompt: string; files: GenerationFileView[]; addresses: string[]; auto: boolean };
82
+ readers: GenerationReadersView;
83
+ reads: { analysis?: { value: SiteAnalysis; model: string; usageIds: string[] }; style?: StyleRead | null };
84
+ /** What Design and Plan use: the owner's edit, else the reading. */
85
+ analysis: SiteAnalysis | null;
86
+ analysisRevision: number;
87
+ design?: { presetId?: string; design: DesignChoices; auto?: boolean };
88
+ plan?: {
89
+ revisions: PlanRevisionView[];
90
+ model: string;
91
+ lastChange?: { sentence: string; outcome: `${PlanChangeOutcome}`; message?: string; at: string };
92
+ };
93
+ siteBuildId?: string;
94
+ models: Partial<Record<`${AiStep}`, string>>;
95
+ estimate: number;
96
+ cap: number;
97
+ /** Derived from the ledger — the running credit total. */
98
+ spent: number;
99
+ error?: { code: string; detail: string; providerMessage?: string; issues?: Array<{ code: string; path: string }> };
100
+ }
101
+
102
+ /** `POST …/ai/generations`. */
103
+ export interface GenerationStartRequest {
104
+ prompt: string;
105
+ files?: Array<{ key: string; role?: `${PictureRole}` }>;
106
+ addresses?: string[];
107
+ }
108
+
109
+ /** The longest message, the most files and addresses a generation starts from (Start's limits). */
110
+ export const GENERATION_LIMITS = {
111
+ promptChars: 4_000,
112
+ files: 12,
113
+ addresses: 3,
114
+ sentenceChars: 500,
115
+ } as const;
@@ -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,7 @@ 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';
@@ -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 {