@vectoriox/iox-ui 5.83.1 → 5.85.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.
Files changed (53) hide show
  1. package/actions-core/index.d.ts +18 -2
  2. package/composite-core/index.d.ts +93 -15
  3. package/design-core/index.d.ts +566 -4
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +18 -1
  5. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +307 -39
  7. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs +680 -15
  9. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +665 -24
  11. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  12. package/fesm2022/vectoriox-iox-ui-harness-core.mjs +39 -1
  13. package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
  14. package/fesm2022/vectoriox-iox-ui-scene-core.mjs +101 -5
  15. package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
  16. package/fesm2022/vectoriox-iox-ui.mjs +285 -12
  17. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  18. package/generation-core/index.d.ts +414 -8
  19. package/harness-core/index.d.ts +81 -3
  20. package/index.d.ts +143 -42
  21. package/package.json +1 -1
  22. package/scene-core/index.d.ts +32 -4
  23. package/src/actions-core/dialog-motion.ts +25 -0
  24. package/src/composite-core/header.ts +25 -0
  25. package/src/composite-core/public-api.ts +1 -0
  26. package/src/composite-core/slider-runtime.ts +82 -0
  27. package/src/composite-core/slider.ts +194 -27
  28. package/src/composite-core/structure.ts +14 -9
  29. package/src/design-core/flavor-spec.generated.ts +596 -9
  30. package/src/design-core/header-behaviour.ts +76 -0
  31. package/src/design-core/public-api.ts +1 -0
  32. package/src/design-core/theme-color.ts +21 -5
  33. package/src/generation-core/ai-run.ts +10 -2
  34. package/src/generation-core/art-direction.ts +122 -0
  35. package/src/generation-core/audit-site.ts +14 -0
  36. package/src/generation-core/compose-site.ts +8 -0
  37. package/src/generation-core/node-baselines.generated.ts +17 -3
  38. package/src/generation-core/patterns.ts +2 -0
  39. package/src/generation-core/pictures.ts +464 -0
  40. package/src/generation-core/plan-wire.ts +2 -0
  41. package/src/generation-core/public-api.ts +2 -0
  42. package/src/generation-core/site-build-run.ts +3 -0
  43. package/src/generation-core/site-plan.model.ts +4 -0
  44. package/src/generation-core/site-spec.generated.ts +126 -1
  45. package/src/generation-core/stock-photos.ts +40 -10
  46. package/src/generation-core/validate-site-plan.ts +14 -0
  47. package/src/harness-core/business-types.ts +3 -1
  48. package/src/harness-core/generation-view.ts +50 -1
  49. package/src/harness-core/harness-spec.generated.ts +30 -0
  50. package/src/scene-core/scene.ts +18 -0
  51. package/src/scene-core/scroll-driver.ts +83 -6
  52. package/styles/iox-design.css +232 -1
  53. package/styles/iox-forms.css +5 -2
