@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.
@@ -1,5 +1,6 @@
1
- import { MotionId, MoodId, TintId, DensityId, DesignMotion, DesignChoices, DesignMode, StylePreset, AnimateId, ShapeId } from '@vectoriox/iox-ui/design-core';
2
- import { FactKey, PathIssue, SiteTemplate } from '@vectoriox/iox-ui/generation-core';
1
+ import { MotionId, MoodId, TintId, DensityId, DesignMotion, DesignChoices, DesignMode, StylePreset, ShapeId, AnimateId } from '@vectoriox/iox-ui/design-core';
2
+ import { FactKey, PathIssue, JsonSchema, SiteTemplate, ShapeEntry, AiGenerationStage, AiStep, SitePlan, PlanEdit, PlanChange } from '@vectoriox/iox-ui/generation-core';
3
+ export { JsonSchema } from '@vectoriox/iox-ui/generation-core';
3
4
 
4
5
  declare const SPEC_HARNESS_SOURCE: {
5
6
  readonly repo: "nextlevelbuilder/ui-ux-pro-max-skill";
@@ -2572,18 +2573,7 @@ interface AnalysisContext {
2572
2573
  /** At most this many moods (the closed list a preset carries too). */
2573
2574
  declare const MAX_ANALYSIS_MOODS = 3;
2574
2575
  declare function validateSiteAnalysis(value: unknown, ctx: AnalysisContext): AnalysisValidation;
2575
- /**
2576
- * The structured-output schema a model is given to write a `SiteAnalysis` (AI plan §3c/§3d, M0-5): built from the
2577
- * SAME shape table `validateSiteAnalysis` reads (keys, which are required, each key's explanation as its
2578
- * description) and the same closed lists (30 business types, needs, facts, moods, avoids) — so the shape the model
2579
- * is told about is the shape the gate refuses. Written to the strictest common subset of JSON Schema the providers'
2580
- * native strict modes accept (Anthropic `output_config.format`, OpenRouter `json_schema` + `strict`): every object
2581
- * closes with `additionalProperties: false`; no length, count or number bounds (the gate checks those). The
2582
- * schema is a hint the provider enforces; `validateSiteAnalysis` stays the gate.
2583
- */
2584
- type JsonSchema = {
2585
- [key: string]: unknown;
2586
- };
2576
+
2587
2577
  /** The name a provider shows the format under (`json_schema.name`). */
2588
2578
  declare const SITE_ANALYSIS_SCHEMA_NAME = "site_analysis";
2589
2579
  declare function siteAnalysisJsonSchema(): JsonSchema;
@@ -2673,5 +2663,296 @@ interface StructureChoice {
2673
2663
  */
2674
2664
  declare function structureFor(typeId: string | null | undefined): StructureChoice;
2675
2665
 
2676
- export { ANIMATE_ADVICE_STEPS, AnalysisIssueCode, AnalysisLocale, AvoidCheck, BUSINESS_TYPES, BUSINESS_TYPE_IDS, BookingNeed, COPY_LENGTH_ORDER, CopyLength, HARNESS_SOURCE, LITTLE_MOVEMENT_AVOID, LITTLE_MOVEMENT_CAP, LintKind, MAX_ANALYSIS_MOODS, NEEDS, NEED_IDS, NeedSource, PresetFit, RANK_WEIGHTS, READ_ANIMATE, READ_WEIGHTS, RankReason, SITE_ANALYSIS_SCHEMA_NAME, SPEC_ANALYSIS_SHAPE, SPEC_BUSINESS_TYPES, SPEC_HARNESS_EXAMPLES, SPEC_HARNESS_SOURCE, SPEC_NEEDS, SPEC_STRUCTURES, SPEC_USER_AVOIDS, STRUCTURES, StyleReadGround, StyleReadHeadings, StyleReadMotion, StyleReadSourceKind, USER_AVOID_IDS, VoiceFormality, VoicePerson, addressesIn, analysisPromptContext, businessTypeById, calmerChoice, inferNeeds, isLookRule, lintDesign, lookMatches, mapStyleRead, matchBusinessType, matchBusinessTypes, motionAdvice, motionFor, normalizeAddress, oklchHue, presetHasSerifHeadings, presetLook, presetReason, rankStylePresets, rankWithReferences, siteAnalysisJsonSchema, structureFor, userAvoidRule, validateSiteAnalysis, withoutAddress };
2677
- export type { AnalysisContext, AnalysisIssue, AnalysisValidation, AvoidRule, BusinessType, BusinessTypeMatch, CheckRule, FactRead, GoodFit, JsonSchema, LintFinding, LintSite, LookCondition, LookDesign, LookRule, MotionBasis, MotionInput, MotionStart, NeedDef, NeedId, PromptContext, PromptContextOptions, PromptSection, RankInput, RankedPreset, SiteAnalysis, SiteNeed, StructureChoice, StructureId, StyleHints, StyleRead, UserAvoidId };
2666
+ /**
2667
+ * The palette MEASURE (AI plan §3k, R-D2: a colour is measured, never asked of a model) — ONE function for every
2668
+ * reader: the browser measures a picture at upload (canvas pixels → `pixelSamples`), the server measures a picture it
2669
+ * reads and a website's CSS (declared colours, weighted by where they paint). Weighted samples in, ≤ 6 colours out,
2670
+ * by a deterministic median cut: the same input gives the same hexes on every run, in every runtime. Pure.
2671
+ */
2672
+ /** One colour seen, and how much of the reference it covers (pixels, or a CSS colour's weight). */
2673
+ interface ColourSample {
2674
+ /** 0–255 each. */
2675
+ r: number;
2676
+ g: number;
2677
+ b: number;
2678
+ weight: number;
2679
+ }
2680
+ interface PaletteColour {
2681
+ hex: string;
2682
+ /** Its part of the whole, 0–1; the palette's shares add up to 1. */
2683
+ share: number;
2684
+ }
2685
+ interface PaletteReading {
2686
+ palette: PaletteColour[];
2687
+ ground: `${StyleReadGround}`;
2688
+ /** The most frequent saturated colour — a CANDIDATE seed (the AA audit decides, `referenceSeed`). */
2689
+ brand?: string;
2690
+ }
2691
+ /** At most this many colours (the StyleRead's palette). */
2692
+ declare const PALETTE_SIZE = 6;
2693
+ /** The sampling ceiling for a picture — every n-th pixel, so a 12 MP photo costs what a thumbnail does. */
2694
+ declare const MAX_PIXEL_SAMPLES = 20000;
2695
+ /** OKLab lightness / chroma thresholds the reading speaks in. */
2696
+ declare const PALETTE_THRESHOLDS: {
2697
+ /** Above: a light colour. */
2698
+ readonly light: 0.72;
2699
+ /** Below: a dark colour. */
2700
+ readonly dark: 0.38;
2701
+ /** At least this share of light (or dark) makes the ground light (or dark); otherwise mixed. */
2702
+ readonly groundShare: 0.55;
2703
+ /** A brand candidate is at least this colourful… */
2704
+ readonly brandChroma: 0.08;
2705
+ /** …and neither near-white nor near-black. */
2706
+ readonly brandMinL: 0.25;
2707
+ readonly brandMaxL: 0.92;
2708
+ /** Two palette colours closer than this (OKLab distance) are one colour. */
2709
+ readonly mergeDistance: 0.035;
2710
+ };
2711
+ /** Every n-th opaque pixel of an RGBA buffer (canvas `ImageData.data`, a decoder's pixels), weight 1 each. */
2712
+ declare function pixelSamples(rgba: ArrayLike<number>, width: number, height: number, max?: number): ColourSample[];
2713
+ /** A palette (hex + share) back to samples — what merging two readings measures. */
2714
+ declare function paletteSamples(palette: ReadonlyArray<PaletteColour>, weight?: number): ColourSample[];
2715
+ /** Weighted samples → ≤ 6 colours, largest share first (ties by hex), shares adding up to 1. */
2716
+ declare function measurePalette(samples: ReadonlyArray<ColourSample>, size?: number): PaletteColour[];
2717
+ /** Light, dark or mixed: the share of the palette that is light (or dark). */
2718
+ declare function paletteGround(palette: ReadonlyArray<PaletteColour>): `${StyleReadGround}`;
2719
+ /** The most frequent saturated colour that is neither near-white nor near-black, or undefined. */
2720
+ declare function paletteBrand(palette: ReadonlyArray<PaletteColour>): string | undefined;
2721
+ /** The whole reading of a set of samples: the palette, its ground, its brand candidate. */
2722
+ declare function readPalette(samples: ReadonlyArray<ColourSample>): PaletteReading;
2723
+
2724
+ /**
2725
+ * Which brand colour a style starts from when the owner gave references (AI plan §3k, R4 "the AA gate decides the
2726
+ * colour"): the reading's measured `brand` — ONLY when the preset, worn in EVERY mode it offers with that colour as its
2727
+ * seed, passes the same contrast audit the contract gates every preset with (`auditDesignContrast`: every element
2728
+ * style in every section scheme, form parts, focus rings). Otherwise the preset's own seed stays and the reference
2729
+ * colour is a swatch the Colour tab marks "doesn't pass contrast here". The model never sees, picks or changes a hex.
2730
+ * Pure; the wizard (Design ▸ Colour) and the server ("Build with these") ask the same question.
2731
+ */
2732
+ interface ReferenceSeed {
2733
+ /** The seed to start from: the reference's when it passed, else the preset's. */
2734
+ seed: string;
2735
+ /** The reference colour measured, or null when there is none (no reading, nothing saturated). */
2736
+ reference: string | null;
2737
+ /** True when `seed` is the reference colour. */
2738
+ used: boolean;
2739
+ /** The modes the reference colour failed in (empty when used, or when there is no reference). */
2740
+ failsIn: Array<'light' | 'dark'>;
2741
+ }
2742
+ /** Every mode `presetId` offers passes the audit with `seed`. */
2743
+ declare function seedPassesPreset(presetId: string, seed: string): {
2744
+ passes: boolean;
2745
+ failsIn: Array<'light' | 'dark'>;
2746
+ };
2747
+ /** The seed a preset starts from, given a reading (or none). Unknown preset → null. */
2748
+ declare function referenceSeed(read: Pick<StyleRead, 'brand'> | null | undefined, presetId: string): ReferenceSeed | null;
2749
+
2750
+ /**
2751
+ * The READING call (AI plan §3d step 1, §3k R2, M1-1): ONE `text.fast` call reads the owner's words AND the pictures
2752
+ * they attached (image input) and answers with the SiteAnalysis and — when there are pictures — which ones are the
2753
+ * owner's own (Your photos) and which are pictures they like (references, style only), plus what the liked pictures
2754
+ * FEEL like: moods, the lettering, corners, spacing. Never a colour: the palette is MEASURED (`palette.ts`, R-D2).
2755
+ * The format is built from `READING_SHAPE` (keys + their explanations), the gate reads the same table. Pure.
2756
+ */
2757
+ /** Which group a picture belongs to (R-D3: from the words; own when the words don't say). */
2758
+ declare enum PictureRole {
2759
+ Own = "own",
2760
+ Reference = "reference"
2761
+ }
2762
+ /** At most this many pictures go to the reading call as image input (§3d: "up to 6 pictures"). */
2763
+ declare const MAX_READ_PICTURES = 6;
2764
+ declare const READING_SHAPE: Readonly<Record<string, ShapeEntry>>;
2765
+ interface PictureStyle {
2766
+ moods: MoodId[];
2767
+ headings?: `${StyleReadHeadings}`;
2768
+ shape?: ShapeId;
2769
+ density?: DensityId;
2770
+ }
2771
+ interface SiteReading {
2772
+ analysis: SiteAnalysis;
2773
+ /** Present when pictures were attached. */
2774
+ pictures?: {
2775
+ files: Array<{
2776
+ index: number;
2777
+ role: `${PictureRole}`;
2778
+ }>;
2779
+ style: PictureStyle;
2780
+ };
2781
+ }
2782
+ declare const SITE_READING_SCHEMA_NAME = "site_reading";
2783
+ /** The reading's format: the analysis alone when no picture was attached, else the analysis + the pictures. */
2784
+ declare function siteReadingJsonSchema(pictureCount: number): JsonSchema;
2785
+ declare enum ReadingIssueCode {
2786
+ Shape = "shape",
2787
+ UnknownKey = "unknown-key",
2788
+ Duplicate = "duplicate",
2789
+ InvalidValue = "invalid-value",
2790
+ /** A picture named twice, or one left out. */
2791
+ Pictures = "pictures"
2792
+ }
2793
+ type ReadingIssue = PathIssue<ReadingIssueCode> | AnalysisIssue;
2794
+ /** The reading's gate: the analysis by `validateSiteAnalysis` (paths under `analysis.` when pictures came too), the pictures by `READING_SHAPE`. */
2795
+ declare function validateSiteReading(value: unknown, ctx: {
2796
+ prompt: string;
2797
+ pictureCount: number;
2798
+ }): {
2799
+ valid: boolean;
2800
+ issues: ReadingIssue[];
2801
+ };
2802
+ /** A valid reading's answer → `SiteReading` (the analysis alone answers `{ analysis }`). */
2803
+ declare function decodeSiteReading(value: unknown, pictureCount: number): SiteReading;
2804
+ /** A reference picture as the server read it: its measured palette (and where it is). */
2805
+ interface MeasuredPicture {
2806
+ ref: string;
2807
+ palette: StyleRead['palette'];
2808
+ }
2809
+ /** The liked pictures as a StyleRead: their palettes measured together, what the model saw of them. */
2810
+ declare function pictureStyleRead(pictures: ReadonlyArray<MeasuredPicture>, style: PictureStyle | null | undefined): StyleRead | null;
2811
+ /**
2812
+ * Every reading of one generation (the pictures', each site's) → ONE StyleRead: palettes measured together (each
2813
+ * reading weighs the same), the ground re-read from that palette, moods by how often they were seen (≤ 3), the rest the
2814
+ * most common answer. Null when nothing was read.
2815
+ */
2816
+ declare function mergeStyleReads(reads: ReadonlyArray<StyleRead | null | undefined>): StyleRead | null;
2817
+
2818
+ /**
2819
+ * A generation as the screens read it (AI plan "M1 pipeline — the screens contract"): what iox-cms's
2820
+ * `GET …/ai/generations/:id` answers and the Thinking · Analysis · Design · Plan · Build screens render. ONE
2821
+ * definition: iox-cms builds it, cms-client types its client with it. Types and enums only — no transport.
2822
+ */
2823
+ /** Where one reader is (Thinking's three lines). */
2824
+ declare enum ReaderState {
2825
+ Wait = "wait",
2826
+ Run = "run",
2827
+ Done = "done",
2828
+ /** Nothing to read (no pictures, no address). */
2829
+ Skipped = "skipped",
2830
+ /** Refused on purpose (robots.txt, a private address…) — said in one plain line, never blocks the reading. */
2831
+ Refused = "refused",
2832
+ Failed = "failed"
2833
+ }
2834
+ /** Why a website address was not read (the screens' i18n `ai.siteRead.<reason>`). */
2835
+ declare enum SiteReadRefusal {
2836
+ /** robots.txt disallows our user agent (`IOX-Import`). */
2837
+ Robots = "robots",
2838
+ InvalidAddress = "invalid-address",
2839
+ PrivateAddress = "private-address",
2840
+ Unreachable = "unreachable",
2841
+ Timeout = "timeout",
2842
+ TooLarge = "too-large",
2843
+ NotHtml = "not-html",
2844
+ HttpError = "http-error",
2845
+ TooManyRedirects = "redirects"
2846
+ }
2847
+ /** What a sentence in the Plan prompt box did. */
2848
+ declare enum PlanChangeOutcome {
2849
+ Applied = "applied",
2850
+ /** The closed operations cannot say it — one line, the map unchanged (the call still cost). */
2851
+ Cannot = "cannot",
2852
+ /** Its edits did not make a valid plan, twice — the map unchanged. */
2853
+ Invalid = "invalid"
2854
+ }
2855
+ interface GenerationFileView {
2856
+ key: string;
2857
+ url: string;
2858
+ role: `${PictureRole}`;
2859
+ }
2860
+ interface SiteReaderView {
2861
+ address: string;
2862
+ state: `${ReaderState}`;
2863
+ reason?: `${SiteReadRefusal}`;
2864
+ }
2865
+ interface GenerationReadersView {
2866
+ words: {
2867
+ state: `${ReaderState}`;
2868
+ };
2869
+ pictures: {
2870
+ state: `${ReaderState}`;
2871
+ count: number;
2872
+ };
2873
+ site: {
2874
+ state: `${ReaderState}`;
2875
+ addresses: SiteReaderView[];
2876
+ };
2877
+ }
2878
+ interface PlanRevisionView {
2879
+ plan: SitePlan;
2880
+ at: string;
2881
+ sentence?: string;
2882
+ edits?: PlanEdit[];
2883
+ changed?: PlanChange[];
2884
+ }
2885
+ interface AiGenerationView {
2886
+ _id: string;
2887
+ organization: string;
2888
+ stage: `${AiGenerationStage}`;
2889
+ /** The model step the runner is working on; null = the owner's move. */
2890
+ task: `${AiStep}` | null;
2891
+ intake: {
2892
+ prompt: string;
2893
+ files: GenerationFileView[];
2894
+ addresses: string[];
2895
+ auto: boolean;
2896
+ };
2897
+ readers: GenerationReadersView;
2898
+ reads: {
2899
+ analysis?: {
2900
+ value: SiteAnalysis;
2901
+ model: string;
2902
+ usageIds: string[];
2903
+ };
2904
+ style?: StyleRead | null;
2905
+ };
2906
+ /** What Design and Plan use: the owner's edit, else the reading. */
2907
+ analysis: SiteAnalysis | null;
2908
+ analysisRevision: number;
2909
+ design?: {
2910
+ presetId?: string;
2911
+ design: DesignChoices;
2912
+ auto?: boolean;
2913
+ };
2914
+ plan?: {
2915
+ revisions: PlanRevisionView[];
2916
+ model: string;
2917
+ lastChange?: {
2918
+ sentence: string;
2919
+ outcome: `${PlanChangeOutcome}`;
2920
+ message?: string;
2921
+ at: string;
2922
+ };
2923
+ };
2924
+ siteBuildId?: string;
2925
+ models: Partial<Record<`${AiStep}`, string>>;
2926
+ estimate: number;
2927
+ cap: number;
2928
+ /** Derived from the ledger — the running credit total. */
2929
+ spent: number;
2930
+ error?: {
2931
+ code: string;
2932
+ detail: string;
2933
+ providerMessage?: string;
2934
+ issues?: Array<{
2935
+ code: string;
2936
+ path: string;
2937
+ }>;
2938
+ };
2939
+ }
2940
+ /** `POST …/ai/generations`. */
2941
+ interface GenerationStartRequest {
2942
+ prompt: string;
2943
+ files?: Array<{
2944
+ key: string;
2945
+ role?: `${PictureRole}`;
2946
+ }>;
2947
+ addresses?: string[];
2948
+ }
2949
+ /** The longest message, the most files and addresses a generation starts from (Start's limits). */
2950
+ declare const GENERATION_LIMITS: {
2951
+ readonly promptChars: 4000;
2952
+ readonly files: 12;
2953
+ readonly addresses: 3;
2954
+ readonly sentenceChars: 500;
2955
+ };
2956
+
2957
+ export { ANIMATE_ADVICE_STEPS, AnalysisIssueCode, AnalysisLocale, AvoidCheck, BUSINESS_TYPES, BUSINESS_TYPE_IDS, BookingNeed, COPY_LENGTH_ORDER, CopyLength, GENERATION_LIMITS, HARNESS_SOURCE, LITTLE_MOVEMENT_AVOID, LITTLE_MOVEMENT_CAP, LintKind, MAX_ANALYSIS_MOODS, MAX_PIXEL_SAMPLES, MAX_READ_PICTURES, NEEDS, NEED_IDS, NeedSource, PALETTE_SIZE, PALETTE_THRESHOLDS, PictureRole, PlanChangeOutcome, PresetFit, RANK_WEIGHTS, READING_SHAPE, READ_ANIMATE, READ_WEIGHTS, RankReason, ReaderState, ReadingIssueCode, SITE_ANALYSIS_SCHEMA_NAME, SITE_READING_SCHEMA_NAME, SPEC_ANALYSIS_SHAPE, SPEC_BUSINESS_TYPES, SPEC_HARNESS_EXAMPLES, SPEC_HARNESS_SOURCE, SPEC_NEEDS, SPEC_STRUCTURES, SPEC_USER_AVOIDS, STRUCTURES, SiteReadRefusal, StyleReadGround, StyleReadHeadings, StyleReadMotion, StyleReadSourceKind, USER_AVOID_IDS, VoiceFormality, VoicePerson, addressesIn, analysisPromptContext, businessTypeById, calmerChoice, decodeSiteReading, inferNeeds, isLookRule, lintDesign, lookMatches, mapStyleRead, matchBusinessType, matchBusinessTypes, measurePalette, mergeStyleReads, motionAdvice, motionFor, normalizeAddress, oklchHue, paletteBrand, paletteGround, paletteSamples, pictureStyleRead, pixelSamples, presetHasSerifHeadings, presetLook, presetReason, rankStylePresets, rankWithReferences, readPalette, referenceSeed, seedPassesPreset, siteAnalysisJsonSchema, siteReadingJsonSchema, structureFor, userAvoidRule, validateSiteAnalysis, validateSiteReading, withoutAddress };
2958
+ export type { AiGenerationView, AnalysisContext, AnalysisIssue, AnalysisValidation, AvoidRule, BusinessType, BusinessTypeMatch, CheckRule, ColourSample, FactRead, GenerationFileView, GenerationReadersView, GenerationStartRequest, GoodFit, LintFinding, LintSite, LookCondition, LookDesign, LookRule, MeasuredPicture, MotionBasis, MotionInput, MotionStart, NeedDef, NeedId, PaletteColour, PaletteReading, PictureStyle, PlanRevisionView, PromptContext, PromptContextOptions, PromptSection, RankInput, RankedPreset, ReadingIssue, ReferenceSeed, SiteAnalysis, SiteNeed, SiteReaderView, SiteReading, StructureChoice, StructureId, StyleHints, StyleRead, UserAvoidId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.53.0",
3
+ "version": "5.55.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -4,6 +4,8 @@
4
4
  * list with its price per token class, the one cost → credits rule (1 credit = 1 US cent of what the AI cost, D4),
5
5
  * the refusals (board 11) and a generation's stages. ONE definition: iox-cms meters and refuses with it, the
6
6
  * Settings ▸ AI screen and the wizard show it. Pure types, enums and helpers; no transport, no prices typed here.
7
+ * The step and capability spellings are the control plane's (`GET /instances/me/entitlements`, AI plan "M0 control
8
+ * plane — the instance-route contract") — the platform has ONE spelling of each.
7
9
  */
8
10
 
9
11
  export enum AiProviderId {
@@ -19,20 +21,29 @@ export enum AiKeyMode {
19
21
 
20
22
  /** The steps a model works on (board 9's rows), in screen order. */
21
23
  export enum AiStep {
22
- ReadMessage = 'read',
24
+ ReadMessage = 'analysis',
23
25
  PlanSite = 'plan',
24
- ChangePlan = 'change',
25
- MakePictures = 'pictures',
26
- NameStructures = 'scan',
26
+ ChangePlan = 'planEdit',
27
+ MakePictures = 'image',
28
+ NameStructures = 'scanNaming',
27
29
  }
28
30
 
29
31
  export const AI_STEPS: ReadonlyArray<AiStep> = Object.values(AiStep);
30
32
 
33
+ /** Each step's name on the wire (the control plane's `label`, the console) — the screens translate their own. */
34
+ export const AI_STEP_LABELS: Readonly<Record<AiStep, string>> = {
35
+ [AiStep.ReadMessage]: 'Read your message',
36
+ [AiStep.PlanSite]: 'Plan the site',
37
+ [AiStep.ChangePlan]: 'Change the plan',
38
+ [AiStep.MakePictures]: 'Make pictures',
39
+ [AiStep.NameStructures]: 'Name repeated structures',
40
+ };
41
+
31
42
  /** What a model must be able to do for a step to run on it. */
32
43
  export enum AiCapability {
33
- StructuredOutput = 'structured-output',
34
- ImageInput = 'image-input',
35
- ImageOutput = 'image-output',
44
+ StructuredOutput = 'structuredOutputs',
45
+ ImageInput = 'imageInput',
46
+ ImageOutput = 'imageOutput',
36
47
  }
37
48
 
38
49
  export const AI_STEP_NEEDS: Readonly<Record<AiStep, ReadonlyArray<AiCapability>>> = {
@@ -48,7 +59,8 @@ export const AI_STEP_TYPICAL_TOKENS: Readonly<Record<AiStep, AiTokenCounts>> = {
48
59
  [AiStep.ReadMessage]: { input: 8_800, output: 1_500, cacheWrite: 0, cacheRead: 0 },
49
60
  [AiStep.PlanSite]: { input: 12_000, output: 7_000, cacheWrite: 0, cacheRead: 0 },
50
61
  [AiStep.ChangePlan]: { input: 6_000, output: 500, cacheWrite: 0, cacheRead: 0 },
51
- [AiStep.MakePictures]: { input: 1_120, output: 0, cacheWrite: 0, cacheRead: 0 },
62
+ // a 1 K picture is 1,120 image tokens (Google's figure for Nano Banana 2) — I-D4
63
+ [AiStep.MakePictures]: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, imageOutput: 1_120 },
52
64
  [AiStep.NameStructures]: { input: 80_000, output: 10_000, cacheWrite: 0, cacheRead: 0 },
53
65
  };
54
66
 
@@ -60,6 +72,8 @@ export interface AiTokenCounts {
60
72
  cacheWrite: number;
61
73
  /** Read from the prompt cache (priced well below input). */
62
74
  cacheRead: number;
75
+ /** Image tokens written (a picture step) — priced at `imageOutput`. */
76
+ imageOutput?: number;
63
77
  }
64
78
 
65
79
  /** US dollars per million tokens, per class. A class without a price is priced as input. */
@@ -68,6 +82,8 @@ export interface AiTokenPrice {
68
82
  output: number;
69
83
  cacheWrite?: number;
70
84
  cacheRead?: number;
85
+ /** Image-output tokens; a model without the price is priced at `output`. */
86
+ imageOutput?: number;
71
87
  }
72
88
 
73
89
  /** A model of the vetted list (served by the control plane with the entitlements, §3n) — its price travels with it. */
@@ -79,6 +95,8 @@ export interface AiModelEntry {
79
95
  steps: ReadonlyArray<`${AiStep}`>;
80
96
  /** The steps it is the default for (board 9's "recommended"). */
81
97
  recommendedFor?: ReadonlyArray<`${AiStep}`>;
98
+ /** The steps it runs when the default is not available (the control plane's `fallbackModel`). */
99
+ fallbackFor?: ReadonlyArray<`${AiStep}`>;
82
100
  capabilities: ReadonlyArray<`${AiCapability}`>;
83
101
  /** Null when the provider reports each call's cost itself and the list carries no price. */
84
102
  price: AiTokenPrice | null;
@@ -99,7 +117,8 @@ export function costUsd(tokens: AiTokenCounts, price: AiTokenPrice): number {
99
117
  return tokens.input * perToken(price.input, 0)
100
118
  + tokens.output * perToken(price.output, 0)
101
119
  + tokens.cacheWrite * perToken(price.cacheWrite, price.input)
102
- + tokens.cacheRead * perToken(price.cacheRead, price.input);
120
+ + tokens.cacheRead * perToken(price.cacheRead, price.input)
121
+ + (tokens.imageOutput ?? 0) * perToken(price.imageOutput, price.output);
103
122
  }
104
123
 
105
124
  /** Dollars → credits, kept to a millionth of a credit (a short read costs a fraction of one). */
@@ -0,0 +1,97 @@
1
+ import { requiredShapeKeys, ShapeEntry, shapeKeys } from './shape-check';
2
+
3
+ /**
4
+ * The ONE set of builders every structured-output format is written with (AI plan §3c, M0-5 / M1-1 / M1-3 / M1-5):
5
+ * the SiteAnalysis, the reading, the Site Plan and the plan edits. Each object is built FROM a documented shape table
6
+ * (`shape-check`): its keys are the table's keys, the required ones are the table's required ones, each described in
7
+ * the table's own words — and a builder handed a key list that disagrees with its table THROWS, so a shape that gains
8
+ * a key cannot ship a format that silently lacks it. The subset is the strictest both providers' native strict modes
9
+ * accept (Anthropic `output_config.format`, OpenRouter `json_schema` + `strict`): every object closed
10
+ * (`additionalProperties: false`), unions as `anyOf`, single values as a one-entry `enum`, no length / count / number
11
+ * bounds, no `$ref`. The format is a hint the provider enforces; the shared validator stays the gate.
12
+ */
13
+
14
+ export type JsonSchema = { [key: string]: unknown };
15
+
16
+ const OPTIONAL_MARK = '?';
17
+
18
+ export class JsonSchemaShapeError extends Error {}
19
+
20
+ export const jsonText = (): JsonSchema => ({ type: 'string' });
21
+ export const jsonBoolean = (): JsonSchema => ({ type: 'boolean' });
22
+ export const jsonInteger = (): JsonSchema => ({ type: 'integer' });
23
+ export const jsonOneOf = (values: ReadonlyArray<string>): JsonSchema => ({ type: 'string', enum: [...values] });
24
+ export const jsonListOf = (items: JsonSchema): JsonSchema => ({ type: 'array', items });
25
+ export const jsonAnyOf = (options: ReadonlyArray<JsonSchema>): JsonSchema => ({ anyOf: [...options] });
26
+
27
+ /** A closed object with exactly these properties. */
28
+ export function jsonClosedObject(properties: Record<string, JsonSchema>, required: ReadonlyArray<string>, description?: string): JsonSchema {
29
+ return {
30
+ type: 'object',
31
+ properties,
32
+ required: [...required],
33
+ additionalProperties: false,
34
+ ...(description ? { description } : {}),
35
+ };
36
+ }
37
+
38
+ /** The table's own words for a key (`key` or `key?`). */
39
+ export function describeShapeKey(entry: ShapeEntry, key: string): string {
40
+ return String(entry[key] ?? entry[`${key}${OPTIONAL_MARK}`] ?? '');
41
+ }
42
+
43
+ /** An entry's `[name]` keys (the object's own field names) written as ONE list on the wire — no strict mode can say
44
+ * "any key", so the map travels as `[{ name, value }]` under `key` and the decoder turns it back. */
45
+ export interface DynamicKeysAsList {
46
+ /** The wire key holding the list. */
47
+ key: string;
48
+ /** The list's schema. */
49
+ schema: JsonSchema;
50
+ }
51
+
52
+ /**
53
+ * An object of one shape-table entry: exactly its fixed keys, its required keys required, each described by the
54
+ * table; an entry's `[name]` keys are not object keys — `dynamic` writes them as one list. Throws when the properties
55
+ * given are not the table's keys.
56
+ */
57
+ export function jsonShapedObject(entry: ShapeEntry, properties: Record<string, JsonSchema>, name = 'shape', dynamic?: DynamicKeysAsList): JsonSchema {
58
+ const expected = [...shapeKeys(entry)].sort();
59
+ const given = Object.keys(properties).sort();
60
+ if (expected.join() !== given.join()) {
61
+ throw new JsonSchemaShapeError(`${name}: the format's keys (${given.join(', ')}) are not the shape table's (${expected.join(', ')})`);
62
+ }
63
+ const dynamicKey = Object.keys(entry).find(k => /^\[[^\]]+\]\??$/.test(k));
64
+ if (!!dynamic !== !!dynamicKey) throw new JsonSchemaShapeError(`${name}: ${dynamicKey ? `its ${dynamicKey} keys need a list on the wire` : 'it has no [name] keys to list'}`);
65
+ const described = Object.fromEntries(Object.entries(properties).map(([key, schema]) => [key, { ...schema, description: describeShapeKey(entry, key) }]));
66
+ if (dynamic && dynamicKey) described[dynamic.key] = { ...dynamic.schema, description: `${dynamicKey}: ${entry[dynamicKey]}` };
67
+ return jsonClosedObject(described, requiredShapeKeys(entry));
68
+ }
69
+
70
+ /** Every keyword a schema uses — what a spec checks against the strict subset. */
71
+ export function jsonSchemaKeywords(node: unknown, out = new Set<string>()): Set<string> {
72
+ if (Array.isArray(node)) node.forEach(n => jsonSchemaKeywords(n, out));
73
+ else if (node && typeof node === 'object') {
74
+ for (const [k, v] of Object.entries(node)) {
75
+ out.add(k);
76
+ if (k === 'properties') Object.values(v as object).forEach(p => jsonSchemaKeywords(p, out));
77
+ else if (k !== 'enum' && k !== 'required' && k !== 'description') jsonSchemaKeywords(v, out);
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** The keywords the strict subset never uses. */
84
+ export const JSON_SCHEMA_BANNED_KEYWORDS: ReadonlyArray<string> = [
85
+ 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'pattern', 'oneOf', '$ref', 'const', 'format',
86
+ ];
87
+
88
+ /** Every object node of a schema (through properties, items and anyOf). */
89
+ export function jsonSchemaObjects(node: unknown, out: JsonSchema[] = []): JsonSchema[] {
90
+ if (!node || typeof node !== 'object') return out;
91
+ const n = node as JsonSchema;
92
+ if (n['type'] === 'object') out.push(n);
93
+ for (const p of Object.values((n['properties'] as object | undefined) ?? {})) jsonSchemaObjects(p, out);
94
+ if (n['items']) jsonSchemaObjects(n['items'], out);
95
+ for (const option of (n['anyOf'] as unknown[] | undefined) ?? []) jsonSchemaObjects(option, out);
96
+ return out;
97
+ }