@vectoriox/iox-ui 5.46.0 → 5.47.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,94 @@
1
+ import { DesignChoices, DesignMotion, parseHexColor, rgbToOklab, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
2
+ import type { FactKey } from '@vectoriox/iox-ui/generation-core';
3
+
4
+ import { AvoidCheck, BUSINESS_TYPES, BusinessType, COPY_LENGTH_ORDER, CopyLength, isLookRule } from './business-types';
5
+ import { lookMatches } from './look';
6
+
7
+ /**
8
+ * Design advice (AI plan rev 3 §3f, H5's core): the business type's avoid rules run against a built site's
9
+ * look, home sections, facts and copy length. ADVICE only — Needs attention and the Publish dialog show it,
10
+ * "Keep it" dismisses it, and nothing is ever refused on it (H-D9).
11
+ */
12
+
13
+ export enum LintKind {
14
+ Look = 'look',
15
+ Content = 'content',
16
+ Fact = 'fact',
17
+ }
18
+
19
+ export interface LintFinding {
20
+ kind: LintKind;
21
+ say: string;
22
+ fact?: FactKey;
23
+ }
24
+
25
+ /** What the lint reads of a site. */
26
+ export interface LintSite {
27
+ design: Pick<DesignChoices, 'flavorId' | 'seed' | 'motion' | 'density' | 'mode'> & Partial<Pick<DesignChoices, 'tint'>> & Partial<DesignMotion>;
28
+ moods?: ReadonlyArray<string>;
29
+ /** The home page's section patterns. */
30
+ homePatterns?: ReadonlyArray<string>;
31
+ /** Every page's section patterns (defaults to the home page's). */
32
+ allPatterns?: ReadonlyArray<string>;
33
+ facts?: Partial<Record<FactKey, string>>;
34
+ copyLength?: `${CopyLength}`;
35
+ heroHasImage?: boolean;
36
+ }
37
+
38
+ const DEGREES = 180 / Math.PI;
39
+ const FULL_TURN = 360;
40
+ /** How far a site may move above what its kind of business usually wears before the lint says so. */
41
+ export const ANIMATE_ADVICE_STEPS = 2;
42
+ const ANIMATE_ORDER = Object.keys(SPEC_AXES.animations);
43
+
44
+ /** A colour's OKLCH hue (degrees) and chroma, through design-core's one OKLab conversion. */
45
+ export function oklchHue(hex: string): { hue: number; chroma: number } | null {
46
+ const rgb = parseHexColor(hex);
47
+ if (!rgb) return null;
48
+ const { a, b } = rgbToOklab(rgb);
49
+ return { hue: (Math.atan2(b, a) * DEGREES + FULL_TURN) % FULL_TURN, chroma: Math.hypot(a, b) };
50
+ }
51
+
52
+ const familyOf = (patternId: string) => patternId.split('.')[0];
53
+
54
+ export function lintDesign(site: LintSite, typeId: string, types: ReadonlyArray<BusinessType> = BUSINESS_TYPES): LintFinding[] {
55
+ const type = types.find(t => t.id === typeId);
56
+ if (!type) return [];
57
+ const out: LintFinding[] = [];
58
+ const home = site.homePatterns ?? [];
59
+ const all = site.allPatterns ?? home;
60
+ for (const rule of type.avoid) {
61
+ if (isLookRule(rule)) {
62
+ if (lookMatches(rule.when, site.design, site.moods ?? [])) out.push({ kind: LintKind.Look, say: rule.say });
63
+ continue;
64
+ }
65
+ switch (rule.check) {
66
+ case AvoidCheck.NeedsFamily:
67
+ if (!home.some(id => familyOf(id) === rule.family)) out.push({ kind: LintKind.Content, say: rule.say });
68
+ break;
69
+ case AvoidCheck.NeedsFact:
70
+ if (!site.facts?.[rule.fact]) out.push({ kind: LintKind.Fact, say: rule.say, fact: rule.fact });
71
+ break;
72
+ case AvoidCheck.ImageHero:
73
+ if (!site.heroHasImage) out.push({ kind: LintKind.Content, say: rule.say });
74
+ break;
75
+ case AvoidCheck.NoPattern:
76
+ if (all.includes(rule.pattern)) out.push({ kind: LintKind.Content, say: rule.say });
77
+ break;
78
+ case AvoidCheck.CopyLength:
79
+ if (site.copyLength && COPY_LENGTH_ORDER.indexOf(site.copyLength) > COPY_LENGTH_ORDER.indexOf(rule.max)) out.push({ kind: LintKind.Content, say: rule.say });
80
+ break;
81
+ case AvoidCheck.BrandHue: {
82
+ const hue = oklchHue(site.design.seed);
83
+ if (hue && hue.chroma >= rule.minChroma && hue.hue >= rule.from && hue.hue <= rule.to) out.push({ kind: LintKind.Look, say: rule.say });
84
+ break;
85
+ }
86
+ }
87
+ }
88
+ // Rev 3.1: two levels above what this kind of business usually wears (Subtle → Lively) is advice, never a block.
89
+ if (site.design.animate && ANIMATE_ORDER.indexOf(site.design.animate) >= ANIMATE_ORDER.indexOf(type.moves.animate) + ANIMATE_ADVICE_STEPS) {
90
+ const usual = (SPEC_AXES.animations as Readonly<Record<string, { label: string }>>)[type.moves.animate].label;
91
+ out.push({ kind: LintKind.Look, say: `${type.label} sites usually move less (${usual}); this much movement as people scroll can feel restless.` });
92
+ }
93
+ return out;
94
+ }
@@ -0,0 +1,32 @@
1
+ import type { DesignChoices, DesignMode, DesignMotion, StylePreset } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { LookCondition } from './business-types';
4
+
5
+ /** The parts of a look a rule can name: the design's choices (motion included) in one colour mode. */
6
+ export type LookDesign = Pick<DesignChoices, 'flavorId' | 'motion' | 'density'> & Partial<Pick<DesignChoices, 'tint'>>
7
+ & Partial<DesignMotion> & { mode: DesignMode };
8
+
9
+ const LIST_AXES = ['flavor', 'mode', 'motion', 'tint', 'density', 'scroll', 'animate', 'between'] as const;
10
+ type ListAxis = (typeof LIST_AXES)[number];
11
+ const DESIGN_KEY: Readonly<Record<ListAxis, keyof LookDesign>> = {
12
+ flavor: 'flavorId', mode: 'mode', motion: 'motion', tint: 'tint', density: 'density',
13
+ scroll: 'scroll', animate: 'animate', between: 'between',
14
+ };
15
+
16
+ /** True when `design` (worn with `moods`) is the look `when` describes — every axis it names must match. */
17
+ export function lookMatches(when: LookCondition, design: LookDesign, moods: ReadonlyArray<string>): boolean {
18
+ for (const axis of LIST_AXES) {
19
+ const values = when[axis] as ReadonlyArray<string> | undefined;
20
+ if (values && !values.includes(String(design[DESIGN_KEY[axis]] ?? ''))) return false;
21
+ }
22
+ if (when.moods && !when.moods.some(m => moods.includes(m))) return false;
23
+ return true;
24
+ }
25
+
26
+ /** A style preset as a rule sees it, in `mode` (default: the mode it opens in). */
27
+ export function presetLook(preset: StylePreset, mode: DesignMode = preset.modes[0]): LookDesign {
28
+ return {
29
+ flavorId: preset.flavorId, motion: preset.motion, density: preset.density, tint: preset.tint, mode,
30
+ ...preset.moves,
31
+ };
32
+ }
@@ -0,0 +1,67 @@
1
+ import { DesignMotion, SPEC_AXES, STYLE_PRESETS, StylePreset } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { BUSINESS_TYPES, BusinessType } from './business-types';
4
+ import { READ_ANIMATE, StyleRead } from './rank';
5
+
6
+ /**
7
+ * Where Design ▸ Motion starts (AI plan rev 3.1 §3p, MO-D3) — and what "Build with these" takes. Pure.
8
+ * Scrolling and Between pages are the BUSINESS's (a spa scrolls softly whatever its look); How animated is the
9
+ * CALMER of the business's (or what the references show) and the style's — so a quiet serif look never gets
10
+ * bouncing cards. The owner's "little movement" caps all three. No matching type → the style's own.
11
+ */
12
+
13
+ export interface MotionInput {
14
+ businessType?: string | null;
15
+ avoid?: ReadonlyArray<string>;
16
+ }
17
+
18
+ export interface MotionStart extends DesignMotion {
19
+ /** Why, in English — one line per step the start took. */
20
+ why: string[];
21
+ }
22
+
23
+ /** The owner asked for little movement (`USER_AVOID_IDS`). */
24
+ export const LITTLE_MOVEMENT_AVOID = 'lots-of-motion';
25
+ /** What "little movement" caps each choice at. */
26
+ export const LITTLE_MOVEMENT_CAP: Readonly<DesignMotion> = { scroll: 'smooth', animate: 'subtle', between: 'fade' };
27
+
28
+ type Axis = keyof DesignMotion;
29
+ const TABLES = { scroll: SPEC_AXES.scrolls, animate: SPEC_AXES.animations, between: SPEC_AXES.betweens } as const;
30
+ const ORDER: Readonly<Record<Axis, ReadonlyArray<string>>> = {
31
+ scroll: Object.keys(SPEC_AXES.scrolls), animate: Object.keys(SPEC_AXES.animations), between: Object.keys(SPEC_AXES.betweens),
32
+ };
33
+
34
+ /** The stiller of two choices on one axis (the axes are ordered stillest first). */
35
+ export function calmerChoice<A extends Axis>(axis: A, a: DesignMotion[A], b: DesignMotion[A]): DesignMotion[A] {
36
+ return ORDER[axis].indexOf(a) <= ORDER[axis].indexOf(b) ? a : b;
37
+ }
38
+
39
+ const label = (axis: Axis, id: string) => (TABLES[axis] as Readonly<Record<string, { label: string }>>)[id]?.label ?? id;
40
+ const triple = (m: DesignMotion) => `${label('scroll', m.scroll)} · ${label('animate', m.animate)} · ${label('between', m.between)}`;
41
+
42
+ export function motionFor(
43
+ input: MotionInput,
44
+ presetId: string,
45
+ read: StyleRead | null = null,
46
+ types: ReadonlyArray<BusinessType> = BUSINESS_TYPES,
47
+ presets: ReadonlyArray<StylePreset> = STYLE_PRESETS,
48
+ ): MotionStart {
49
+ const type = input.businessType ? types.find(t => t.id === input.businessType) : undefined;
50
+ const preset = presets.find(p => p.id === presetId) ?? presets[0];
51
+ const base = type ? type.moves : preset.moves;
52
+ const ref = read?.motion ? READ_ANIMATE[read.motion] : null;
53
+ const wanted = ref ?? base.animate;
54
+ let out: DesignMotion = { scroll: base.scroll, animate: calmerChoice('animate', wanted, preset.moves.animate), between: base.between };
55
+ const why = [type ? `${type.label}: ${triple(base)}` : `${preset.name}: the style's own`];
56
+ if (out.animate !== wanted) why.push(`${preset.name} moves less (${label('animate', preset.moves.animate)})`);
57
+ if (ref && type) why.push(`your references: ${label('animate', ref)}`);
58
+ if ((input.avoid ?? []).includes(LITTLE_MOVEMENT_AVOID)) {
59
+ out = {
60
+ scroll: calmerChoice('scroll', out.scroll, LITTLE_MOVEMENT_CAP.scroll),
61
+ animate: calmerChoice('animate', out.animate, LITTLE_MOVEMENT_CAP.animate),
62
+ between: calmerChoice('between', out.between, LITTLE_MOVEMENT_CAP.between),
63
+ };
64
+ why.push('you asked for little movement');
65
+ }
66
+ return { ...out, why };
67
+ }
@@ -0,0 +1,42 @@
1
+ import { BookingNeed, BUSINESS_TYPES, BusinessType, NeedId } from './business-types';
2
+
3
+ /** Who asked for a need: the owner named it, or the kind of business needs it ("we added"). */
4
+ export enum NeedSource {
5
+ You = 'you',
6
+ Type = 'type',
7
+ }
8
+
9
+ export interface SiteNeed {
10
+ id: NeedId;
11
+ from: `${NeedSource}`;
12
+ /** The owner's own word for it ("Friday workshops"). */
13
+ name?: string;
14
+ }
15
+
16
+ /** The needs that already let a visitor ask for a slot. */
17
+ const BOOKING_NEEDS: ReadonlyArray<NeedId> = ['booking-request', 'sign-up-form'];
18
+ const BOOKING_REQUEST: NeedId = 'booking-request';
19
+
20
+ /**
21
+ * What the site needs (AI plan rev 3 §3d, H3): what the owner named, then what this kind of business needs and
22
+ * they did not name — its page set, in order, and a booking request for a business that must take bookings —
23
+ * each marked `from: 'type'` (the Analysis step's "we added"). Pure; an unknown type adds nothing.
24
+ */
25
+ export function inferNeeds(
26
+ typeId: string | null | undefined,
27
+ said: ReadonlyArray<Omit<SiteNeed, 'from'>> = [],
28
+ types: ReadonlyArray<BusinessType> = BUSINESS_TYPES,
29
+ ): SiteNeed[] {
30
+ const type = typeId ? types.find(t => t.id === typeId) : undefined;
31
+ const out: SiteNeed[] = said.map(n => ({ ...n, from: NeedSource.You }));
32
+ const have = new Set<NeedId>(said.map(n => n.id));
33
+ for (const id of type?.needs ?? []) {
34
+ if (have.has(id)) continue;
35
+ have.add(id);
36
+ out.push({ id, from: NeedSource.Type });
37
+ }
38
+ if (type?.booking === BookingNeed.Must && !BOOKING_NEEDS.some(id => have.has(id))) {
39
+ out.push({ id: BOOKING_REQUEST, from: NeedSource.Type });
40
+ }
41
+ return out;
42
+ }
@@ -0,0 +1,91 @@
1
+ import { DesignChoices, presetChoices } from '@vectoriox/iox-ui/design-core';
2
+ import { FactKey, patternById, SITE_FACT_LABELS, SPEC_SITE_LIBRARY } from '@vectoriox/iox-ui/generation-core';
3
+
4
+ import { BusinessType, businessTypeById, isLookRule } from './business-types';
5
+ import { SiteNeed } from './needs';
6
+ import { SiteAnalysis } from './site-analysis';
7
+
8
+ /**
9
+ * What the PLAN prompt is given (AI plan rev 3 §3f "Retrieval, not the whole knowledge base"): the one matched
10
+ * business rule, the analysis the owner corrected, the style they picked, and the sections that rule's home
11
+ * uses — never the 30 rules or the 24 presets. Design ▸ Look and the prompt read the same rule. Pure; the M1
12
+ * caller serialises it.
13
+ */
14
+
15
+ export interface PromptSection {
16
+ id: string;
17
+ family: string;
18
+ /** The composed pattern's label; null while the library has not composed it yet (track L). */
19
+ label: string | null;
20
+ composed: boolean;
21
+ }
22
+
23
+ export interface PromptContext {
24
+ type: {
25
+ id: string;
26
+ label: string;
27
+ tone: string;
28
+ /** Words the copy must not use — the type's and the owner's. */
29
+ avoidWords: string[];
30
+ imagery: string;
31
+ fonts: string;
32
+ colourMood: string;
33
+ structure: BusinessType['structure'];
34
+ booking: BusinessType['booking'];
35
+ /** What this kind of site should not look like — the look rules' lines. */
36
+ lookAdvice: string[];
37
+ } | null;
38
+ business: SiteAnalysis['business'];
39
+ voice: SiteAnalysis['voice'];
40
+ needs: SiteNeed[];
41
+ facts: Partial<Record<FactKey, string>>;
42
+ /** The facts the prompt did not state — placeholders, never invented. */
43
+ missingFacts: Array<{ key: FactKey; label: string }>;
44
+ moods: SiteAnalysis['moods'];
45
+ hero: SiteAnalysis['hero'];
46
+ locale: SiteAnalysis['locale'];
47
+ design: DesignChoices | null;
48
+ sections: PromptSection[];
49
+ }
50
+
51
+ export interface PromptContextOptions {
52
+ /** The style the owner picked on Design ▸ Look. */
53
+ presetId?: string | null;
54
+ /** …or the exact choices (after Colour / Type / Shape / Motion edits) — wins over `presetId`. */
55
+ design?: DesignChoices | null;
56
+ }
57
+
58
+ const FAMILY_OF: ReadonlyMap<string, string> = new Map(SPEC_SITE_LIBRARY.map(e => [e.id, e.family]));
59
+
60
+ export function analysisPromptContext(analysis: SiteAnalysis, opts: PromptContextOptions = {}): PromptContext {
61
+ const type = businessTypeById(analysis.businessType);
62
+ const facts: Partial<Record<FactKey, string>> = {};
63
+ for (const [key, read] of Object.entries(analysis.facts) as Array<[FactKey, { value: string }]>) facts[key] = read.value;
64
+ return {
65
+ type: type ? {
66
+ id: type.id,
67
+ label: type.label,
68
+ tone: type.tone,
69
+ avoidWords: [...new Set([...type.words, ...analysis.voice.avoidWords])],
70
+ imagery: type.imagery,
71
+ fonts: type.fonts,
72
+ colourMood: type.colour.mood,
73
+ structure: type.structure,
74
+ booking: type.booking,
75
+ lookAdvice: type.avoid.filter(isLookRule).map(r => r.say),
76
+ } : null,
77
+ business: analysis.business,
78
+ voice: analysis.voice,
79
+ needs: analysis.needs,
80
+ facts,
81
+ missingFacts: analysis.missingFacts.map(key => ({ key, label: SITE_FACT_LABELS[key] })),
82
+ moods: analysis.moods,
83
+ hero: analysis.hero,
84
+ locale: analysis.locale,
85
+ design: opts.design ?? (opts.presetId ? presetChoices(opts.presetId) : null),
86
+ sections: (type?.home ?? []).map(id => {
87
+ const pattern = patternById(id);
88
+ return { id, family: pattern?.family ?? FAMILY_OF.get(id) ?? id.split('.')[0], label: pattern?.label ?? null, composed: !!pattern };
89
+ }),
90
+ };
91
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @vectoriox/iox-ui/harness-core — the design HARNESS (AI plan rev 3 §3f, decision N18), FRAMEWORK-FREE: the 30
3
+ * kinds of business and their rules (generated 1:1 from iox-ai-guidance `flavor-spec.v1.json` `harness`), the
4
+ * `SiteAnalysis` gate, what a site needs, the style ranking (+ references), where Motion starts, the design
5
+ * lint and what the plan prompt is given. Pure: no transport, no clock, no randomness. Consumed by iox-cms (the
6
+ * AI pipeline), the wizard and the boards. Depends on design-core and generation-core; neither depends on it.
7
+ * ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig paths — or
8
+ * `nest start` dies with ERR_REQUIRE_ESM while jest and build both pass.
9
+ */
10
+ export * from './harness-spec.generated';
11
+ export * from './business-types';
12
+ export * from './look';
13
+ export * from './rank';
14
+ export * from './motion';
15
+ export * from './needs';
16
+ export * from './lint';
17
+ export * from './site-analysis';
18
+ export * from './prompt-context';
@@ -0,0 +1,226 @@
1
+ import {
2
+ DensityId, flavorPackById, MOOD_LABELS, MoodId, parseFontFamilies, ShapeId, STYLE_PRESETS, StylePreset, TintId,
3
+ } from '@vectoriox/iox-ui/design-core';
4
+ import type { AnimateId } from '@vectoriox/iox-ui/design-core';
5
+
6
+ import { AvoidRule, BUSINESS_TYPES, BusinessType, isLookRule, LookRule, userAvoidRule } from './business-types';
7
+ import { lookMatches, presetLook } from './look';
8
+
9
+ /**
10
+ * Which styles suit a business, best first (AI plan rev 3 §3f, H3) — Design ▸ Look's order. Pure and
11
+ * deterministic: the browser and the server give the same order, and the eval asserts it.
12
+ */
13
+
14
+ /** What the ranker reads of an analysis. */
15
+ export interface RankInput {
16
+ businessType?: string | null;
17
+ moods?: ReadonlyArray<string>;
18
+ /** What the owner asked to avoid (`USER_AVOID_IDS`). */
19
+ avoid?: ReadonlyArray<string>;
20
+ }
21
+
22
+ export enum PresetFit {
23
+ Good = 'good',
24
+ Ok = 'ok',
25
+ Poor = 'poor',
26
+ }
27
+
28
+ /** Why a style sits where it does — the line Design ▸ Look shows. */
29
+ export enum RankReason {
30
+ /** The business type names it as a good fit (`reason` = the type's line). */
31
+ GoodFit = 'good-fit',
32
+ /** A type rule advises against its look (`reason` = the rule's line). */
33
+ TypeAvoid = 'type-avoid',
34
+ /** The owner asked to avoid its look (`reason` = the rule's line). */
35
+ UserAvoid = 'user-avoid',
36
+ /** It shares the analysis's moods (`sharedMoods`). */
37
+ SharedMoods = 'shared-moods',
38
+ None = 'none',
39
+ }
40
+
41
+ export interface RankedPreset {
42
+ id: string;
43
+ fit: PresetFit;
44
+ score: number;
45
+ reasonKind: RankReason;
46
+ /** In English, from the contract (the UI may localise `reasonKind` + `sharedMoods` instead). */
47
+ reason: string;
48
+ sharedMoods: MoodId[];
49
+ /** Catalogue position — ties keep it. */
50
+ order: number;
51
+ /** With references (`rankWithReferences`): the boost the reading gave it, and whether it is the nearest. */
52
+ refBoost?: number;
53
+ nearest?: boolean;
54
+ }
55
+
56
+ /** The ranking's weights: the type's own order, then moods, then motion character, minus what is advised against. */
57
+ export const RANK_WEIGHTS = {
58
+ firstGoodFit: 100,
59
+ goodFitStep: 10,
60
+ sharedMood: 6,
61
+ sharedMove: 2,
62
+ typeAvoid: 60,
63
+ userAvoid: 80,
64
+ } as const;
65
+
66
+ const MOVE_KEYS = ['scroll', 'animate', 'between'] as const;
67
+
68
+ /**
69
+ * The rule among `rules` that `preset`'s look matches — in EVERY mode it offers (a preset that can switch to a
70
+ * mode escaping a mode-only rule is judged in that mode: Night gallery offers light). The rule reported is the
71
+ * one its default mode matches.
72
+ */
73
+ function ruleHit(preset: StylePreset, rules: ReadonlyArray<LookRule>): LookRule | null {
74
+ for (const mode of preset.modes) {
75
+ if (!rules.some(r => lookMatches(r.when, presetLook(preset, mode), preset.moods))) return null;
76
+ }
77
+ return rules.find(r => lookMatches(r.when, presetLook(preset), preset.moods)) ?? null;
78
+ }
79
+
80
+ const lookRules = (rules: ReadonlyArray<AvoidRule>): LookRule[] => rules.filter(isLookRule);
81
+
82
+ function moodsLine(moods: ReadonlyArray<MoodId>): string {
83
+ return `${moods.map(m => MOOD_LABELS[m]).join(' and ')}, like your description.`;
84
+ }
85
+
86
+ /**
87
+ * Every preset, best first: score = the type's own order (a good fit 100, 90, 80…) + 6 per mood shared with
88
+ * the analysis + 2 per motion choice shared with the type, − 60 for a look the type advises against, − 80 for
89
+ * one the owner said to avoid. Ties keep catalogue order. With no matching type, moods alone order them.
90
+ */
91
+ export function rankStylePresets(
92
+ input: RankInput,
93
+ presets: ReadonlyArray<StylePreset> = STYLE_PRESETS,
94
+ types: ReadonlyArray<BusinessType> = BUSINESS_TYPES,
95
+ ): RankedPreset[] {
96
+ const type = input.businessType ? types.find(t => t.id === input.businessType) : undefined;
97
+ const typeRules = type ? lookRules(type.avoid) : [];
98
+ const userRules = (input.avoid ?? []).map(userAvoidRule).filter((r): r is LookRule => !!r);
99
+ const moods = input.moods ?? [];
100
+ return presets.map((preset, order) => {
101
+ const goodAt = type ? type.good.findIndex(g => g.preset === preset.id) : -1;
102
+ const sharedMoods = preset.moods.filter(m => moods.includes(m));
103
+ let score = goodAt >= 0 ? RANK_WEIGHTS.firstGoodFit - RANK_WEIGHTS.goodFitStep * goodAt : 0;
104
+ score += RANK_WEIGHTS.sharedMood * sharedMoods.length;
105
+ if (type) score += RANK_WEIGHTS.sharedMove * MOVE_KEYS.filter(k => preset.moves[k] === type.moves[k]).length;
106
+ const typeHit = ruleHit(preset, typeRules);
107
+ const userHit = ruleHit(preset, userRules);
108
+ if (typeHit) score -= RANK_WEIGHTS.typeAvoid;
109
+ if (userHit) score -= RANK_WEIGHTS.userAvoid;
110
+ const fit = typeHit || userHit ? PresetFit.Poor : goodAt >= 0 ? PresetFit.Good : PresetFit.Ok;
111
+ const [reasonKind, reason] = userHit ? [RankReason.UserAvoid, userHit.say]
112
+ : typeHit ? [RankReason.TypeAvoid, typeHit.say]
113
+ : goodAt >= 0 && type ? [RankReason.GoodFit, type.good[goodAt].reason]
114
+ : sharedMoods.length ? [RankReason.SharedMoods, moodsLine(sharedMoods)]
115
+ : [RankReason.None, ''];
116
+ return { id: preset.id, fit, score, reasonKind, reason, sharedMoods, order };
117
+ }).sort((a, b) => b.score - a.score || a.order - b.order);
118
+ }
119
+
120
+ // ── References: a reading of pictures or a site, as style (plan §3k, R4) ─────────────────────────────────
121
+ export enum StyleReadSourceKind {
122
+ Image = 'image',
123
+ Site = 'site',
124
+ }
125
+ export enum StyleReadGround {
126
+ Light = 'light',
127
+ Dark = 'dark',
128
+ Mixed = 'mixed',
129
+ }
130
+ export enum StyleReadMotion {
131
+ None = 'none',
132
+ Some = 'some',
133
+ Lots = 'lots',
134
+ }
135
+ export enum StyleReadHeadings {
136
+ Serif = 'serif',
137
+ Sans = 'sans',
138
+ Display = 'display',
139
+ Mono = 'mono',
140
+ }
141
+
142
+ /** What a reader measured (palette) and saw (moods, shape…) — never their CSS, text or pictures. */
143
+ export interface StyleRead {
144
+ sources: ReadonlyArray<{ kind: `${StyleReadSourceKind}`; ref: string }>;
145
+ /** ≤ 6, MEASURED, never asked of a model. */
146
+ palette: ReadonlyArray<{ hex: string; share: number }>;
147
+ /** The most frequent saturated colour — a CANDIDATE seed: the AA audit decides. */
148
+ brand?: string;
149
+ ground: `${StyleReadGround}`;
150
+ shape?: ShapeId;
151
+ density?: DensityId;
152
+ motion?: `${StyleReadMotion}`;
153
+ headings?: `${StyleReadHeadings}`;
154
+ moods: ReadonlyArray<MoodId>;
155
+ }
156
+
157
+ /** The boosts a reading gives each preset, and the defaults it suggests. */
158
+ export interface StyleHints {
159
+ boost: Record<string, number>;
160
+ seed: string | null;
161
+ tint: TintId;
162
+ shape?: ShapeId;
163
+ density?: DensityId;
164
+ animate: AnimateId | null;
165
+ }
166
+
167
+ export const READ_WEIGHTS = {
168
+ sharedMood: 8,
169
+ ground: 10,
170
+ shape: 6,
171
+ density: 4,
172
+ serifHeadings: 6,
173
+ } as const;
174
+
175
+ /** How much motion a reading saw → How animated. */
176
+ export const READ_ANIMATE: Readonly<Record<`${StyleReadMotion}`, AnimateId>> = {
177
+ [StyleReadMotion.None]: 'still',
178
+ [StyleReadMotion.Some]: 'subtle',
179
+ [StyleReadMotion.Lots]: 'standard',
180
+ };
181
+
182
+ const SERIF_GENERIC = 'serif';
183
+
184
+ /** True when the preset's pairing sets headings in a serif (its display stack ends in the serif generic). */
185
+ export function presetHasSerifHeadings(preset: StylePreset): boolean {
186
+ const pair = flavorPackById(preset.flavorId)?.pairs.find(p => p.id === preset.pairId);
187
+ return parseFontFamilies(pair?.tokens['font-display'] ?? '').includes(SERIF_GENERIC);
188
+ }
189
+
190
+ /** A reading → a score boost per preset and the defaults it suggests (pure, deterministic). */
191
+ export function mapStyleRead(read: StyleRead, presets: ReadonlyArray<StylePreset> = STYLE_PRESETS): StyleHints {
192
+ const boost: Record<string, number> = {};
193
+ for (const p of presets) {
194
+ let b = READ_WEIGHTS.sharedMood * p.moods.filter(m => read.moods.includes(m)).length;
195
+ if (read.ground !== StyleReadGround.Mixed && p.modes[0] === read.ground) b += READ_WEIGHTS.ground;
196
+ if (read.shape && p.shape === read.shape) b += READ_WEIGHTS.shape;
197
+ if (read.density && p.density === read.density) b += READ_WEIGHTS.density;
198
+ if (read.headings === StyleReadHeadings.Serif && presetHasSerifHeadings(p)) b += READ_WEIGHTS.serifHeadings;
199
+ boost[p.id] = b;
200
+ }
201
+ return {
202
+ boost,
203
+ seed: read.brand ?? null,
204
+ tint: read.palette.length && read.ground === StyleReadGround.Light ? 'subtle' : 'neutral',
205
+ ...(read.shape ? { shape: read.shape } : {}),
206
+ ...(read.density ? { density: read.density } : {}),
207
+ animate: read.motion ? READ_ANIMATE[read.motion] : null,
208
+ };
209
+ }
210
+
211
+ /** `rankStylePresets` + the reading's boost; `nearest` = the best-boosted style that is not a poor fit. */
212
+ export function rankWithReferences(
213
+ input: RankInput,
214
+ read: StyleRead | null | undefined,
215
+ presets: ReadonlyArray<StylePreset> = STYLE_PRESETS,
216
+ types: ReadonlyArray<BusinessType> = BUSINESS_TYPES,
217
+ ): RankedPreset[] {
218
+ const ranked = rankStylePresets(input, presets, types);
219
+ if (!read) return ranked;
220
+ const { boost } = mapStyleRead(read, presets);
221
+ const out = ranked.map(x => ({ ...x, score: x.score + (boost[x.id] ?? 0), refBoost: boost[x.id] ?? 0 }))
222
+ .sort((a, b) => b.score - a.score || a.order - b.order);
223
+ const nearest = out.filter(x => x.fit !== PresetFit.Poor)
224
+ .reduce<RankedPreset | null>((a, b) => (!a || (b.refBoost ?? 0) > (a.refBoost ?? 0) ? b : a), null);
225
+ return out.map(x => ({ ...x, nearest: !!nearest && x.id === nearest.id }));
226
+ }