@@ -231,6 +231,40 @@ export type StockPlacement<T extends StockTarget = StockTarget> = T & { photo: S
231
231
  export interface StockPlanOptions {
232
232
  /** The business type's imagery line (harness-core `BusinessType.imagery`) — its words count for every slot. */
233
233
  imagery?: string;
234
+ /**
235
+ * M7 (premium sites PS4): the SITE the photos are chosen for (its organization). Among the photos that answer a slot
236
+ * nearly as well as the best (`STOCK_SEED_MARGIN`), the seed decides — so two sites of the same kind of business stop
237
+ * opening on the same hero, and the same site always gets the same photos. No seed: the library's order decides.
238
+ */
239
+ seed?: string;
240
+ /** M7: library keys other sites of this studio already show — chosen only when no photo they do not show fits. */
241
+ avoid?: ReadonlyArray<string>;
242
+ }
243
+
244
+ /** How far below the best score a photo may be and still be the seed's choice (one word of the plan's, about). */
245
+ export const STOCK_SEED_MARGIN = 2;
246
+
247
+ /** A stable 32-bit hash (FNV-1a) — the seed's order of the library, the same in every runtime. */
248
+ function seededRank(seed: string, key: string): number {
249
+ let hash = 0x811c9dc5;
250
+ for (const ch of `${seed}:${key}`) {
251
+ hash ^= ch.codePointAt(0)!;
252
+ hash = Math.imul(hash, 0x01000193) >>> 0;
253
+ }
254
+ return hash;
255
+ }
256
+
257
+ /** The best of `pool` for `slot`: the highest score (ties: the library's order), or — with a seed — the seed's pick among
258
+ * those within `STOCK_SEED_MARGIN` of it. */
259
+ function bestFor(pool: ReadonlyArray<StockPhoto>, slot: StockNeed, imagery: ReadonlyArray<string>, seed: string | undefined): StockPhoto | undefined {
260
+ const scored = pool.map(photo => ({ photo, score: scoreStockPhoto(photo, slot, imagery) }));
261
+ const top = scored.reduce((m, s) => Math.max(m, s.score), -Infinity);
262
+ if (!seed) return scored.find(s => s.score === top)?.photo;
263
+ const near = scored.filter(s => s.score >= top - STOCK_SEED_MARGIN);
264
+ return near.reduce<{ photo: StockPhoto; rank: number } | undefined>((best, s) => {
265
+ const rank = seededRank(seed, s.photo.key);
266
+ return !best || rank < best.rank ? { photo: s.photo, rank } : best;
267
+ }, undefined)?.photo;
234
268
  }
235
269
 
236
270
  const intentRank = (intent: string) => STOCK_INTENT_ORDER.indexOf(intent as StockIntent);
@@ -239,15 +273,17 @@ const intentRank = (intent: string) => STOCK_INTENT_ORDER.indexOf(intent as Stoc
239
273
  * Which photo goes where (I2.1). Slots choose in intent order (the hero first, list items last; page order within),
240
274
  * each taking the best-scoring photo that FITS its shape (`stockFits`) and is not already on its page (or among its
241
275
  * type's samples) — a photo no slot has taken yet always before one another page shows. Ties go to the library's
242
- * order. A slot nothing fits keeps its empty box (a sample its abstract picture). None at all when the type says no
243
- * stock, and never a photo the review rejected or flagged. Returned in the slots' order.
276
+ * order — or, with a `seed` (M7), the site's own pick among those nearly as good, and another site's photos (`avoid`) only
277
+ * when nothing fresh fits. A slot nothing fits keeps its empty box (a sample its abstract picture). None at all when the
278
+ * type says no stock, and never a photo the review rejected or flagged. Returned in the slots' order.
244
279
  */
245
280
  export function planStockPhotos<T extends StockTarget>(slots: ReadonlyArray<T>, photos: ReadonlyArray<StockPhoto>, stockAllowed: boolean, opts: StockPlanOptions = {}): Array<StockPlacement<T>> {
246
281
  if (!stockAllowed) return [];
247
282
  const usable = photos.filter(stockPhotoUsable);
248
283
  const imagery = stockWords(opts.imagery);
249
284
  const order = slots.map((slot, i) => ({ slot, i })).sort((a, b) => intentRank(a.slot.intent) - intentRank(b.slot.intent) || a.i - b.i);
250
- const used = new Set<string>();
285
+ // Another site's photos count as already shown: a fresh one that fits always comes first.
286
+ const used = new Set<string>(opts.avoid ?? []);
251
287
  const shownIn = new Map<string, Set<string>>();
252
288
  const placed = new Map<number, StockPlacement<T>>();
253
289
  for (const { slot, i } of order) {
@@ -255,13 +291,7 @@ export function planStockPhotos<T extends StockTarget>(slots: ReadonlyArray<T>,
255
291
  const here = shownIn.get(group) ?? new Set<string>();
256
292
  const candidates = usable.filter(p => !here.has(p.key) && stockFits(p, slot));
257
293
  const fresh = candidates.filter(p => !used.has(p.key));
258
- const pool = fresh.length ? fresh : candidates;
259
- let best: StockPhoto | undefined;
260
- let bestScore = -Infinity;
261
- for (const photo of pool) {
262
- const score = scoreStockPhoto(photo, slot, imagery);
263
- if (score > bestScore) { best = photo; bestScore = score; }
264
- }
294
+ const best = bestFor(fresh.length ? fresh : candidates, slot, imagery, opts.seed);
265
295
  if (!best) continue;
266
296
  used.add(best.key);
267
297
  here.add(best.key);
@@ -10,6 +10,7 @@ import { ContentFieldControl, FIELD_TYPE_OF_CONTROL,
10
10
  FACT_KEYS, GlobalSlot, PAGE_ARCHETYPES, SITE_NEED_IDS, PlanDataSourceMode, PlanDataSourceType, RelativeOp, REQUIRED_CONTENT_ROLES, SitePlan, SitePlanSourceKind,
11
11
  SITE_LOCALES, SITE_PLAN_VERSION,
12
12
  } from './site-plan.model';
13
+ import { artDirectionIssues } from './art-direction';
13
14
  import { patternListGap } from './recipe-fields';
14
15
  import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
15
16
  import {
@@ -76,6 +77,8 @@ export enum SitePlanIssueCode {
76
77
  /** SC6b: the owner's film — pictures that cannot play, too few to play smoothly, or a film's pattern on a plan with
77
78
  * no film (or twice). Set by the build, never by a model. */
78
79
  Film = 'film',
80
+ /** M7: the art direction is not a picture world with its five parts. Set by the generation, never by a model. */
81
+ ArtDirection = 'art-direction',
79
82
  }
80
83
 
81
84
  export type SitePlanIssue = PathIssue<SitePlanIssueCode>;
@@ -115,6 +118,7 @@ const CMS_SOURCE_KEYS = shapeKeys(SHAPE['cmsDataSource']);
115
118
  const RELATIVE_SOURCE_KEYS = shapeKeys(SHAPE['relativeDataSource']);
116
119
  const PAGE_KEYS = shapeKeys(SHAPE['page']);
117
120
  const FILM_KEYS = shapeKeys(SHAPE['film']);
121
+ const ART_DIRECTION_KEYS = shapeKeys(SHAPE['artDirection']);
118
122
  const SECTION_KEYS = shapeKeys(SHAPE['section']);
119
123
  const GLOBAL_POSITION: Readonly<Record<`${GlobalSlot}`, `${PatternGlobalPosition}`>> = {
120
124
  [GlobalSlot.Header]: PatternGlobalPosition.Before,
@@ -508,6 +512,15 @@ function validateFilm(c: Collector, film: unknown, pages: unknown[]): void {
508
512
  });
509
513
  }
510
514
 
515
+ /** M7: what every picture made for the site shares — a known world and its five parts (`artDirectionIssues`). */
516
+ function validateArtDirection(c: Collector, direction: unknown): void {
517
+ if (direction === undefined) return;
518
+ if (isObject(direction)) c.keys(direction, ART_DIRECTION_KEYS, 'artDirection');
519
+ for (const issue of artDirectionIssues(direction)) {
520
+ c.add(issue.path ? SitePlanIssueCode.ArtDirection : SitePlanIssueCode.Shape, issue.path ? `artDirection.${issue.path}` : 'artDirection', issue.message);
521
+ }
522
+ }
523
+
511
524
  function validatePageSeo(c: Collector, seo: unknown, path: string): void {
512
525
  if (seo === undefined) return;
513
526
  if (!isObject(seo)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
@@ -556,6 +569,7 @@ export function validateSitePlan(plan: unknown): SitePlanValidation {
556
569
  validateGlobals(c, plan['globals']);
557
570
  validateScenes(c, c.array(plan, 'pages', ''), typeof design['animate'] === 'string' ? design['animate'] : null);
558
571
  validateFilm(c, plan['film'], c.array(plan, 'pages', ''));
572
+ validateArtDirection(c, plan['artDirection']);
559
573
  return { valid: c.issues.length === 0, issues: c.issues };
560
574
  }
561
575
 
@@ -1,5 +1,5 @@
1
1
  import type { DensityId, DesignMotion, MoodId, MotionId, TintId } from '@vectoriox/iox-ui/design-core';
2
- import type { FactKey } from '@vectoriox/iox-ui/generation-core';
2
+ import type { FactKey, PictureWorld } from '@vectoriox/iox-ui/generation-core';
3
3
 
4
4
  import {
5
5
  SPEC_BUSINESS_TYPES, SPEC_HARNESS_SOURCE, SPEC_NEEDS, SPEC_STRUCTURES, SPEC_USER_AVOIDS,
@@ -115,6 +115,8 @@ export interface BusinessType {
115
115
  * so a type capped below it never gets one. Binds generated output only — the owner may choose any level. */
116
116
  readonly ceiling: DesignMotion['animate'];
117
117
  readonly imagery: string;
118
+ /** M7: the picture world a site's made pictures start in (generation-core `artDirectionFor`; contract site.pictureWorlds). */
119
+ readonly world: `${PictureWorld}`;
118
120
  readonly pages: ReadonlyArray<string>;
119
121
  /** The needs its page set implies, in page order (derived from `pages` at contract build). */
120
122
  readonly needs: ReadonlyArray<NeedId>;
@@ -1,6 +1,6 @@
1
1
  import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
2
  import type { FrameSequence } from '@vectoriox/iox-ui/media-core';
3
- import type { AiGenerationStage, AiStep, PlanChange, PlanEdit, SitePlan } from '@vectoriox/iox-ui/generation-core';
3
+ import type { AiGenerationStage, AiRefusalCode, AiStep, PictureRefusal, PlanChange, PlanEdit, SitePlan } from '@vectoriox/iox-ui/generation-core';
4
4
 
5
5
  import type { StyleRead } from './rank';
6
6
  import type { SiteAnalysis } from './site-analysis';
@@ -73,6 +73,44 @@ export interface PlanRevisionView {
73
73
  changed?: PlanChange[];
74
74
  }
75
75
 
76
+ /** Why no picture can be made for this site right now (the Plan step says it instead of offering the switch). */
77
+ export enum PicturesUnavailable {
78
+ /** No slot is left after the owner's photos and the refusals. */
79
+ Nothing = 'nothing',
80
+ /** The key in use has no model that makes pictures (a Claude key does not). */
81
+ NoModel = 'no-model',
82
+ }
83
+
84
+ /**
85
+ * M7: the pictures a generation may have MADE, as the Plan step shows them before the owner says yes, and while they are
86
+ * made. `estimate` is the cost shown — every picture and its review plus the second tries (generation-core
87
+ * `estimatePictures`) — and the most the run may spend on them.
88
+ */
89
+ export interface GenerationPicturesView {
90
+ /** Pictures that would be made (after the owner's photos and the refusals). */
91
+ count: number;
92
+ /** The owner's photos that go first. */
93
+ own: number;
94
+ /** Slots no picture is made for, by why. */
95
+ refused: Partial<Record<`${PictureRefusal}`, number>>;
96
+ /** Credits — the cost shown and the run's ceiling. */
97
+ estimate: number;
98
+ /** One picture with its review, in credits. */
99
+ each: number;
100
+ rerolls: number;
101
+ /** The model that makes them, or null. */
102
+ model: string | null;
103
+ /** Why none can be made now — instead of the switch. A refusal code when credits or the plan say no. */
104
+ unavailable?: `${PicturesUnavailable}` | `${AiRefusalCode}`;
105
+ /** The owner said yes (Build with pictures): the run's progress. */
106
+ wanted?: boolean;
107
+ made?: number;
108
+ /** Slots whose review said no to every try — stock or the empty box stands in. */
109
+ failed?: number;
110
+ /** Credits the pictures have cost so far (the ledger). */
111
+ spent?: number;
112
+ }
113
+
76
114
  export interface AiGenerationView {
77
115
  _id: string;
78
116
  organization: string;
@@ -101,6 +139,17 @@ export interface AiGenerationView {
101
139
  /** Derived from the ledger — the running credit total. */
102
140
  spent: number;
103
141
  error?: { code: string; detail: string; providerMessage?: string; issues?: Array<{ code: string; path: string }> };
142
+ /** M7: the pictures that may be made (on the Plan step) or are being made. Absent: no plan yet. */
143
+ pictures?: GenerationPicturesView;
144
+ }
145
+
146
+ /** `POST …/ai/generations/:id/build` (and `/auto`). */
147
+ export interface GenerationBuildRequest {
148
+ replaceGlobals?: boolean;
149
+ /** SC6b: the owner's film as pictures. */
150
+ film?: FrameSequence;
151
+ /** M7: make pictures for the site first (the cost shown on the Plan step). */
152
+ pictures?: boolean;
104
153
  }
105
154
 
106
155
  /** `POST …/ai/generations`. */
@@ -207,6 +207,7 @@ export const SPEC_BUSINESS_TYPES = [
207
207
  "between": "cinematic"
208
208
  },
209
209
  "ceiling": "expressive",
210
+ "world": "documentary",
210
211
  "imagery": "Your own photos only, large and uncropped; never stock.",
211
212
  "pages": [
212
213
  "home",
@@ -312,6 +313,7 @@ export const SPEC_BUSINESS_TYPES = [
312
313
  "between": "cinematic"
313
314
  },
314
315
  "ceiling": "expressive",
316
+ "world": "architectural",
315
317
  "imagery": "Wide, bright room shots; details of materials.",
316
318
  "pages": [
317
319
  "home",
@@ -422,6 +424,7 @@ export const SPEC_BUSINESS_TYPES = [
422
424
  "between": "fade"
423
425
  },
424
426
  "ceiling": "expressive",
427
+ "world": "architectural",
425
428
  "imagery": "Large project photos and drawings.",
426
429
  "pages": [
427
430
  "home",
@@ -531,6 +534,7 @@ export const SPEC_BUSINESS_TYPES = [
531
534
  "between": "slide"
532
535
  },
533
536
  "ceiling": "expressive",
537
+ "world": "hard-light",
534
538
  "imagery": "Case-study images of your own work.",
535
539
  "pages": [
536
540
  "home",
@@ -630,6 +634,7 @@ export const SPEC_BUSINESS_TYPES = [
630
634
  "between": "fade"
631
635
  },
632
636
  "ceiling": "expressive",
637
+ "world": "drawing",
633
638
  "imagery": "Your pieces, photographed flat and true to colour.",
634
639
  "pages": [
635
640
  "home",
@@ -735,6 +740,7 @@ export const SPEC_BUSINESS_TYPES = [
735
740
  "between": "slide"
736
741
  },
737
742
  "ceiling": "expressive",
743
+ "world": "macro",
738
744
  "imagery": "Your pieces and hands at work; natural light.",
739
745
  "pages": [
740
746
  "home",
@@ -833,6 +839,7 @@ export const SPEC_BUSINESS_TYPES = [
833
839
  "between": "cinematic"
834
840
  },
835
841
  "ceiling": "expressive",
842
+ "world": "macro",
836
843
  "imagery": "Close-ups on a plain ground.",
837
844
  "pages": [
838
845
  "home",
@@ -935,6 +942,7 @@ export const SPEC_BUSINESS_TYPES = [
935
942
  "between": "slide"
936
943
  },
937
944
  "ceiling": "expressive",
945
+ "world": "low-key",
938
946
  "imagery": "Healed work, clean photos.",
939
947
  "pages": [
940
948
  "home",
@@ -1031,6 +1039,7 @@ export const SPEC_BUSINESS_TYPES = [
1031
1039
  "between": "fade"
1032
1040
  },
1033
1041
  "ceiling": "standard",
1042
+ "world": "high-key",
1034
1043
  "imagery": "Rooms, textures, hands; no faces needed.",
1035
1044
  "pages": [
1036
1045
  "home",
@@ -1150,6 +1159,7 @@ export const SPEC_BUSINESS_TYPES = [
1150
1159
  "between": "fade"
1151
1160
  },
1152
1161
  "ceiling": "lively",
1162
+ "world": "high-key",
1153
1163
  "imagery": "Your own before-and-after photos.",
1154
1164
  "pages": [
1155
1165
  "home",
@@ -1252,6 +1262,7 @@ export const SPEC_BUSINESS_TYPES = [
1252
1262
  "between": "fade"
1253
1263
  },
1254
1264
  "ceiling": "lively",
1265
+ "world": "high-key",
1255
1266
  "imagery": "The room, real classes, daylight.",
1256
1267
  "pages": [
1257
1268
  "home",
@@ -1362,6 +1373,7 @@ export const SPEC_BUSINESS_TYPES = [
1362
1373
  "between": "slide"
1363
1374
  },
1364
1375
  "ceiling": "lively",
1376
+ "world": "hard-light",
1365
1377
  "imagery": "Real people training; movement.",
1366
1378
  "pages": [
1367
1379
  "home",
@@ -1464,6 +1476,7 @@ export const SPEC_BUSINESS_TYPES = [
1464
1476
  "between": "none"
1465
1477
  },
1466
1478
  "ceiling": "subtle",
1479
+ "world": "documentary",
1467
1480
  "imagery": "A calm room, daylight; your portrait.",
1468
1481
  "pages": [
1469
1482
  "home",
@@ -1574,6 +1587,7 @@ export const SPEC_BUSINESS_TYPES = [
1574
1587
  "between": "fade"
1575
1588
  },
1576
1589
  "ceiling": "subtle",
1590
+ "world": "high-key",
1577
1591
  "imagery": "Your team and rooms; no stock doctors.",
1578
1592
  "pages": [
1579
1593
  "home",
@@ -1679,6 +1693,7 @@ export const SPEC_BUSINESS_TYPES = [
1679
1693
  "between": "fade"
1680
1694
  },
1681
1695
  "ceiling": "subtle",
1696
+ "world": "high-key",
1682
1697
  "imagery": "Your clinic and team.",
1683
1698
  "pages": [
1684
1699
  "home",
@@ -1785,6 +1800,7 @@ export const SPEC_BUSINESS_TYPES = [
1785
1800
  "between": "fade"
1786
1801
  },
1787
1802
  "ceiling": "standard",
1803
+ "world": "documentary",
1788
1804
  "imagery": "Real animals you treat (with permission).",
1789
1805
  "pages": [
1790
1806
  "home",
@@ -1883,6 +1899,7 @@ export const SPEC_BUSINESS_TYPES = [
1883
1899
  "between": "fade"
1884
1900
  },
1885
1901
  "ceiling": "expressive",
1902
+ "world": "low-key",
1886
1903
  "imagery": "Your own food and room photos.",
1887
1904
  "pages": [
1888
1905
  "home",
@@ -1992,6 +2009,7 @@ export const SPEC_BUSINESS_TYPES = [
1992
2009
  "between": "slide"
1993
2010
  },
1994
2011
  "ceiling": "lively",
2012
+ "world": "documentary",
1995
2013
  "imagery": "Your bakes and drinks, close up.",
1996
2014
  "pages": [
1997
2015
  "home",
@@ -2095,6 +2113,7 @@ export const SPEC_BUSINESS_TYPES = [
2095
2113
  "between": "cinematic"
2096
2114
  },
2097
2115
  "ceiling": "expressive",
2116
+ "world": "nocturne",
2098
2117
  "imagery": "The cellar, the bar, the bottle.",
2099
2118
  "pages": [
2100
2119
  "home",
@@ -2198,6 +2217,7 @@ export const SPEC_BUSINESS_TYPES = [
2198
2217
  "between": "slide"
2199
2218
  },
2200
2219
  "ceiling": "expressive",
2220
+ "world": "high-key",
2201
2221
  "imagery": "Product photos on plain grounds; real people wearing them.",
2202
2222
  "pages": [
2203
2223
  "home",
@@ -2293,6 +2313,7 @@ export const SPEC_BUSINESS_TYPES = [
2293
2313
  "between": "fade"
2294
2314
  },
2295
2315
  "ceiling": "lively",
2316
+ "world": "macro",
2296
2317
  "imagery": "Your own arrangements in daylight.",
2297
2318
  "pages": [
2298
2319
  "home",
@@ -2389,6 +2410,7 @@ export const SPEC_BUSINESS_TYPES = [
2389
2410
  "between": "fade"
2390
2411
  },
2391
2412
  "ceiling": "expressive",
2413
+ "world": "documentary",
2392
2414
  "imagery": "Pieces in real rooms and on plain grounds.",
2393
2415
  "pages": [
2394
2416
  "home",
@@ -2486,6 +2508,7 @@ export const SPEC_BUSINESS_TYPES = [
2486
2508
  "between": "fade"
2487
2509
  },
2488
2510
  "ceiling": "standard",
2511
+ "world": "architectural",
2489
2512
  "imagery": "Your portrait; diagrams over stock.",
2490
2513
  "pages": [
2491
2514
  "home",
@@ -2587,6 +2610,7 @@ export const SPEC_BUSINESS_TYPES = [
2587
2610
  "between": "fade"
2588
2611
  },
2589
2612
  "ceiling": "standard",
2613
+ "world": "documentary",
2590
2614
  "imagery": "Your portrait, working with people.",
2591
2615
  "pages": [
2592
2616
  "home",
@@ -2683,6 +2707,7 @@ export const SPEC_BUSINESS_TYPES = [
2683
2707
  "between": "none"
2684
2708
  },
2685
2709
  "ceiling": "subtle",
2710
+ "world": "architectural",
2686
2711
  "imagery": "Your team; your office.",
2687
2712
  "pages": [
2688
2713
  "home",
@@ -2793,6 +2818,7 @@ export const SPEC_BUSINESS_TYPES = [
2793
2818
  "between": "slide"
2794
2819
  },
2795
2820
  "ceiling": "lively",
2821
+ "world": "architectural",
2796
2822
  "imagery": "Your own property photos; never stock.",
2797
2823
  "pages": [
2798
2824
  "home",
@@ -2898,6 +2924,7 @@ export const SPEC_BUSINESS_TYPES = [
2898
2924
  "between": "fade"
2899
2925
  },
2900
2926
  "ceiling": "standard",
2927
+ "world": "documentary",
2901
2928
  "imagery": "Real people you help (with permission).",
2902
2929
  "pages": [
2903
2930
  "home",
@@ -2991,6 +3018,7 @@ export const SPEC_BUSINESS_TYPES = [
2991
3018
  "between": "cinematic"
2992
3019
  },
2993
3020
  "ceiling": "expressive",
3021
+ "world": "nocturne",
2994
3022
  "imagery": "Real events at your venue.",
2995
3023
  "pages": [
2996
3024
  "home",
@@ -3093,6 +3121,7 @@ export const SPEC_BUSINESS_TYPES = [
3093
3121
  "between": "cinematic"
3094
3122
  },
3095
3123
  "ceiling": "expressive",
3124
+ "world": "documentary",
3096
3125
  "imagery": "Rooms, views, breakfast; your own photos.",
3097
3126
  "pages": [
3098
3127
  "home",
@@ -3201,6 +3230,7 @@ export const SPEC_BUSINESS_TYPES = [
3201
3230
  "between": "slide"
3202
3231
  },
3203
3232
  "ceiling": "lively",
3233
+ "world": "documentary",
3204
3234
  "imagery": "Real classes (with permission).",
3205
3235
  "pages": [
3206
3236
  "home",
@@ -80,6 +80,24 @@ export enum SceneAttribute {
80
80
  Root = 'data-iox-scene',
81
81
  Tracks = 'data-iox-tracks',
82
82
  PhoneTracks = 'data-iox-tracks-phone',
83
+ /** A root's stage that is not a Scene's `scene.stage` part: the slider a scroll plays (premium M4). */
84
+ Stage = 'data-iox-scene-stage',
85
+ }
86
+
87
+ /**
88
+ * A SLIDER THE SCROLL PLAYS (premium components M4 — M1.3's held gallery). Its progress rests on each picture and changes
89
+ * only over the middle share of each step, so every picture arrives and stays a moment while the page holds it: `p` is
90
+ * the scroll's progress through the runway (0–1), `n` the slides; the answer is Swiper's progress (0–1).
91
+ */
92
+ export const SLIDER_HOLD_SHARE = 0.2;
93
+ /** Page px of scroll per picture, as a share of the screen's height (the runway the renderers reserve). */
94
+ export const SLIDER_SCROLL_STEP_SCREENS = 0.55;
95
+ export function heldProgress(p: number, n: number): number {
96
+ if (!(n >= 2) || !Number.isFinite(p)) return 0;
97
+ const s = clamp01(p) * (n - 1);
98
+ const i = Math.min(n - 2, Math.floor(s));
99
+ const f = clamp01((s - i - SLIDER_HOLD_SHARE) / (1 - 2 * SLIDER_HOLD_SHARE));
100
+ return round((i + smoothstep(f)) / (n - 1));
83
101
  }
84
102
 
85
103
  /** At or under this stage width a layer plays its phone tracks. The `mobile` breakpoint's width. */
@@ -1,7 +1,7 @@
1
1
  import { SceneRole } from '@vectoriox/iox-ui/composite-core';
2
2
  import { FramePlayer, normalizeFrameSequence, VideoAttribute } from '@vectoriox/iox-ui/media-core';
3
3
 
4
- import { normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, scenePlayed, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
4
+ import { heldProgress, normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, scenePlayed, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
5
5
  import { LIGHT_UP_PIECE_CAP, SplitText, splitText } from './split-text';
6
6
 
7
7
  /**
@@ -24,9 +24,32 @@ const SETTLED_PX = 0.25;
24
24
  const MAX_FRAME_SECONDS = 0.1;
25
25
  /** The first frame after a sleep has no previous one to measure from: it counts as one frame, or a scroll would lose it. */
26
26
  const WAKING_FRAME_SECONDS = 1 / 60;
27
- const STAGE_SELECTOR = `.iox-role-${SceneRole.Stage.replace(/\./g, '-')}`;
27
+ const STAGE_SELECTOR = `.iox-role-${SceneRole.Stage.replace(/\./g, '-')}, [${SceneAttribute.Stage}]`;
28
28
  const TRANSFORM_WILL_CHANGE = 'transform, opacity';
29
29
 
30
+ /**
31
+ * Dispatched (bubbling) by something the driver plays that arrived after the driver last looked — a Swiper booted on a
32
+ * slider's stage. Never a DOM `scroll`: nothing that follows the driver may dispatch one (SC2).
33
+ */
34
+ export const SCENE_WAKE_EVENT = 'iox-scene-wake';
35
+
36
+ /**
37
+ * ONE driver per root (premium M4 — §0 "To verify first"). The live site attaches a driver per runtime instance (the
38
+ * page's and the globals'); an instance that scanned a container holding the other's Scenes would drive them a second
39
+ * time, two damped values fighting over one layer. A root is held by the first driver that adds it until that driver
40
+ * lets it go.
41
+ */
42
+ const HELD = new WeakMap<HTMLElement, ScrollDriver>();
43
+
44
+ /** The slider a stage carries when the scroll plays it: a booted Swiper (it sets `el.swiper`), read by shape. */
45
+ interface PlayedSlider { slides?: ArrayLike<unknown>; setProgress?: (progress: number, speed?: number) => void; destroyed?: boolean }
46
+ function sliderOf(stage: HTMLElement | null): PlayedSlider | null {
47
+ // The stage is the runway's child: the `.swiper` itself (both engine paths), or the canvas's shell around it.
48
+ const holder = (stage?.classList.contains('swiper') ? stage : stage?.querySelector('.swiper')) as (HTMLElement & { swiper?: PlayedSlider }) | null | undefined;
49
+ const swiper = holder?.swiper;
50
+ return swiper && typeof swiper.setProgress === 'function' && !swiper.destroyed ? swiper : null;
51
+ }
52
+
30
53
  /** One frame of the damping: frame-rate independent, so 60 Hz and 120 Hz settle in the same time. */
31
54
  export function dampToward(current: number, target: number, seconds: number, rate: number = SCENE_DAMPING): number {
32
55
  return current + (target - current) * (1 - Math.exp(-seconds * rate));
@@ -86,6 +109,8 @@ interface Scene {
86
109
  /** Page px and the stage's own width, measured when something resizes and never per frame. */
87
110
  height: number;
88
111
  travel: number;
112
+ /** Page px from the top of the screen at which the stage holds: 0 for a Scene, mid-screen for a played slider. */
113
+ stick: number;
89
114
  phone: boolean;
90
115
  inlineSign: 1 | -1;
91
116
  /** No frame written yet: the first one lands exactly where the scroll is, with no glide. */
@@ -105,20 +130,28 @@ export class ScrollDriver {
105
130
  private frame = 0;
106
131
  private lastTime = 0;
107
132
  private readonly wakeUp = (): void => this.wake();
133
+ private readonly onWakeEvent = (e: Event): void => {
134
+ const scene = [...this.scenes.values()].find(s => s.root.contains(e.target as Node));
135
+ if (scene) { scene.fresh = true; this.wake(); }
136
+ };
108
137
 
109
138
  constructor(private readonly options: ScrollDriverOptions = {}) {
110
139
  this.host = options.host ?? window;
111
140
  // In capture: a scroll does not bubble, and the canvas's Lenis scrolls an element, not the window.
112
141
  this.host.addEventListener('scroll', this.wakeUp, { capture: true, passive: true });
113
142
  this.host.addEventListener('resize', this.wakeUp, { passive: true });
143
+ this.host.addEventListener(SCENE_WAKE_EVENT, this.onWakeEvent, { capture: true });
114
144
  const Observer = (this.host as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
115
145
  this.resize = Observer ? new Observer(() => this.remeasureAll()) : null;
116
146
  }
117
147
 
118
- /** Drive a Scene's root. Idempotent: adding a root again re-reads it. */
119
- add(root: HTMLElement): void {
148
+ /** Drive a Scene's root. Idempotent: adding a root again re-reads it. False when another driver holds it. */
149
+ add(root: HTMLElement): boolean {
150
+ const holder = HELD.get(root);
151
+ if (holder && holder !== this) return false;
120
152
  this.remove(root);
121
- const scene: Scene = { root, stage: null, layers: [], progress: 0, target: 0, height: 0, travel: 0, phone: false, inlineSign: 1, fresh: true, mutations: null };
153
+ HELD.set(root, this);
154
+ const scene: Scene = { root, stage: null, layers: [], progress: 0, target: 0, height: 0, travel: 0, stick: 0, phone: false, inlineSign: 1, fresh: true, mutations: null };
122
155
  this.scenes.set(root, scene);
123
156
  this.resize?.observe(root);
124
157
  if (this.options.observe) {
@@ -129,11 +162,18 @@ export class ScrollDriver {
129
162
  }
130
163
  }
131
164
  this.readScene(scene);
165
+ return true;
166
+ }
167
+
168
+ /** True when this driver holds `root`. */
169
+ holds(root: HTMLElement): boolean {
170
+ return this.scenes.has(root);
132
171
  }
133
172
 
134
173
  remove(root: HTMLElement): void {
135
174
  const scene = this.scenes.get(root);
136
175
  if (!scene) return;
176
+ if (HELD.get(root) === this) HELD.delete(root);
137
177
  scene.mutations?.disconnect();
138
178
  this.resize?.unobserve(root);
139
179
  if (scene.stage) scene.stage.style.removeProperty('translate');
@@ -198,6 +238,7 @@ export class ScrollDriver {
198
238
  this.frame = 0;
199
239
  this.host.removeEventListener('scroll', this.wakeUp, { capture: true });
200
240
  this.host.removeEventListener('resize', this.wakeUp);
241
+ this.host.removeEventListener(SCENE_WAKE_EVENT, this.onWakeEvent, { capture: true });
201
242
  for (const root of [...this.scenes.keys()]) this.remove(root);
202
243
  this.resize?.disconnect();
203
244
  }
@@ -250,6 +291,9 @@ export class ScrollDriver {
250
291
  private measure(scene: Scene): void {
251
292
  scene.height = scene.root.offsetHeight;
252
293
  scene.travel = Math.max(0, scene.height - (scene.stage?.offsetHeight ?? scene.height));
294
+ // Where the stage holds: its sticky top (a played slider holds mid-screen; a Scene's stage at 0). Read when
295
+ // something resizes, never per frame.
296
+ scene.stick = scene.stage ? (parseFloat(this.host.getComputedStyle(scene.stage).top) || 0) : 0;
253
297
  scene.phone = (scene.stage?.offsetWidth ?? Infinity) <= SCENE_PHONE_MAX_WIDTH;
254
298
  scene.inlineSign = scene.stage && this.host.getComputedStyle(scene.stage).direction === 'rtl' ? -1 : 1;
255
299
  for (const layer of scene.layers) layer.stale = true;
@@ -281,11 +325,14 @@ export class ScrollDriver {
281
325
  const rect = scene.root.getBoundingClientRect();
282
326
  const origin = this.options.originOf?.(scene.root)?.getBoundingClientRect().top ?? 0;
283
327
  const zoom = scene.height ? rect.height / scene.height : 1;
284
- const { local, progress } = sceneScrollState(rect.top, origin, zoom, scene.travel);
328
+ const { local, progress } = sceneScrollState(rect.top, origin + scene.stick * zoom, zoom, scene.travel);
285
329
  return { scene, local, target: progress };
286
330
  }
287
331
 
288
332
  private write(scene: Scene): void {
333
+ // A slider the scroll plays (M4): the damped moment, held on each picture.
334
+ const slider = sliderOf(scene.stage);
335
+ if (slider) slider.setProgress!(heldProgress(scene.progress, slider.slides?.length ?? 0), 0);
289
336
  for (const layer of scene.layers) {
290
337
  const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
291
338
  this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
@@ -359,3 +406,33 @@ export function attachScenes(container: HTMLElement, options: ScrollDriverOption
359
406
  roots.forEach(root => driver.add(root));
360
407
  return driver;
361
408
  }
409
+
410
+ /**
411
+ * The Scene roots that belong to `elements` (each, and what it holds) — what one runtime instance owns: the page's nodes,
412
+ * or the globals'. Never a whole container that also holds another instance's Scenes (premium M4: the globals' instance
413
+ * scanned the app root and drove the page's Scenes a second time).
414
+ */
415
+ export function sceneRootsIn(elements: Iterable<Element>): HTMLElement[] {
416
+ const out = new Set<HTMLElement>();
417
+ for (const el of elements) {
418
+ if ((el as HTMLElement).hasAttribute?.(SceneAttribute.Root)) out.add(el as HTMLElement);
419
+ // A wrapper the node renders around itself (a played slider's runway around its `.swiper`) is the node's own.
420
+ const parent = el.parentElement;
421
+ if (parent?.hasAttribute(SceneAttribute.Root) && parent.children.length === 1) out.add(parent);
422
+ el.querySelectorAll?.<HTMLElement>(`[${SceneAttribute.Root}]`).forEach(root => out.add(root));
423
+ }
424
+ return [...out];
425
+ }
426
+
427
+ /** The live site's door for a runtime that owns some nodes: their Scenes on one driver (null when none, or under reduced
428
+ * motion — the page's own CSS then lets each Scene flow). A root another driver holds stays with it. */
429
+ export function attachSceneRoots(roots: ReadonlyArray<HTMLElement>, options: ScrollDriverOptions = {}): ScrollDriver | null {
430
+ if (!roots.length) return null;
431
+ const host = options.host ?? window;
432
+ if (host.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
433
+ const driver = new ScrollDriver(options);
434
+ const held = roots.filter(root => driver.add(root));
435
+ if (held.length) return driver;
436
+ driver.destroy();
437
+ return null;
438
+ }