@vectoriox/iox-ui 5.83.1 → 5.84.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,4 +1,4 @@
1
- import { NEW_NODE_STYLE_DEFAULTS, defaultElementStyleFor, elementStyleById, ownedTraitsOf, applyElementStyle, flavorPackById, parseHexColor, SPEC_AXES, EntranceParam, staggerDelay, LinkType, COLOR_SCHEMES, setColorScheme, resolveDesignChoices, ANIMATE_IDS, auditDesignContrast } from '@vectoriox/iox-ui/design-core';
1
+ import { NEW_NODE_STYLE_DEFAULTS, defaultElementStyleFor, elementStyleById, ownedTraitsOf, applyElementStyle, parseHexColor, rgbToOklab, flavorPackById, SPEC_AXES, EntranceParam, staggerDelay, LinkType, COLOR_SCHEMES, setColorScheme, resolveDesignChoices, ANIMATE_IDS, auditDesignContrast, parseCssColour, compositeOver, wcagContrastRatio, relativeLuminance } from '@vectoriox/iox-ui/design-core';
2
2
  import { formPresetById, FORM_ACTIONS_ROLE, channelOpensNewTab, FORM_CHANNEL_ROLE, planFormPresetIds, ControlType, FORM_NODE_TYPE, collectFormSpec, FIELD_NODE_TYPE, isChannelFilled } from '@vectoriox/iox-ui/forms-core';
3
3
  import { normalizeFrameSequence, filmScreensFor, FILM_PREPARED_PICTURES, FrameExtension } from '@vectoriox/iox-ui/media-core';
4
4
  import { SEO_DESCRIPTION_MAX, SEO_TITLE_MAX } from '@vectoriox/iox-ui/seo-core';
@@ -20,11 +20,20 @@ const SPEC_SITE_PLAN_SHAPE = {
20
20
  "dataSources": "(cmsDataSource | relativeDataSource)[] — merged by alias into the org library",
21
21
  "pages": "page[]",
22
22
  "globals": "globals",
23
- "film?": "film — the owner's film as pictures (SC6b), set by the build and placed by placeFilm; never a model's"
23
+ "film?": "film — the owner's film as pictures (SC6b), set by the build and placed by placeFilm; never a model's",
24
+ "artDirection?": "artDirection — what every picture made for the site shares (M7): set by the generation from the business type and the look (generation-core artDirectionFor); never a model's"
24
25
  },
25
26
  "film": {
26
27
  "sequence": "object — a media-core FrameSequence: where the numbered pictures live, how many, their size"
27
28
  },
29
+ "artDirection": {
30
+ "world": "string — a site.pictureWorlds id",
31
+ "medium": "string — the picture's medium and lens",
32
+ "light": "string — its light",
33
+ "grade": "string — its colour grade",
34
+ "texture": "string — its texture",
35
+ "negative": "string[] — what no picture of the site may show (site.pictureNever, then the world's own)"
36
+ },
28
37
  "sourceTemplate": {
29
38
  "kind": "'template'",
30
39
  "templateId": "string",
@@ -5778,6 +5787,112 @@ const SPEC_SITE_LIBRARY = [
5778
5787
  "composed": true
5779
5788
  }
5780
5789
  ];
5790
+ const SPEC_SITE_PICTURE_WORLDS = {
5791
+ "documentary": {
5792
+ "label": "Documentary",
5793
+ "medium": "an unposed documentary photograph, 35mm lens, natural eye-level framing",
5794
+ "light": "available natural light, soft and directional",
5795
+ "grade": "true-to-life colour with gentle contrast",
5796
+ "texture": "fine natural grain and real, lived-in surfaces",
5797
+ "negative": [
5798
+ "staged poses",
5799
+ "a studio backdrop"
5800
+ ],
5801
+ "dark": "low-key"
5802
+ },
5803
+ "high-key": {
5804
+ "label": "High-key",
5805
+ "medium": "a bright editorial photograph, 50mm lens, a clean composition with generous air",
5806
+ "light": "soft, even daylight from a large window; few, pale shadows",
5807
+ "grade": "a light, airy palette with lifted whites and low contrast",
5808
+ "texture": "smooth surfaces and crisp, gentle detail",
5809
+ "negative": [
5810
+ "harsh shadows",
5811
+ "dark corners"
5812
+ ],
5813
+ "dark": "low-key"
5814
+ },
5815
+ "low-key": {
5816
+ "label": "Low-key",
5817
+ "medium": "a moody photograph, 50mm lens at a wide aperture, shallow depth of field",
5818
+ "light": "one warm, low light source; most of the frame falls into deep shadow",
5819
+ "grade": "rich blacks with warm, glowing highlights",
5820
+ "texture": "a soft haze in the air and a gentle film grain",
5821
+ "negative": [
5822
+ "flat lighting",
5823
+ "a bright background"
5824
+ ],
5825
+ "dark": "low-key"
5826
+ },
5827
+ "hard-light": {
5828
+ "label": "Hard light",
5829
+ "medium": "a graphic photograph, 35mm lens, bold and simple shapes",
5830
+ "light": "hard, direct sunlight with crisp, long cast shadows",
5831
+ "grade": "saturated colour and punchy contrast",
5832
+ "texture": "sharp edges and clean planes",
5833
+ "negative": [
5834
+ "haze",
5835
+ "muddy colour"
5836
+ ],
5837
+ "dark": "low-key"
5838
+ },
5839
+ "macro": {
5840
+ "label": "Macro",
5841
+ "medium": "a close-up macro photograph, 100mm macro lens, a very shallow plane of focus",
5842
+ "light": "soft side light raking across the surface",
5843
+ "grade": "natural colour with rich mid-tones",
5844
+ "texture": "tactile surface detail — grain, fibre, glaze, crumb",
5845
+ "negative": [
5846
+ "a wide view",
5847
+ "clutter in focus"
5848
+ ],
5849
+ "dark": "low-key"
5850
+ },
5851
+ "architectural": {
5852
+ "label": "Architectural",
5853
+ "medium": "an architectural photograph, 24mm tilt-shift lens, straight verticals and a calm composition",
5854
+ "light": "calm, diffuse daylight",
5855
+ "grade": "neutral, precise colour with balanced contrast",
5856
+ "texture": "clean lines and crisp materials — stone, wood, glass, plaster",
5857
+ "negative": [
5858
+ "converging verticals",
5859
+ "wide-angle distortion"
5860
+ ],
5861
+ "dark": "nocturne"
5862
+ },
5863
+ "nocturne": {
5864
+ "label": "Nocturne",
5865
+ "medium": "a night photograph, 35mm lens",
5866
+ "light": "practical lights only — lamps, candles, a warm window — against the dark",
5867
+ "grade": "deep blue-black shadows with warm pools of light",
5868
+ "texture": "soft bokeh and a light haze",
5869
+ "negative": [
5870
+ "daylight",
5871
+ "flash"
5872
+ ],
5873
+ "dark": "nocturne"
5874
+ },
5875
+ "drawing": {
5876
+ "label": "Drawing",
5877
+ "medium": "a hand-made illustration, ink and watercolour wash on paper",
5878
+ "light": "flat and even, never photographic",
5879
+ "grade": "a limited palette of two or three colours",
5880
+ "texture": "visible paper grain and brush strokes",
5881
+ "negative": [
5882
+ "photographic realism",
5883
+ "a 3D rendering"
5884
+ ],
5885
+ "dark": "drawing"
5886
+ }
5887
+ };
5888
+ const SPEC_SITE_PICTURE_NEVER = [
5889
+ "text, letters or numbers",
5890
+ "logos, brand names or signage",
5891
+ "a watermark",
5892
+ "a recognisable face",
5893
+ "borders or frames",
5894
+ "distorted hands or objects"
5895
+ ];
5781
5896
 
5782
5897
  const NODE_BASELINES = {
5783
5898
  "Button": {
@@ -11654,6 +11769,98 @@ function jsonSchemaObjects(node, out = []) {
11654
11769
  return out;
11655
11770
  }
11656
11771
 
11772
+ /**
11773
+ * A site's ART DIRECTION (premium-components M7, premium sites PS4): what every picture made for the site shares, so its
11774
+ * pictures read as one photographer's and never as a stock pool. A closed WORLD (the contract's `site.pictureWorlds`)
11775
+ * gives the five parts — medium, light, grade, texture and a negative list — and ONE preamble built from them is carried
11776
+ * VERBATIM at the head of every picture prompt of the site (`artDirectionPreamble`).
11777
+ *
11778
+ * Decided here, not by a model: the world is the business type's (harness-core `BusinessType.world`), a dark look moves it
11779
+ * to the world's `dark`, and the brand's hue joins the grade. Zero tokens, the same site always gets the same direction,
11780
+ * and the quality lever — each world's wording — is written once in the contract, as the proof sites' briefs were.
11781
+ */
11782
+ var PictureWorld;
11783
+ (function (PictureWorld) {
11784
+ PictureWorld["Documentary"] = "documentary";
11785
+ PictureWorld["HighKey"] = "high-key";
11786
+ PictureWorld["LowKey"] = "low-key";
11787
+ PictureWorld["HardLight"] = "hard-light";
11788
+ PictureWorld["Macro"] = "macro";
11789
+ PictureWorld["Architectural"] = "architectural";
11790
+ PictureWorld["Nocturne"] = "nocturne";
11791
+ PictureWorld["Drawing"] = "drawing";
11792
+ })(PictureWorld || (PictureWorld = {}));
11793
+ const PICTURE_WORLD_IDS = Object.values(PictureWorld);
11794
+ const PICTURE_WORLDS = SPEC_SITE_PICTURE_WORLDS;
11795
+ /** What no picture of any site shows — every world adds its own after these. */
11796
+ const PICTURE_NEVER = SPEC_SITE_PICTURE_NEVER;
11797
+ /** The five parts every art direction must carry, in the order the preamble says them. */
11798
+ const ART_DIRECTION_PARTS = ['medium', 'light', 'grade', 'texture', 'negative'];
11799
+ function isPictureWorld(value) {
11800
+ return typeof value === 'string' && PICTURE_WORLD_IDS.includes(value);
11801
+ }
11802
+ /** OKLCH hue bands (degrees, upper bound) → the word a grade says; below the chroma floor a brand is neutral. */
11803
+ const HUE_WORDS = [
11804
+ [12, 'rose'], [33, 'red'], [55, 'terracotta orange'], [80, 'warm amber'], [110, 'golden yellow'], [135, 'olive green'],
11805
+ [170, 'green'], [215, 'teal'], [255, 'blue'], [285, 'deep blue'], [315, 'violet'], [345, 'plum'], [360, 'rose'],
11806
+ ];
11807
+ const NEUTRAL_CHROMA = 0.04;
11808
+ const RADIANS_TO_DEGREES = 180 / Math.PI;
11809
+ const FULL_TURN = 360;
11810
+ /** The word for a brand colour's hue (`#9A4F2B` → "terracotta orange"), or null for a grey, a near-black or a bad value. */
11811
+ function brandHueWord(seed) {
11812
+ const rgb = seed ? parseHexColor(seed) : null;
11813
+ if (!rgb)
11814
+ return null;
11815
+ const { a, b } = rgbToOklab(rgb);
11816
+ if (Math.hypot(a, b) < NEUTRAL_CHROMA)
11817
+ return null;
11818
+ const hue = (Math.atan2(b, a) * RADIANS_TO_DEGREES + FULL_TURN) % FULL_TURN;
11819
+ return HUE_WORDS.find(([upTo]) => hue < upTo)?.[1] ?? null;
11820
+ }
11821
+ /**
11822
+ * The site's art direction: the business type's world (moved to its `dark` world when the look is dark), its five parts,
11823
+ * the brand's hue in the grade, PICTURE_NEVER before the world's own negatives. An unknown world is documentary.
11824
+ */
11825
+ function artDirectionFor(world, design) {
11826
+ const start = isPictureWorld(world) ? world : PictureWorld.Documentary;
11827
+ const moved = design.mode === 'dark' ? PICTURE_WORLDS[start].dark : start;
11828
+ const id = isPictureWorld(moved) ? moved : start;
11829
+ const spec = PICTURE_WORLDS[id];
11830
+ const hue = brandHueWord(design.seed);
11831
+ return {
11832
+ world: id,
11833
+ medium: spec.medium,
11834
+ light: spec.light,
11835
+ grade: hue ? `${spec.grade}, leaning toward ${hue} so the pictures sit with the brand` : spec.grade,
11836
+ texture: spec.texture,
11837
+ negative: [...PICTURE_NEVER, ...spec.negative],
11838
+ };
11839
+ }
11840
+ /** The ONE preamble a site's every picture prompt opens with — the same words, in the same order, every time. */
11841
+ function artDirectionPreamble(direction) {
11842
+ const medium = direction.medium.charAt(0).toUpperCase() + direction.medium.slice(1);
11843
+ return `${medium}. Light: ${direction.light}. Colour: ${direction.grade}. Texture: ${direction.texture}. Never: ${direction.negative.join('; ')}.`;
11844
+ }
11845
+ /** What is wrong with a value as an art direction — a path and a message each (`validateSitePlan` reports them). */
11846
+ function artDirectionIssues(value) {
11847
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11848
+ return [{ path: '', message: 'must be an object' }];
11849
+ const v = value;
11850
+ const issues = [];
11851
+ if (!isPictureWorld(v['world']))
11852
+ issues.push({ path: 'world', message: `must be one of ${PICTURE_WORLD_IDS.join(', ')}` });
11853
+ for (const part of ART_DIRECTION_PARTS.filter(p => p !== 'negative')) {
11854
+ if (typeof v[part] !== 'string' || !v[part].trim())
11855
+ issues.push({ path: part, message: 'must be words' });
11856
+ }
11857
+ const negative = v['negative'];
11858
+ if (!Array.isArray(negative) || !negative.length || negative.some(n => typeof n !== 'string' || !n.trim())) {
11859
+ issues.push({ path: 'negative', message: 'must list what no picture may show' });
11860
+ }
11861
+ return issues;
11862
+ }
11863
+
11657
11864
  const LIST_RECIPES = {
11658
11865
  [PatternRecipe.Accordion]: { picks: f => ({ ...pickAccordionAutoBinds(f) }), required: ['question', 'answer'], needs: 'short-text field for the question and long-text field for the answer' },
11659
11866
  [PatternRecipe.Tabs]: { picks: f => ({ ...pickTabsAutoBinds(f) }), required: ['label', 'text'], needs: 'short-text field for the tab and long-text field for its panel' },
@@ -11739,6 +11946,8 @@ var SitePlanIssueCode;
11739
11946
  /** SC6b: the owner's film — pictures that cannot play, too few to play smoothly, or a film's pattern on a plan with
11740
11947
  * no film (or twice). Set by the build, never by a model. */
11741
11948
  SitePlanIssueCode["Film"] = "film";
11949
+ /** M7: the art direction is not a picture world with its five parts. Set by the generation, never by a model. */
11950
+ SitePlanIssueCode["ArtDirection"] = "art-direction";
11742
11951
  })(SitePlanIssueCode || (SitePlanIssueCode = {}));
11743
11952
  class SitePlanError extends Error {
11744
11953
  constructor(issues) {
@@ -11769,6 +11978,7 @@ const CMS_SOURCE_KEYS = shapeKeys(SHAPE['cmsDataSource']);
11769
11978
  const RELATIVE_SOURCE_KEYS = shapeKeys(SHAPE['relativeDataSource']);
11770
11979
  const PAGE_KEYS = shapeKeys(SHAPE['page']);
11771
11980
  const FILM_KEYS = shapeKeys(SHAPE['film']);
11981
+ const ART_DIRECTION_KEYS = shapeKeys(SHAPE['artDirection']);
11772
11982
  const SECTION_KEYS = shapeKeys(SHAPE['section']);
11773
11983
  const GLOBAL_POSITION = {
11774
11984
  [GlobalSlot.Header]: PatternGlobalPosition.Before,
@@ -12253,6 +12463,16 @@ function validateFilm(c, film, pages) {
12253
12463
  });
12254
12464
  });
12255
12465
  }
12466
+ /** M7: what every picture made for the site shares — a known world and its five parts (`artDirectionIssues`). */
12467
+ function validateArtDirection(c, direction) {
12468
+ if (direction === undefined)
12469
+ return;
12470
+ if (isPlainObject(direction))
12471
+ c.keys(direction, ART_DIRECTION_KEYS, 'artDirection');
12472
+ for (const issue of artDirectionIssues(direction)) {
12473
+ c.add(issue.path ? SitePlanIssueCode.ArtDirection : SitePlanIssueCode.Shape, issue.path ? `artDirection.${issue.path}` : 'artDirection', issue.message);
12474
+ }
12475
+ }
12256
12476
  function validatePageSeo(c, seo, path) {
12257
12477
  if (seo === undefined)
12258
12478
  return;
@@ -12307,6 +12527,7 @@ function validateSitePlan(plan) {
12307
12527
  validateGlobals(c, plan['globals']);
12308
12528
  validateScenes(c, c.array(plan, 'pages', ''), typeof design['animate'] === 'string' ? design['animate'] : null);
12309
12529
  validateFilm(c, plan['film'], c.array(plan, 'pages', ''));
12530
+ validateArtDirection(c, plan['artDirection']);
12310
12531
  return { valid: c.issues.length === 0, issues: c.issues };
12311
12532
  }
12312
12533
  /** Throw a `SitePlanError` carrying every issue unless the plan is valid. */
@@ -14499,6 +14720,9 @@ var BuildNoticeCode;
14499
14720
  /** The stock library was read but holds no photo for this kind of business (never built, or reviewed down to
14500
14721
  * none): the picture slots keep the empty box, and the run says so (I2.1 — never silently). */
14501
14722
  BuildNoticeCode["StockMissing"] = "stock-missing";
14723
+ /** M7: a picture made for the site was refused by its review twice (or its second try did not fit the cost shown):
14724
+ * stock or the empty box stands in, and the run says so. */
14725
+ BuildNoticeCode["PictureFallback"] = "picture-fallback";
14502
14726
  })(BuildNoticeCode || (BuildNoticeCode = {}));
14503
14727
  var PreflightRefusalCode;
14504
14728
  (function (PreflightRefusalCode) {
@@ -14827,6 +15051,8 @@ const PLAN_WIRE_DERIVED = {
14827
15051
  design: "the owner's pick (the context)",
14828
15052
  'plan.film': "the owner's film, set by the build (SC6b)",
14829
15053
  film: "the owner's film, set by the build (SC6b)",
15054
+ 'plan.artDirection': 'the generation, from the business type and the look (M7 — artDirectionFor)',
15055
+ artDirection: 'the generation, from the business type and the look (M7 — artDirectionFor)',
14830
15056
  };
14831
15057
  const wireText = (spec) => {
14832
15058
  const [entry, planKey] = spec.refs[0].split('.');
@@ -15170,6 +15396,8 @@ var AiStep;
15170
15396
  AiStep["PlanSite"] = "plan";
15171
15397
  AiStep["ChangePlan"] = "planEdit";
15172
15398
  AiStep["MakePictures"] = "image";
15399
+ /** M7: a small vision model looks at each made picture against its slot (one structured answer). */
15400
+ AiStep["CheckPictures"] = "imageReview";
15173
15401
  AiStep["NameStructures"] = "scanNaming";
15174
15402
  })(AiStep || (AiStep = {}));
15175
15403
  const AI_STEPS = Object.values(AiStep);
@@ -15179,6 +15407,7 @@ const AI_STEP_LABELS = {
15179
15407
  [AiStep.PlanSite]: 'Plan the site',
15180
15408
  [AiStep.ChangePlan]: 'Change the plan',
15181
15409
  [AiStep.MakePictures]: 'Make pictures',
15410
+ [AiStep.CheckPictures]: 'Check pictures',
15182
15411
  [AiStep.NameStructures]: 'Name repeated structures',
15183
15412
  };
15184
15413
  /** What a model must be able to do for a step to run on it. */
@@ -15193,6 +15422,7 @@ const AI_STEP_NEEDS = {
15193
15422
  [AiStep.PlanSite]: [AiCapability.StructuredOutput],
15194
15423
  [AiStep.ChangePlan]: [AiCapability.StructuredOutput],
15195
15424
  [AiStep.MakePictures]: [AiCapability.ImageOutput],
15425
+ [AiStep.CheckPictures]: [AiCapability.StructuredOutput, AiCapability.ImageInput],
15196
15426
  [AiStep.NameStructures]: [AiCapability.StructuredOutput],
15197
15427
  };
15198
15428
  /**
@@ -15227,8 +15457,12 @@ const AI_STEP_TYPICAL_TOKENS = {
15227
15457
  [AiStep.ReadMessage]: { input: 8_800, output: 1_500, cacheWrite: 0, cacheRead: 0 },
15228
15458
  [AiStep.PlanSite]: partsTotal(AiStep.PlanSite),
15229
15459
  [AiStep.ChangePlan]: partsTotal(AiStep.ChangePlan),
15230
- // a 1 K picture is 1,120 image tokens (Google's figure for Nano Banana 2) — I-D4
15231
- [AiStep.MakePictures]: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, imageOutput: 1_120 },
15460
+ // a 1 K picture is 1,120 image tokens (Google's figure for Nano Banana 2) — I-D4; the prompt (the art direction's
15461
+ // preamble, the slot's subject, its frame and empty area) about 400 more in (M7)
15462
+ [AiStep.MakePictures]: { input: 400, output: 0, cacheWrite: 0, cacheRead: 0, imageOutput: 1_120 },
15463
+ // M7: one picture downscaled to 512 px (≈ 350 image tokens for Claude, ≈ 260 for Gemini) + the slot's question,
15464
+ // and a short structured answer
15465
+ [AiStep.CheckPictures]: { input: 1_200, output: 150, cacheWrite: 0, cacheRead: 0 },
15232
15466
  [AiStep.NameStructures]: { input: 80_000, output: 10_000, cacheWrite: 0, cacheRead: 0 },
15233
15467
  };
15234
15468
  /** 1 credit = 1 US cent of what the AI actually cost (decision D4). */
@@ -16177,6 +16411,10 @@ var SiteFindingCode;
16177
16411
  SiteFindingCode["CopyPlaceholder"] = "copy-placeholder";
16178
16412
  SiteFindingCode["SampleItems"] = "sample-items";
16179
16413
  SiteFindingCode["StockPhoto"] = "stock-photo";
16414
+ /** M7: a picture made with AI for the site still stands where one of the studio's own could. */
16415
+ SiteFindingCode["MadePicture"] = "made-picture";
16416
+ /** M7: a picture the build could not make (its review said no twice) and nothing stood in for: an empty box. */
16417
+ SiteFindingCode["PictureMissing"] = "picture-missing";
16180
16418
  SiteFindingCode["NoPrivacyPage"] = "no-privacy-page";
16181
16419
  SiteFindingCode["PrivacyPagePlaceholder"] = "privacy-page-placeholder";
16182
16420
  SiteFindingCode["LowContrast"] = "low-contrast";
@@ -16201,6 +16439,8 @@ const FINDING_SEVERITY = {
16201
16439
  [SiteFindingCode.CopyPlaceholder]: CheckSeverity.Ask,
16202
16440
  [SiteFindingCode.SampleItems]: CheckSeverity.Ask,
16203
16441
  [SiteFindingCode.StockPhoto]: CheckSeverity.Ask,
16442
+ [SiteFindingCode.MadePicture]: CheckSeverity.Ask,
16443
+ [SiteFindingCode.PictureMissing]: CheckSeverity.Warn,
16204
16444
  [SiteFindingCode.NoPrivacyPage]: CheckSeverity.Ask,
16205
16445
  [SiteFindingCode.PrivacyPagePlaceholder]: CheckSeverity.Block,
16206
16446
  [SiteFindingCode.LowContrast]: CheckSeverity.Warn,
@@ -16224,6 +16464,8 @@ const CHECK_OF = {
16224
16464
  [SiteFindingCode.CopyPlaceholder]: SiteCheckId.Facts,
16225
16465
  [SiteFindingCode.SampleItems]: SiteCheckId.Facts,
16226
16466
  [SiteFindingCode.StockPhoto]: SiteCheckId.Facts,
16467
+ [SiteFindingCode.MadePicture]: SiteCheckId.Facts,
16468
+ [SiteFindingCode.PictureMissing]: SiteCheckId.Facts,
16227
16469
  [SiteFindingCode.NoPrivacyPage]: SiteCheckId.Facts,
16228
16470
  [SiteFindingCode.PrivacyPagePlaceholder]: SiteCheckId.Facts,
16229
16471
  [SiteFindingCode.LowContrast]: SiteCheckId.Accessibility,
@@ -16346,6 +16588,10 @@ function checkFacts(ctx, snapshot, pages, personal) {
16346
16588
  add(ctx, SiteFindingCode.SampleItems, `"${s.label ?? s.id}" is a sample — replace or remove it.`, { nodeId: s.id });
16347
16589
  for (const s of (snapshot.stock ?? []).filter(inScope))
16348
16590
  add(ctx, SiteFindingCode.StockPhoto, 'A stock photo stands in for one of yours.', { pageId: s.pageId, nodeId: s.nodeId, owner: s.owner });
16591
+ for (const s of (snapshot.made ?? []).filter(inScope))
16592
+ add(ctx, SiteFindingCode.MadePicture, 'A picture made with AI stands in for one of yours.', { pageId: s.pageId, nodeId: s.nodeId, owner: s.owner });
16593
+ for (const s of (snapshot.missing ?? []).filter(inScope))
16594
+ add(ctx, SiteFindingCode.PictureMissing, 'A picture could not be made for this spot — add one of yours.', { pageId: s.pageId, nodeId: s.nodeId, owner: s.owner });
16349
16595
  if (!personal)
16350
16596
  return;
16351
16597
  const privacy = snapshot.pages.find(p => PRIVACY_PAGE.test(p.route) || PRIVACY_PAGE.test(p.name));
@@ -16844,21 +17090,47 @@ function stockItemSlots(plan, composed) {
16844
17090
  }
16845
17091
  return out;
16846
17092
  }
16847
- const intentRank = (intent) => STOCK_INTENT_ORDER.indexOf(intent);
17093
+ /** How far below the best score a photo may be and still be the seed's choice (one word of the plan's, about). */
17094
+ const STOCK_SEED_MARGIN = 2;
17095
+ /** A stable 32-bit hash (FNV-1a) — the seed's order of the library, the same in every runtime. */
17096
+ function seededRank(seed, key) {
17097
+ let hash = 0x811c9dc5;
17098
+ for (const ch of `${seed}:${key}`) {
17099
+ hash ^= ch.codePointAt(0);
17100
+ hash = Math.imul(hash, 0x01000193) >>> 0;
17101
+ }
17102
+ return hash;
17103
+ }
17104
+ /** The best of `pool` for `slot`: the highest score (ties: the library's order), or — with a seed — the seed's pick among
17105
+ * those within `STOCK_SEED_MARGIN` of it. */
17106
+ function bestFor(pool, slot, imagery, seed) {
17107
+ const scored = pool.map(photo => ({ photo, score: scoreStockPhoto(photo, slot, imagery) }));
17108
+ const top = scored.reduce((m, s) => Math.max(m, s.score), -Infinity);
17109
+ if (!seed)
17110
+ return scored.find(s => s.score === top)?.photo;
17111
+ const near = scored.filter(s => s.score >= top - STOCK_SEED_MARGIN);
17112
+ return near.reduce((best, s) => {
17113
+ const rank = seededRank(seed, s.photo.key);
17114
+ return !best || rank < best.rank ? { photo: s.photo, rank } : best;
17115
+ }, undefined)?.photo;
17116
+ }
17117
+ const intentRank$1 = (intent) => STOCK_INTENT_ORDER.indexOf(intent);
16848
17118
  /**
16849
17119
  * Which photo goes where (I2.1). Slots choose in intent order (the hero first, list items last; page order within),
16850
17120
  * each taking the best-scoring photo that FITS its shape (`stockFits`) and is not already on its page (or among its
16851
17121
  * type's samples) — a photo no slot has taken yet always before one another page shows. Ties go to the library's
16852
- * order. A slot nothing fits keeps its empty box (a sample its abstract picture). None at all when the type says no
16853
- * stock, and never a photo the review rejected or flagged. Returned in the slots' order.
17122
+ * order — or, with a `seed` (M7), the site's own pick among those nearly as good, and another site's photos (`avoid`) only
17123
+ * when nothing fresh fits. A slot nothing fits keeps its empty box (a sample its abstract picture). None at all when the
17124
+ * type says no stock, and never a photo the review rejected or flagged. Returned in the slots' order.
16854
17125
  */
16855
17126
  function planStockPhotos(slots, photos, stockAllowed, opts = {}) {
16856
17127
  if (!stockAllowed)
16857
17128
  return [];
16858
17129
  const usable = photos.filter(stockPhotoUsable);
16859
17130
  const imagery = stockWords(opts.imagery);
16860
- const order = slots.map((slot, i) => ({ slot, i })).sort((a, b) => intentRank(a.slot.intent) - intentRank(b.slot.intent) || a.i - b.i);
16861
- const used = new Set();
17131
+ const order = slots.map((slot, i) => ({ slot, i })).sort((a, b) => intentRank$1(a.slot.intent) - intentRank$1(b.slot.intent) || a.i - b.i);
17132
+ // Another site's photos count as already shown: a fresh one that fits always comes first.
17133
+ const used = new Set(opts.avoid ?? []);
16862
17134
  const shownIn = new Map();
16863
17135
  const placed = new Map();
16864
17136
  for (const { slot, i } of order) {
@@ -16866,16 +17138,7 @@ function planStockPhotos(slots, photos, stockAllowed, opts = {}) {
16866
17138
  const here = shownIn.get(group) ?? new Set();
16867
17139
  const candidates = usable.filter(p => !here.has(p.key) && stockFits(p, slot));
16868
17140
  const fresh = candidates.filter(p => !used.has(p.key));
16869
- const pool = fresh.length ? fresh : candidates;
16870
- let best;
16871
- let bestScore = -Infinity;
16872
- for (const photo of pool) {
16873
- const score = scoreStockPhoto(photo, slot, imagery);
16874
- if (score > bestScore) {
16875
- best = photo;
16876
- bestScore = score;
16877
- }
16878
- }
17141
+ const best = bestFor(fresh.length ? fresh : candidates, slot, imagery, opts.seed);
16879
17142
  if (!best)
16880
17143
  continue;
16881
17144
  used.add(best.key);
@@ -16952,6 +17215,353 @@ function addStockCredits(footer, credits) {
16952
17215
  return added;
16953
17216
  }
16954
17217
 
17218
+ /**
17219
+ * Pictures MADE for a site (premium-components M7, AI plan I3, premium sites PS4) — pure and deterministic: which picture
17220
+ * slots of a composed site get a made picture, the prompt each is asked with (the site's ONE art direction first,
17221
+ * verbatim, then what the slot is about, its frame and — where words sit over it — the part of the frame left calm for
17222
+ * them), what the whole set costs before anything is asked, how a vision model's look at each picture is judged, and how
17223
+ * dark the scrim under the words must be for the picture actually made. iox-cms makes, reviews and stores the pictures;
17224
+ * the wizard shows the count and the cost from the same functions.
17225
+ *
17226
+ * The order of who fills a slot never changes: the OWNER's photos first (`planOwnPhotos`), then — only when the owner
17227
+ * opted in — a made picture where one may stand, then stock where the business type allows it, else the empty box.
17228
+ */
17229
+ /** Why a slot never gets a made picture — said to the owner, never silently skipped. */
17230
+ var PictureRefusal;
17231
+ (function (PictureRefusal) {
17232
+ /** The business sells its own work (a photographer, a maker): a made picture would pass as it. */
17233
+ PictureRefusal["OwnWork"] = "own-work";
17234
+ /** A product, a dish, a piece the business offers: a made one would show a thing it does not sell. */
17235
+ PictureRefusal["Product"] = "product";
17236
+ /** Where to find the business: a made picture would fake a real place. */
17237
+ PictureRefusal["RealPlace"] = "real-place";
17238
+ /** More slots than one site makes pictures for (`MAX_SITE_PICTURES`): the rest keep stock or the empty box. */
17239
+ PictureRefusal["Limit"] = "limit";
17240
+ })(PictureRefusal || (PictureRefusal = {}));
17241
+ /** The section families whose picture is a real place (the contact / visit section). */
17242
+ const PICTURE_REAL_PLACE_FAMILIES = new Set(['contact']);
17243
+ /** The most pictures one site is made — a ceiling on what one build may spend on pictures. */
17244
+ const MAX_SITE_PICTURES = 12;
17245
+ /** Which edge of the frame the words sit at. */
17246
+ var WordsEdge;
17247
+ (function (WordsEdge) {
17248
+ WordsEdge["Top"] = "top";
17249
+ WordsEdge["Bottom"] = "bottom";
17250
+ })(WordsEdge || (WordsEdge = {}));
17251
+ /** A scrim with no height of its own (it wraps its words) covers about this much of the frame. */
17252
+ const WORDS_AREA_DEFAULT_SHARE = 0.45;
17253
+ const MEDIA_SCRIM_STYLE = 'media-scrim';
17254
+ const ABSOLUTE = 'absolute';
17255
+ const ZERO = /^0(px|%)?$/;
17256
+ const PERCENT = /^(\d+(?:\.\d+)?)%$/;
17257
+ /** The frames an image model is asked for (`image_config.aspect_ratio`), as width / height. */
17258
+ const PICTURE_ASPECTS = {
17259
+ '1:1': 1, '2:3': 2 / 3, '3:2': 3 / 2, '3:4': 3 / 4, '4:3': 4 / 3, '4:5': 4 / 5, '5:4': 5 / 4, '9:16': 9 / 16, '16:9': 16 / 9, '21:9': 21 / 9,
17260
+ };
17261
+ /** A slot that fills its box (a Scene's picture, a full-bleed band) is made wide: a phone crops its middle. */
17262
+ const FILL_ASPECT = '16:9';
17263
+ /** The ONE list of picture slots a build fills — its composed sections' empty pictures, then its sample items'. */
17264
+ function pictureTargets(plan, composed) {
17265
+ return [...stockSlots(plan, composed), ...stockItemSlots(plan, composed)];
17266
+ }
17267
+ /** A slot's stable key. */
17268
+ function pictureSlotKey(target) {
17269
+ return target.kind === StockSlotKind.Section ? `page:${target.owner}:${target.nodeId}` : `item:${target.contentType}:${target.index}:${target.field}`;
17270
+ }
17271
+ /** The nearest frame a model is asked for (by the ratio of ratios); a slot with no shape fills its box. */
17272
+ function pictureAspect(aspect) {
17273
+ if (!aspect || !Number.isFinite(aspect) || aspect <= 0)
17274
+ return FILL_ASPECT;
17275
+ let best = FILL_ASPECT;
17276
+ let distance = Infinity;
17277
+ for (const [ratio, value] of Object.entries(PICTURE_ASPECTS)) {
17278
+ const d = Math.abs(Math.log(value / aspect));
17279
+ if (d < distance) {
17280
+ best = ratio;
17281
+ distance = d;
17282
+ }
17283
+ }
17284
+ return best;
17285
+ }
17286
+ function indexNodes(composed) {
17287
+ const at = new Map();
17288
+ composed.pages.forEach((page, p) => page.layout.forEach((root, s) => {
17289
+ walkSerialized([root], (node, ancestors) => at.set(node.id, { node, parent: ancestors[ancestors.length - 1], section: s, page: p }));
17290
+ }));
17291
+ return at;
17292
+ }
17293
+ /**
17294
+ * The words over a picture: a picture laid ABSOLUTE in its box whose sibling wears `media-scrim` (a Scene's stage, a
17295
+ * full-bleed band) carries the words that box holds. The area is the scrim's: its height when it says one, else
17296
+ * `WORDS_AREA_DEFAULT_SHARE`, at the edge it is pinned to.
17297
+ */
17298
+ function wordsOverPicture(node, parent) {
17299
+ if (!parent || node.styleProps['position'] !== ABSOLUTE)
17300
+ return null;
17301
+ const scrim = parent.children?.find(c => c !== node && c.elementStyle === MEDIA_SCRIM_STYLE);
17302
+ if (!scrim)
17303
+ return null;
17304
+ const top = String(scrim.styleProps['top'] ?? '');
17305
+ const bottom = String(scrim.styleProps['bottom'] ?? '');
17306
+ const edge = ZERO.test(top) && !ZERO.test(bottom) ? WordsEdge.Top : WordsEdge.Bottom;
17307
+ const height = PERCENT.exec(String(scrim.styleProps['height'] ?? ''));
17308
+ const share = height ? Math.min(1, Number(height[1]) / 100) : WORDS_AREA_DEFAULT_SHARE;
17309
+ return { area: { edge, share }, scrimNodeId: scrim.id };
17310
+ }
17311
+ const SECTION_TEXT_MAX = 160;
17312
+ /** What a section says (its slots, in the plan's words), cut to a prompt's length. */
17313
+ function sectionText(plan, page, section) {
17314
+ const slots = plan.pages[page]?.sections[section]?.slots ?? {};
17315
+ const text = Object.values(slots).filter((v) => typeof v === 'string' && !!v.trim()).join(' — ').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
17316
+ return text.length > SECTION_TEXT_MAX ? `${text.slice(0, SECTION_TEXT_MAX - 1).trimEnd()}…` : text;
17317
+ }
17318
+ /** Why `target` never gets a made picture, or null. */
17319
+ function pictureRefusalOf(target, stockAllowed) {
17320
+ if (!stockAllowed)
17321
+ return PictureRefusal.OwnWork;
17322
+ if (target.kind === StockSlotKind.Item)
17323
+ return PictureRefusal.Product;
17324
+ const family = patternById(target.pattern)?.family ?? '';
17325
+ return PICTURE_REAL_PLACE_FAMILIES.has(family) ? PictureRefusal.RealPlace : null;
17326
+ }
17327
+ const intentRank = (intent) => STOCK_INTENT_ORDER.indexOf(intent);
17328
+ /**
17329
+ * Which slots get a made picture: the owner's photos take theirs first (the same `planOwnPhotos` the build places them
17330
+ * with), then each slot left — the hero first, page order within — gets one unless it is refused (the work, a product,
17331
+ * a real place) or the site already has `max`. Pure: the wizard's count, the generation's run and the build agree.
17332
+ */
17333
+ function planPictures(plan, composed, opts) {
17334
+ const { placements: own, rest } = planOwnPhotos(pictureTargets(plan, composed), opts.ownPhotos ?? []);
17335
+ const nodes = indexNodes(composed);
17336
+ const max = opts.max ?? MAX_SITE_PICTURES;
17337
+ const ordered = rest.map((t, i) => ({ t, i })).sort((a, b) => intentRank(a.t.intent) - intentRank(b.t.intent) || a.i - b.i).map(x => x.t);
17338
+ const make = [];
17339
+ const refused = [];
17340
+ for (const target of ordered) {
17341
+ const key = pictureSlotKey(target);
17342
+ const reason = pictureRefusalOf(target, opts.stockAllowed) ?? (make.length >= max ? PictureRefusal.Limit : null);
17343
+ if (reason) {
17344
+ refused.push({ key, reason });
17345
+ continue;
17346
+ }
17347
+ make.push(pictureSlotOf(plan, target, key, nodes));
17348
+ }
17349
+ return { own, make, refused };
17350
+ }
17351
+ function pictureSlotOf(plan, target, key, nodes) {
17352
+ const base = { key, target, intent: target.intent, ratio: pictureAspect(target.aspect) };
17353
+ if (target.kind !== StockSlotKind.Section)
17354
+ return { ...base, family: '', text: '' };
17355
+ const at = nodes.get(target.nodeId);
17356
+ const over = at ? wordsOverPicture(at.node, at.parent) : null;
17357
+ return {
17358
+ ...base,
17359
+ family: patternById(target.pattern)?.family ?? '',
17360
+ text: at ? sectionText(plan, at.page, at.section) : '',
17361
+ ...(over ? { wordsArea: over.area, scrimNodeId: over.scrimNodeId } : {}),
17362
+ };
17363
+ }
17364
+ // ── The prompt ──────────────────────────────────────────────────────────────────────────────────────────────────────
17365
+ /** What each kind of slot asks to see (`business` = the kind of business, "a café or bakery"). */
17366
+ const INTENT_SUBJECT = {
17367
+ [StockIntent.Atmosphere]: b => `A wide, atmospheric picture that sets the mood of ${b}: depth, a sense of the place, nothing staged.`,
17368
+ [StockIntent.Place]: b => `The kind of place ${b} is: the room or the setting at eye level, quiet and inviting.`,
17369
+ [StockIntent.Detail]: b => `A close detail of the work of ${b}: its materials, its tools, a moment of making.`,
17370
+ [StockIntent.Product]: b => `One thing ${b} offers, alone on a plain ground.`,
17371
+ };
17372
+ const EDGE_WORD = { [WordsEdge.Top]: 'upper', [WordsEdge.Bottom]: 'lower' };
17373
+ /** The empty part a slot's words sit over, said to the model: where, how much, and what it must look like. */
17374
+ function emptyAreaPhrase(area) {
17375
+ const share = Math.round(area.share * 100);
17376
+ return `Leave the ${EDGE_WORD[area.edge]} ${share}% of the frame calm and uncluttered — soft focus, even tone, nothing important there — because the site's words sit over it.`;
17377
+ }
17378
+ /** The frame, said plainly. */
17379
+ function frameWords(ratio) {
17380
+ const value = PICTURE_ASPECTS[ratio] ?? 1;
17381
+ const shape = value > 1.05 ? 'landscape' : value < 0.95 ? 'portrait' : 'square';
17382
+ return `Frame: ${shape}, ${ratio}.`;
17383
+ }
17384
+ /**
17385
+ * The prompt a picture is asked with: the site's preamble VERBATIM, what the slot is about (its intent, then the
17386
+ * section's own words), its frame, the empty part the words need — and nothing else. The same slot of the same plan
17387
+ * is always asked the same words.
17388
+ */
17389
+ function picturePrompt(slot, direction, context) {
17390
+ return [
17391
+ artDirectionPreamble(direction),
17392
+ INTENT_SUBJECT[slot.intent](context.business),
17393
+ ...(slot.text ? [`It goes beside these words of the site: “${slot.text}”.`] : []),
17394
+ frameWords(slot.ratio),
17395
+ ...(slot.wordsArea ? [emptyAreaPhrase(slot.wordsArea)] : []),
17396
+ ].join('\n');
17397
+ }
17398
+ // ── The cost, shown before ──────────────────────────────────────────────────────────────────────────────────────────
17399
+ /** One picture in this many may be made a second time (a review said no) — paid for in the estimate shown. */
17400
+ const PICTURE_REROLL_EVERY = 6;
17401
+ /** How many second tries a set of `count` pictures has, shown and paid for up front. */
17402
+ function pictureRerolls(count) {
17403
+ return count > 0 ? Math.ceil(count / PICTURE_REROLL_EVERY) : 0;
17404
+ }
17405
+ /**
17406
+ * The cost shown before the owner says yes (I-D4): each picture and its review at the vetted list's prices, plus one
17407
+ * second try (a picture and its review) for every `PICTURE_REROLL_EVERY` pictures. The run never spends past `total` —
17408
+ * a second try that would cross it is not made — so the shown cost is a ceiling met exactly when every try is used.
17409
+ */
17410
+ function estimatePictures(count, prices) {
17411
+ const picture = estimateStepCredits(AiStep.MakePictures, prices.image);
17412
+ const review = estimateStepCredits(AiStep.CheckPictures, prices.review);
17413
+ const rerolls = pictureRerolls(count);
17414
+ return { count, rerolls, picture, review, total: creditsOfUsd(((count + rerolls) * (picture + review)) / 100) };
17415
+ }
17416
+ // ── The review ──────────────────────────────────────────────────────────────────────────────────────────────────────
17417
+ /** The structured answer's name (`output.name`). */
17418
+ const PICTURE_REVIEW_FORMAT = 'picture_review';
17419
+ /** The review's shape, each key in the words the model reads (every key required: no optional parameter). */
17420
+ const PICTURE_REVIEW_SHAPE = {
17421
+ subject: 'boolean — it shows what the slot asked for, in the art direction asked',
17422
+ calmArea: 'boolean — the part of the frame named for words is calm enough to read words over (true when none was named)',
17423
+ clean: 'boolean — no text, letters, numbers, logo, watermark, signage or frame anywhere',
17424
+ anatomy: 'boolean — no distorted hands, faces, bodies or objects; nothing melted or duplicated',
17425
+ alt: 'string — what the picture shows, in at most 12 plain words, in the language asked (the alt text)',
17426
+ note: 'string — why, when any answer is false; else empty',
17427
+ };
17428
+ const BOOLEAN_KEYS = ['subject', 'calmArea', 'clean', 'anatomy'];
17429
+ const TEXT_KEYS = ['alt', 'note'];
17430
+ /** The review's strict format: 6 properties, all required, closed — well inside every structured-output limit. */
17431
+ function pictureReviewJsonSchema() {
17432
+ const properties = {};
17433
+ for (const key of BOOLEAN_KEYS)
17434
+ properties[key] = { ...jsonBoolean(), description: PICTURE_REVIEW_SHAPE[key] };
17435
+ for (const key of TEXT_KEYS)
17436
+ properties[key] = { ...jsonText(), description: PICTURE_REVIEW_SHAPE[key] };
17437
+ return jsonClosedObject(properties, [...BOOLEAN_KEYS, ...TEXT_KEYS], 'A look at one picture made for a website, against what its slot asked for.');
17438
+ }
17439
+ /** What is wrong with an answer as a review — the gate the call service holds it to. */
17440
+ function pictureReviewIssues(value) {
17441
+ if (!value || typeof value !== 'object' || Array.isArray(value))
17442
+ return [{ code: 'shape', path: '', message: 'must be an object' }];
17443
+ const v = value;
17444
+ const issues = [];
17445
+ for (const key of BOOLEAN_KEYS)
17446
+ if (typeof v[key] !== 'boolean')
17447
+ issues.push({ code: 'shape', path: key, message: 'must be true or false' });
17448
+ for (const key of TEXT_KEYS)
17449
+ if (typeof v[key] !== 'string')
17450
+ issues.push({ code: 'shape', path: key, message: 'must be text' });
17451
+ for (const key of Object.keys(v))
17452
+ if (!(key in PICTURE_REVIEW_SHAPE))
17453
+ issues.push({ code: 'shape', path: key, message: 'is not a key of the review' });
17454
+ return issues;
17455
+ }
17456
+ /** A picture is used when it shows the subject, is clean and whole — and, where words sit over it, leaves them room. */
17457
+ function pictureReviewPasses(review, slot) {
17458
+ return review.subject && review.clean && review.anatomy && (!slot.wordsArea || review.calmArea);
17459
+ }
17460
+ // ── Words over the picture made ─────────────────────────────────────────────────────────────────────────────────────
17461
+ /** How strong the scrim under a picture's words is, weakest first. `Soft` is the element style's own gradient. */
17462
+ var ScrimLevel;
17463
+ (function (ScrimLevel) {
17464
+ ScrimLevel["Soft"] = "soft";
17465
+ ScrimLevel["Firm"] = "firm";
17466
+ ScrimLevel["Full"] = "full";
17467
+ })(ScrimLevel || (ScrimLevel = {}));
17468
+ const SCRIM_LEVELS = [ScrimLevel.Soft, ScrimLevel.Firm, ScrimLevel.Full];
17469
+ /** What each level writes on the scrim node (`backgroundColor`, token references only) — `null`: the style's own. */
17470
+ const SCRIM_BACKGROUND = {
17471
+ [ScrimLevel.Soft]: null,
17472
+ [ScrimLevel.Firm]: 'linear-gradient(180deg, transparent 0%, var(--iox-media-scrim) 18%)',
17473
+ [ScrimLevel.Full]: 'var(--iox-media-scrim)',
17474
+ };
17475
+ /** The least share of the scrim's own strength under the first line of words, at each level (the gradient's ramp). */
17476
+ const SCRIM_COVER = {
17477
+ [ScrimLevel.Soft]: 0.5,
17478
+ [ScrimLevel.Firm]: 0.85,
17479
+ [ScrimLevel.Full]: 1,
17480
+ };
17481
+ /** WCAG AA for body text — the words over a picture include a lead and a button label, never only a headline. */
17482
+ const PICTURE_TEXT_CONTRAST = 4.5;
17483
+ /** The share of pixels left out at each end, so a single sparkle does not decide the scrim. */
17484
+ const AREA_PERCENTILE = 0.05;
17485
+ /** The area is read a little past its edge: a Scene scales its picture in from the middle. */
17486
+ const AREA_MARGIN = 0.1;
17487
+ /** A phone shows the middle of a wide picture: read that column too. */
17488
+ const PHONE_COLUMN = [1 / 3, 2 / 3];
17489
+ const srgbToLinear = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
17490
+ const linearToSrgb = (v) => (v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055);
17491
+ const BYTE = 255;
17492
+ const RGBA = 4;
17493
+ function windowLuminance(rgba, width, height, rows, columns) {
17494
+ const values = [];
17495
+ const [y0, y1] = rows.map(r => Math.round(r * height));
17496
+ const [x0, x1] = columns.map(c => Math.round(c * width));
17497
+ for (let y = Math.max(0, y0); y < Math.min(height, y1); y++) {
17498
+ for (let x = Math.max(0, x0); x < Math.min(width, x1); x++) {
17499
+ const i = (y * width + x) * RGBA;
17500
+ values.push(0.2126 * srgbToLinear(rgba[i] / BYTE) + 0.7152 * srgbToLinear(rgba[i + 1] / BYTE) + 0.0722 * srgbToLinear(rgba[i + 2] / BYTE));
17501
+ }
17502
+ }
17503
+ if (!values.length)
17504
+ return { low: 0, high: 0, mean: 0 };
17505
+ values.sort((a, b) => a - b);
17506
+ const at = (q) => values[Math.min(values.length - 1, Math.max(0, Math.round(q * (values.length - 1))))];
17507
+ return { low: at(AREA_PERCENTILE), high: at(1 - AREA_PERCENTILE), mean: values.reduce((a, b) => a + b, 0) / values.length };
17508
+ }
17509
+ /**
17510
+ * The light of the part of a picture its words sit over (RGBA bytes, row by row) — read across the whole width (a laptop)
17511
+ * and in the middle third (a phone's crop of a wide picture); the darker low and the lighter high of the two.
17512
+ */
17513
+ function areaLuminance(rgba, width, height, area) {
17514
+ const reach = Math.min(1, area.share + AREA_MARGIN);
17515
+ const rows = area.edge === WordsEdge.Top ? [0, reach] : [1 - reach, 1];
17516
+ const laptop = windowLuminance(rgba, width, height, rows, [0, 1]);
17517
+ const phone = windowLuminance(rgba, width, height, rows, PHONE_COLUMN);
17518
+ return { low: Math.min(laptop.low, phone.low), high: Math.max(laptop.high, phone.high), mean: laptop.mean };
17519
+ }
17520
+ /** A grey of this relative luminance. */
17521
+ function greyOf(luminance) {
17522
+ const v = linearToSrgb(Math.min(1, Math.max(0, luminance)));
17523
+ return { r: v, g: v, b: v };
17524
+ }
17525
+ /**
17526
+ * The weakest scrim under which the words reach AA over the picture made: the scrim's own colour (`--iox-media-scrim`,
17527
+ * as the look writes it) at its least strength under the words, over the area's darkest and lightest, against the ink
17528
+ * (`--iox-media-scrim-ink`). `Full` when nothing weaker does — the contract already holds a full scrim over white to AA.
17529
+ */
17530
+ function chooseScrim(area, colours, minContrast = PICTURE_TEXT_CONTRAST) {
17531
+ const scrim = parseCssColour(colours.scrim);
17532
+ const ink = parseCssColour(colours.ink);
17533
+ if (!scrim || !ink)
17534
+ return { level: ScrimLevel.Full, contrast: 0 };
17535
+ const worstAt = (level) => Math.min(...[area.low, area.high].map(y => {
17536
+ const under = compositeOver({ ...scrim, a: scrim.a * SCRIM_COVER[level] }, greyOf(y));
17537
+ return wcagContrastRatio(ink, under);
17538
+ }));
17539
+ for (const level of SCRIM_LEVELS) {
17540
+ const contrast = worstAt(level);
17541
+ if (contrast >= minContrast)
17542
+ return { level, contrast };
17543
+ }
17544
+ return { level: ScrimLevel.Full, contrast: worstAt(ScrimLevel.Full) };
17545
+ }
17546
+ /** Set each chosen scrim on its node (`Soft` leaves the element style's own). Returns the nodes changed. */
17547
+ function applyPictureScrims(trees, chosen) {
17548
+ const byId = new Map(chosen.map(c => [c.nodeId, SCRIM_BACKGROUND[c.level]]));
17549
+ let changed = 0;
17550
+ walkSerialized(trees, node => {
17551
+ const background = byId.get(node.id);
17552
+ if (!background)
17553
+ return;
17554
+ node.styleProps['backgroundColor'] = background;
17555
+ changed++;
17556
+ });
17557
+ return changed;
17558
+ }
17559
+ /** The relative luminance of a CSS colour (for a review sheet or a log), or null. */
17560
+ function luminanceOf(colour) {
17561
+ const c = parseCssColour(colour);
17562
+ return c ? relativeLuminance(c) : null;
17563
+ }
17564
+
16955
17565
  /**
16956
17566
  * @vectoriox/iox-ui/generation-core — the shared, FRAMEWORK-FREE site-plan core (design-system plan
16957
17567
  * §6b, W1): the Site Plan shape + its validator, the pattern library, the page types and the three structures (Studio ·
@@ -16968,5 +17578,5 @@ function addStockCredits(footer, credits) {
16968
17578
  * Generated bundle index. Do not edit.
16969
17579
  */
16970
17580
 
16971
- export { ACTIVE_BUILD_STATUSES, AI_REFUSAL_NEXT, AI_REFUSAL_STATUS, AI_STEPS, AI_STEP_LABELS, AI_STEP_NEEDS, AI_STEP_PART_TOKENS, AI_STEP_TYPICAL_TOKENS, AiCallStatus, AiCapability, AiFeature, AiGenerationStage, AiKeyMode, AiLimit, AiNextKind, AiProviderId, AiRefusalCode, AiStep, BAR_ITEMS_MAX, BAR_ITEMS_MIN, BASELINE_ROLES, BUILD_STEPS, BaselineRole, BindScope, BuildNoticeCode, BuildStep, CONTENT_ROLE_MEANINGS, CREDITS_PER_USD, CardBindTarget, CheckSeverity, ComposeError, ComposeIssueCode, ContentFieldControl, ContentFieldType, ContentRole, ContentTypeAction, CreatedKind, DANGLING_LINK_CODES, DEFAULT_PHONE_MENU_BREAKPOINT, DEFAULT_SITE_LOCALE, ENTRANCE_EASING, ENTRANCE_ORIGIN_LOOK, ENTRANCE_TRIGGER, EntrancePart, EntranceSide, FACT_KEYS, FIELD_TYPE_OF_CONTROL, FILM_PATTERN, FINAL_GENERATION_STAGES, FINDING_SEVERITY, FilmUnplaced, GRAMMAR_TOO_LARGE_MESSAGE, GlobalSlot, HEBREW_PREFIXES, JSON_SCHEMA_BANNED_KEYWORDS, JsonSchemaShapeError, KeptReason, NAV_PART_LABELS, NAV_SHAPE_COPY, NEUTRAL_STYLE_PROPS, NODE_BASELINES, NO_TOKENS, NavPagesSource, NavRole, NavShape, NavShapeError, PAGE_ARCHETYPES, PLAN_EDITS_ANSWER_SHAPES, PLAN_EDITS_PART_SCHEMA_NAMES, PLAN_EDIT_OP_PART, PLAN_EDIT_SHAPE, PLAN_EDIT_WIRE_KEYS, PLAN_PARTS, PLAN_PART_KEYS, PLAN_WIRE_DERIVED, PLAN_WIRE_PART_SCHEMA_NAMES, PLAN_WIRE_SHAPE, PRICING_PATTERN, PUBLISH_BLOCKING_PLACEHOLDERS, PageDataKind, PathIssueCollector, PatternDirective, PatternGlobalPosition, PatternOpens, PatternPlaceholder, PatternRecipe, PhoneMenuBreakpoint, PlaceholderKind, PlanChangeAction, PlanChangeKind, PlanDataSourceMode, PlanDataSourceType, PlanEditIssueCode, PlanEditOp, PlanEditsOutcome, PlanPart, PlanWireEntry, PlanWireShapeError, PlanWordIssueCode, PreflightRefusalCode, PublishRefusalCode, REQUIRED_CONTENT_ROLES, RelativeOp, SAMPLE_IMAGE_REF, SCENE_LEVEL, SCENE_OTHER_PAGES, SHOWCASE_FILM, SITE_CHECKS, SITE_FACT_LABELS, SITE_HEADER_PATTERN, SITE_LIBRARY, SITE_LOCALES, SITE_NEED_IDS, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_ARCHETYPES, SPEC_SITE_COPY_LENGTHS, SPEC_SITE_DENSITY_COPY, SPEC_SITE_FACTS, SPEC_SITE_LIBRARY, SPEC_SITE_LOCALES, SPEC_SITE_NEED_IDS, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES, SPEC_SITE_TEMPLATES, STOCK_FAMILIES, STOCK_FAMILY_INTENTS, STOCK_INTENT_ORDER, STOCK_INTENT_SUBJECTS, STOCK_ITEM_ASPECT, STOCK_MIN_KEPT, STOCK_PROVIDER_TERMS, STOCK_SUBJECT_WORDS, STRICT_SCHEMA_BUDGET, STRICT_SCHEMA_LIMITS, STUDIO_TEMPLATE, SiteBuildKind, SiteBuildStatus, SiteCheckId, SiteFindingCode, SiteLocale, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, StepState, StockIntent, StockOrientation, StockProviderId, StockReviewFlag, StockReviewVerdict, StockSlotKind, StockSubject, addBarItem, addStockCredits, aiStepParts, applyOwnPhotoGalleries, applyPlanEdits, applyStockItemPhotos, applyStockPhotos, archetypeIssues, aspectRatioOf, assertStyleSlots, assertValidSitePlan, auditSite, barIconForRoute, blocksPublish, buildKeys, buildStepFacts, builtHomePageId, composeNavHeader, composeSection, composeSite, contentTypePreviewFields, contentTypeToWire, copyLengthOf, costUsd, createSerializedNode, creditsOfUsd, dataSourceToWire, derivedDataSources, describePlanData, describeShapeKey, entranceAction, entranceInteraction, estimateStepCredits, expandPlanWire, expandWireContentType, expandWireDataSource, expandWireEdit, expandWireField, expandWirePage, expandWireSection, failedBuildStep, fieldToWire, filmOfPlan, filmPlacement, findNavRoot, isActiveBuild, isNonEmptyString, isPlainObject, isRtlLocale, jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, jsonSchemaKeywords, jsonSchemaObjects, jsonShapedObject, jsonText, modelCanDo, moveBarItem, navPages, navShapeBreakpoint, navShapeOf, nextSourceOf, nodeBindPaths, orientationOfRatio, pageToWire, parsePatternPath, parseSampleImageRef, pathAt, patternById, patternHasForm, patternLinkSlots, patternListGap, patternManifest, patternNeedsBinding, patternNodes, patternOptionalParts, patternRoleNeeds, patternScopes, patternSlotBudgets, patternSlots, patternTakesBinding, phoneMenuPages, placeFilm, planEditOps, planEditToWire, planEditWireGaps, planEditWireKeys, planEditWireOps, planEditWireShape, planEditsFromWire, planEditsJsonSchema, planEditsToWire, planEntrances, planFromTemplate, planNavShape, planOwnPhotos, planPartOfPath, planPartToWire, planStockPhotos, planToWire, planWireGaps, planWirePartJsonSchema, planWirePartShape, planWordIssues, publishRefusal, publishVerdict, recipeListBinds, releasedLayoutDefaults, removeBarItem, requiredAcknowledgements, requiredShapeKeys, roleField, routeMatches, routeParams, sampleImageRef, scoreStockPhoto, sectionEntrances, sectionPatternIds, sectionToWire, setStyleProps, shapeKeys, showcaseGlobals, showcasePages, showcasePlans, showcaseScenes, siteNavPages, slotBudget, stockCopyKey, stockCreditLine, stockCreditLines, stockCreditNode, stockCreditNodeId, stockFits, stockGroupOf, stockItemSlots, stockKept, stockPhotoRatio, stockPhotoSubject, stockPhotoUsable, stockPhotoWords, stockSlots, stockWord, stockWords, strictSchemaComplexity, strictSchemaRefusal, typicalStepTokens, validatePlanEdits, validateSitePlan, walkSerialized, wireContentTypeJsonSchema, wireDataSourceJsonSchema, wireFieldJsonSchema, wirePageJsonSchema, wireSectionJsonSchema, withDerivedSources, wordPattern, writtenTexts };
17581
+ export { ACTIVE_BUILD_STATUSES, AI_REFUSAL_NEXT, AI_REFUSAL_STATUS, AI_STEPS, AI_STEP_LABELS, AI_STEP_NEEDS, AI_STEP_PART_TOKENS, AI_STEP_TYPICAL_TOKENS, ART_DIRECTION_PARTS, AiCallStatus, AiCapability, AiFeature, AiGenerationStage, AiKeyMode, AiLimit, AiNextKind, AiProviderId, AiRefusalCode, AiStep, BAR_ITEMS_MAX, BAR_ITEMS_MIN, BASELINE_ROLES, BUILD_STEPS, BaselineRole, BindScope, BuildNoticeCode, BuildStep, CONTENT_ROLE_MEANINGS, CREDITS_PER_USD, CardBindTarget, CheckSeverity, ComposeError, ComposeIssueCode, ContentFieldControl, ContentFieldType, ContentRole, ContentTypeAction, CreatedKind, DANGLING_LINK_CODES, DEFAULT_PHONE_MENU_BREAKPOINT, DEFAULT_SITE_LOCALE, ENTRANCE_EASING, ENTRANCE_ORIGIN_LOOK, ENTRANCE_TRIGGER, EntrancePart, EntranceSide, FACT_KEYS, FIELD_TYPE_OF_CONTROL, FILL_ASPECT, FILM_PATTERN, FINAL_GENERATION_STAGES, FINDING_SEVERITY, FilmUnplaced, GRAMMAR_TOO_LARGE_MESSAGE, GlobalSlot, HEBREW_PREFIXES, JSON_SCHEMA_BANNED_KEYWORDS, JsonSchemaShapeError, KeptReason, MAX_SITE_PICTURES, NAV_PART_LABELS, NAV_SHAPE_COPY, NEUTRAL_STYLE_PROPS, NODE_BASELINES, NO_TOKENS, NavPagesSource, NavRole, NavShape, NavShapeError, PAGE_ARCHETYPES, PICTURE_ASPECTS, PICTURE_NEVER, PICTURE_REAL_PLACE_FAMILIES, PICTURE_REROLL_EVERY, PICTURE_REVIEW_FORMAT, PICTURE_REVIEW_SHAPE, PICTURE_TEXT_CONTRAST, PICTURE_WORLDS, PICTURE_WORLD_IDS, PLAN_EDITS_ANSWER_SHAPES, PLAN_EDITS_PART_SCHEMA_NAMES, PLAN_EDIT_OP_PART, PLAN_EDIT_SHAPE, PLAN_EDIT_WIRE_KEYS, PLAN_PARTS, PLAN_PART_KEYS, PLAN_WIRE_DERIVED, PLAN_WIRE_PART_SCHEMA_NAMES, PLAN_WIRE_SHAPE, PRICING_PATTERN, PUBLISH_BLOCKING_PLACEHOLDERS, PageDataKind, PathIssueCollector, PatternDirective, PatternGlobalPosition, PatternOpens, PatternPlaceholder, PatternRecipe, PhoneMenuBreakpoint, PictureRefusal, PictureWorld, PlaceholderKind, PlanChangeAction, PlanChangeKind, PlanDataSourceMode, PlanDataSourceType, PlanEditIssueCode, PlanEditOp, PlanEditsOutcome, PlanPart, PlanWireEntry, PlanWireShapeError, PlanWordIssueCode, PreflightRefusalCode, PublishRefusalCode, REQUIRED_CONTENT_ROLES, RelativeOp, SAMPLE_IMAGE_REF, SCENE_LEVEL, SCENE_OTHER_PAGES, SCRIM_BACKGROUND, SCRIM_COVER, SCRIM_LEVELS, SHOWCASE_FILM, SITE_CHECKS, SITE_FACT_LABELS, SITE_HEADER_PATTERN, SITE_LIBRARY, SITE_LOCALES, SITE_NEED_IDS, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_ARCHETYPES, SPEC_SITE_COPY_LENGTHS, SPEC_SITE_DENSITY_COPY, SPEC_SITE_FACTS, SPEC_SITE_LIBRARY, SPEC_SITE_LOCALES, SPEC_SITE_NEED_IDS, SPEC_SITE_PATTERNS, SPEC_SITE_PICTURE_NEVER, SPEC_SITE_PICTURE_WORLDS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES, SPEC_SITE_TEMPLATES, STOCK_FAMILIES, STOCK_FAMILY_INTENTS, STOCK_INTENT_ORDER, STOCK_INTENT_SUBJECTS, STOCK_ITEM_ASPECT, STOCK_MIN_KEPT, STOCK_PROVIDER_TERMS, STOCK_SEED_MARGIN, STOCK_SUBJECT_WORDS, STRICT_SCHEMA_BUDGET, STRICT_SCHEMA_LIMITS, STUDIO_TEMPLATE, ScrimLevel, SiteBuildKind, SiteBuildStatus, SiteCheckId, SiteFindingCode, SiteLocale, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, StepState, StockIntent, StockOrientation, StockProviderId, StockReviewFlag, StockReviewVerdict, StockSlotKind, StockSubject, WORDS_AREA_DEFAULT_SHARE, WordsEdge, addBarItem, addStockCredits, aiStepParts, applyOwnPhotoGalleries, applyPictureScrims, applyPlanEdits, applyStockItemPhotos, applyStockPhotos, archetypeIssues, areaLuminance, artDirectionFor, artDirectionIssues, artDirectionPreamble, aspectRatioOf, assertStyleSlots, assertValidSitePlan, auditSite, barIconForRoute, blocksPublish, brandHueWord, buildKeys, buildStepFacts, builtHomePageId, chooseScrim, composeNavHeader, composeSection, composeSite, contentTypePreviewFields, contentTypeToWire, copyLengthOf, costUsd, createSerializedNode, creditsOfUsd, dataSourceToWire, derivedDataSources, describePlanData, describeShapeKey, emptyAreaPhrase, entranceAction, entranceInteraction, estimatePictures, estimateStepCredits, expandPlanWire, expandWireContentType, expandWireDataSource, expandWireEdit, expandWireField, expandWirePage, expandWireSection, failedBuildStep, fieldToWire, filmOfPlan, filmPlacement, findNavRoot, frameWords, isActiveBuild, isNonEmptyString, isPictureWorld, isPlainObject, isRtlLocale, jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, jsonSchemaKeywords, jsonSchemaObjects, jsonShapedObject, jsonText, luminanceOf, modelCanDo, moveBarItem, navPages, navShapeBreakpoint, navShapeOf, nextSourceOf, nodeBindPaths, orientationOfRatio, pageToWire, parsePatternPath, parseSampleImageRef, pathAt, patternById, patternHasForm, patternLinkSlots, patternListGap, patternManifest, patternNeedsBinding, patternNodes, patternOptionalParts, patternRoleNeeds, patternScopes, patternSlotBudgets, patternSlots, patternTakesBinding, phoneMenuPages, pictureAspect, picturePrompt, pictureRefusalOf, pictureRerolls, pictureReviewIssues, pictureReviewJsonSchema, pictureReviewPasses, pictureSlotKey, pictureTargets, placeFilm, planEditOps, planEditToWire, planEditWireGaps, planEditWireKeys, planEditWireOps, planEditWireShape, planEditsFromWire, planEditsJsonSchema, planEditsToWire, planEntrances, planFromTemplate, planNavShape, planOwnPhotos, planPartOfPath, planPartToWire, planPictures, planStockPhotos, planToWire, planWireGaps, planWirePartJsonSchema, planWirePartShape, planWordIssues, publishRefusal, publishVerdict, recipeListBinds, releasedLayoutDefaults, removeBarItem, requiredAcknowledgements, requiredShapeKeys, roleField, routeMatches, routeParams, sampleImageRef, scoreStockPhoto, sectionEntrances, sectionPatternIds, sectionToWire, setStyleProps, shapeKeys, showcaseGlobals, showcasePages, showcasePlans, showcaseScenes, siteNavPages, slotBudget, stockCopyKey, stockCreditLine, stockCreditLines, stockCreditNode, stockCreditNodeId, stockFits, stockGroupOf, stockItemSlots, stockKept, stockPhotoRatio, stockPhotoSubject, stockPhotoUsable, stockPhotoWords, stockSlots, stockWord, stockWords, strictSchemaComplexity, strictSchemaRefusal, typicalStepTokens, validatePlanEdits, validateSitePlan, walkSerialized, wireContentTypeJsonSchema, wireDataSourceJsonSchema, wireFieldJsonSchema, wirePageJsonSchema, wireSectionJsonSchema, withDerivedSources, wordPattern, wordsOverPicture, writtenTexts };
16972
17582
  //# sourceMappingURL=vectoriox-iox-ui-generation-core.mjs.map