@vectoriox/iox-ui 5.78.0 → 5.80.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.
@@ -36,3 +36,4 @@ export * from './plan-words';
36
36
  export * from './audit-site';
37
37
  export * from './stock-match';
38
38
  export * from './stock-photos';
39
+ export * from './film';
@@ -1,4 +1,5 @@
1
1
  import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+ import { FILM_PREPARED_PICTURES, FrameExtension, FrameSequence } from '@vectoriox/iox-ui/media-core';
2
3
 
3
4
  import { BindScope, patternNeedsBinding, PatternGlobalPosition, patternScopes, SCENE_LEVEL, SITE_PATTERNS } from './patterns';
4
5
  import { GlobalSlot, PagePlan, PageSectionPlan, SiteBusiness, SiteLocale, SitePlan, SitePlanSourceKind, SITE_PLAN_VERSION } from './site-plan.model';
@@ -15,8 +16,17 @@ export interface ShowcaseInput {
15
16
  business: SiteBusiness;
16
17
  design: DesignChoices;
17
18
  locale?: `${SiteLocale}`;
19
+ /** SC6b: the film the film's Scene plays — a render-parity run serves its own; absent, `SHOWCASE_FILM`. */
20
+ film?: FrameSequence;
18
21
  }
19
22
 
23
+ /** SC6b: a film's description for the showcase — as many pictures as the build prepares; nothing is fetched to compose it. */
24
+ export const SHOWCASE_FILM: FrameSequence = {
25
+ base: 'https://showcase.iox.invalid/film', ext: FrameExtension.Webp,
26
+ wide: { count: FILM_PREPARED_PICTURES, width: 1600, height: 900 },
27
+ phone: { count: FILM_PREPARED_PICTURES, width: 1100, height: 619 },
28
+ };
29
+
20
30
  const ITEM_ROUTE = '/work/:slug';
21
31
  const LIST_SOURCE = 'projects';
22
32
 
@@ -70,7 +80,7 @@ export function showcaseScenes(): string[] {
70
80
  /**
71
81
  * The showcase as Site Plans — as many as it takes for every header and every footer to be composed once, then one per
72
82
  * Scene pattern (SC6): its home page holds that Scene and nothing else, at How animated Expressive, the only level a
73
- * Scene is composed at.
83
+ * Scene is composed at; the film's Scene with a film (SC6b).
74
84
  */
75
85
  export function showcasePlans(input: ShowcaseInput): SitePlan[] {
76
86
  const { headers, footers } = showcaseGlobals();
@@ -87,6 +97,10 @@ export function showcasePlans(input: ShowcaseInput): SitePlan[] {
87
97
  pages: JSON.parse(JSON.stringify(planPages)),
88
98
  globals: { [GlobalSlot.Header]: headers[i % headers.length], [GlobalSlot.Footer]: footers[i % footers.length] } as SitePlan['globals'],
89
99
  });
90
- const scenes = showcaseScenes().map((id, i) => plan(i, [{ key: 'home', name: 'Home', route: '/', isHome: true, inNav: false, sections: [sectionFor(id)] }], { ...input.design, animate: SCENE_LEVEL }));
100
+ const scenes = showcaseScenes().map((id, i) => {
101
+ const scene = plan(i, [{ key: 'home', name: 'Home', route: '/', isHome: true, inNav: false, sections: [sectionFor(id)] }], { ...input.design, animate: SCENE_LEVEL });
102
+ // A film's Scene plays a film (SC6b): the plan carries one, as the build's would.
103
+ return SITE_PATTERNS[id].film ? { ...scene, film: { sequence: JSON.parse(JSON.stringify(input.film ?? SHOWCASE_FILM)) } } : scene;
104
+ });
91
105
  return [...Array.from({ length: count }, (_, i) => plan(i, pages, input.design)), ...scenes];
92
106
  }
@@ -1,3 +1,4 @@
1
+ import type { FrameSequence } from '@vectoriox/iox-ui/media-core';
1
2
  import type { AnimateId, DesignChoices } from '@vectoriox/iox-ui/design-core';
2
3
 
3
4
  import { SPEC_SITE_ARCHETYPES, SPEC_SITE_FACTS, SPEC_SITE_LOCALES, SPEC_SITE_NEED_IDS, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES } from './site-spec.generated';
@@ -246,4 +247,11 @@ export interface SitePlan {
246
247
  readonly dataSources: ReadonlyArray<DataSourcePlan>;
247
248
  readonly pages: ReadonlyArray<PagePlan>;
248
249
  readonly globals: Readonly<Record<`${GlobalSlot}`, string>>;
250
+ /** SC6b: the owner's film as pictures, set by the build and placed by `placeFilm` — never a model's. */
251
+ readonly film?: PlanFilm;
252
+ }
253
+
254
+ /** SC6b: the owner's film — the numbered pictures the wizard's browser made of their video (media-core). */
255
+ export interface PlanFilm {
256
+ readonly sequence: FrameSequence;
249
257
  }
@@ -12,7 +12,11 @@ export const SPEC_SITE_PLAN_SHAPE = {
12
12
  "contentTypes": "contentType[]",
13
13
  "dataSources": "(cmsDataSource | relativeDataSource)[] — merged by alias into the org library",
14
14
  "pages": "page[]",
15
- "globals": "globals"
15
+ "globals": "globals",
16
+ "film?": "film — the owner's film as pictures (SC6b), set by the build and placed by placeFilm; never a model's"
17
+ },
18
+ "film": {
19
+ "sequence": "object — a media-core FrameSequence: where the numbered pictures live, how many, their size"
16
20
  },
17
21
  "sourceTemplate": {
18
22
  "kind": "'template'",
@@ -582,6 +586,157 @@ export const SPEC_SITE_PATTERNS = {
582
586
  ]
583
587
  }
584
588
  },
589
+ "hero.scene-object": {
590
+ "family": "hero",
591
+ "scene": true,
592
+ "film": true,
593
+ "label": "Hero — your film plays as you scroll",
594
+ "intent": "The studio's own film plays forward and back with the scroll behind the headline while the page holds still.",
595
+ "node": {
596
+ "t": "Section",
597
+ "es": "section",
598
+ "scheme": "inverse",
599
+ "role": "scene",
600
+ "lay": {
601
+ "position": "relative",
602
+ "display": "block",
603
+ "width": "100%",
604
+ "height": "300vh",
605
+ "padding": "0px"
606
+ },
607
+ "c": [
608
+ {
609
+ "t": "Container",
610
+ "role": "scene.stage",
611
+ "lay": {
612
+ "position": "sticky",
613
+ "top": "0px",
614
+ "display": "block",
615
+ "width": "100%",
616
+ "height": "100svh",
617
+ "overflow": "hidden",
618
+ "padding": "0px"
619
+ },
620
+ "c": [
621
+ {
622
+ "t": "Video",
623
+ "es": "media",
624
+ "film": true,
625
+ "lay": {
626
+ "position": "absolute",
627
+ "top": "0px",
628
+ "left": "0px",
629
+ "width": "100%",
630
+ "height": "100%",
631
+ "aspectRatio": "auto",
632
+ "borderRadius": "0px"
633
+ },
634
+ "tracks": [
635
+ {
636
+ "prop": "play",
637
+ "keys": [
638
+ [
639
+ 0,
640
+ 0
641
+ ],
642
+ [
643
+ 1,
644
+ 100
645
+ ]
646
+ ]
647
+ }
648
+ ]
649
+ },
650
+ {
651
+ "t": "Container",
652
+ "es": "media-scrim",
653
+ "lay": {
654
+ "display": "flex",
655
+ "flexDirection": "column",
656
+ "gap": "14px",
657
+ "position": "absolute",
658
+ "left": "0px",
659
+ "bottom": "0px",
660
+ "width": "100%",
661
+ "padding": "48px 32px 56px 32px"
662
+ },
663
+ "c": [
664
+ {
665
+ "t": "Heading",
666
+ "es": "display",
667
+ "level": "h1",
668
+ "slot": "headline",
669
+ "max": 56,
670
+ "fromBusiness": "name",
671
+ "tracks": [
672
+ {
673
+ "prop": "y",
674
+ "keys": [
675
+ [
676
+ 0,
677
+ 0
678
+ ],
679
+ [
680
+ 1,
681
+ -6
682
+ ]
683
+ ]
684
+ }
685
+ ]
686
+ },
687
+ {
688
+ "t": "Text",
689
+ "es": "lead",
690
+ "slot": "lead",
691
+ "max": 180,
692
+ "fromBusiness": "tagline",
693
+ "ph": "copy",
694
+ "tracks": [
695
+ {
696
+ "prop": "opacity",
697
+ "keys": [
698
+ [
699
+ 0.3,
700
+ 0
701
+ ],
702
+ [
703
+ 0.45,
704
+ 1
705
+ ]
706
+ ]
707
+ }
708
+ ]
709
+ },
710
+ {
711
+ "t": "ButtonBlock",
712
+ "es": "button-primary",
713
+ "href": "/contact",
714
+ "slot": "cta",
715
+ "max": 24,
716
+ "text": "Get in touch",
717
+ "tracks": [
718
+ {
719
+ "prop": "opacity",
720
+ "keys": [
721
+ [
722
+ 0.35,
723
+ 0
724
+ ],
725
+ [
726
+ 0.5,
727
+ 1
728
+ ]
729
+ ]
730
+ }
731
+ ]
732
+ }
733
+ ]
734
+ }
735
+ ]
736
+ }
737
+ ]
738
+ }
739
+ },
585
740
  "story.scene-chapters": {
586
741
  "family": "story",
587
742
  "scene": true,
@@ -5491,6 +5646,11 @@ export const SPEC_SITE_LIBRARY = [
5491
5646
  "family": "hero",
5492
5647
  "composed": true
5493
5648
  },
5649
+ {
5650
+ "id": "hero.scene-object",
5651
+ "family": "hero",
5652
+ "composed": true
5653
+ },
5494
5654
  {
5495
5655
  "id": "proof.logo-strip",
5496
5656
  "family": "proof",
@@ -1,5 +1,6 @@
1
1
  import { flavorPackById, parseHexColor, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
2
2
  import { formPresetById } from '@vectoriox/iox-ui/forms-core';
3
+ import { filmScreensFor, normalizeFrameSequence } from '@vectoriox/iox-ui/media-core';
3
4
  import { SEO_DESCRIPTION_MAX, SEO_TITLE_MAX } from '@vectoriox/iox-ui/seo-core';
4
5
 
5
6
  import {
@@ -72,6 +73,9 @@ export enum SitePlanIssueCode {
72
73
  /** SC6: a Scene below How animated Expressive, a second Scene on a page, or Scenes on more than the home page and
73
74
  * one other — the pages answer by choosing another pattern. */
74
75
  SceneBudget = 'scene-budget',
76
+ /** SC6b: the owner's film — pictures that cannot play, too few to play smoothly, or a film's pattern on a plan with
77
+ * no film (or twice). Set by the build, never by a model. */
78
+ Film = 'film',
75
79
  }
76
80
 
77
81
  export type SitePlanIssue = PathIssue<SitePlanIssueCode>;
@@ -110,6 +114,7 @@ const SEED_FIXED_KEYS = shapeKeys(SHAPE['seed']);
110
114
  const CMS_SOURCE_KEYS = shapeKeys(SHAPE['cmsDataSource']);
111
115
  const RELATIVE_SOURCE_KEYS = shapeKeys(SHAPE['relativeDataSource']);
112
116
  const PAGE_KEYS = shapeKeys(SHAPE['page']);
117
+ const FILM_KEYS = shapeKeys(SHAPE['film']);
113
118
  const SECTION_KEYS = shapeKeys(SHAPE['section']);
114
119
  const GLOBAL_POSITION: Readonly<Record<`${GlobalSlot}`, `${PatternGlobalPosition}`>> = {
115
120
  [GlobalSlot.Header]: PatternGlobalPosition.Before,
@@ -476,6 +481,33 @@ function validateScenes(c: Collector, pages: unknown[], animate: string | null):
476
481
  });
477
482
  }
478
483
 
484
+ /** SC6b: a film plays only when its pictures can (and smoothly over the shortest hold); its pattern only with a film, once. */
485
+ function validateFilm(c: Collector, film: unknown, pages: unknown[]): void {
486
+ let playable = false;
487
+ if (film !== undefined) {
488
+ if (!isObject(film)) {
489
+ c.add(SitePlanIssueCode.Shape, 'film', 'must be an object');
490
+ } else {
491
+ c.keys(film, FILM_KEYS, 'film');
492
+ const sequence = normalizeFrameSequence(film['sequence']);
493
+ if (!sequence) c.add(SitePlanIssueCode.Film, 'film.sequence', 'not a film\'s pictures (a media-core FrameSequence)');
494
+ else if (filmScreensFor(sequence.wide.count) === null) c.add(SitePlanIssueCode.Film, 'film.sequence', `${sequence.wide.count} pictures — too few to play smoothly`);
495
+ else playable = true;
496
+ }
497
+ }
498
+ let placed = 0;
499
+ pages.forEach((raw, i) => {
500
+ if (!isObject(raw) || !Array.isArray(raw['sections'])) return;
501
+ raw['sections'].forEach((s, j) => {
502
+ const pattern = isObject(s) && typeof s['pattern'] === 'string' ? patternById(s['pattern']) : null;
503
+ if (!pattern?.film) return;
504
+ const path = `pages[${i}].sections[${j}].pattern`;
505
+ if (!playable) c.add(SitePlanIssueCode.Film, path, `"${s['pattern']}" plays the owner's film — this plan has none`);
506
+ else if (++placed > 1) c.add(SitePlanIssueCode.Film, path, 'the film plays once on a site');
507
+ });
508
+ });
509
+ }
510
+
479
511
  function validatePageSeo(c: Collector, seo: unknown, path: string): void {
480
512
  if (seo === undefined) return;
481
513
  if (!isObject(seo)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
@@ -523,6 +555,7 @@ export function validateSitePlan(plan: unknown): SitePlanValidation {
523
555
  validatePages(c, c.array(plan, 'pages', ''), types, sources, typeof design['density'] === 'string' ? design['density'] : null);
524
556
  validateGlobals(c, plan['globals']);
525
557
  validateScenes(c, c.array(plan, 'pages', ''), typeof design['animate'] === 'string' ? design['animate'] : null);
558
+ validateFilm(c, plan['film'], c.array(plan, 'pages', ''));
526
559
  return { valid: c.issues.length === 0, issues: c.issues };
527
560
  }
528
561
 
@@ -1,4 +1,5 @@
1
1
  import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+ import type { FrameSequence } from '@vectoriox/iox-ui/media-core';
2
3
  import type { AiGenerationStage, AiStep, PlanChange, PlanEdit, SitePlan } from '@vectoriox/iox-ui/generation-core';
3
4
 
4
5
  import type { StyleRead } from './rank';
@@ -78,7 +79,8 @@ export interface AiGenerationView {
78
79
  stage: `${AiGenerationStage}`;
79
80
  /** The model step the runner is working on; null = the owner's move. */
80
81
  task: `${AiStep}` | null;
81
- intake: { prompt: string; files: GenerationFileView[]; addresses: string[]; auto: boolean };
82
+ /** `film`: the owner's film as pictures (SC6b), told by the wizard at Build — `placeFilm` places it. */
83
+ intake: { prompt: string; files: GenerationFileView[]; addresses: string[]; auto: boolean; film?: FrameSequence };
82
84
  readers: GenerationReadersView;
83
85
  reads: { analysis?: { value: SiteAnalysis; model: string; usageIds: string[] }; style?: StyleRead | null };
84
86
  /** What Design and Plan use: the owner's edit, else the reading. */
@@ -93,7 +95,9 @@ export interface AiGenerationView {
93
95
  siteBuildId?: string;
94
96
  models: Partial<Record<`${AiStep}`, string>>;
95
97
  estimate: number;
96
- cap: number;
98
+ /** The plan's `aiCreditsPerGeneration` for the whole build, or null when it sets none (a step's own ceiling is
99
+ * twice that step's estimate — going back and planning again starts a new step). */
100
+ cap: number | null;
97
101
  /** Derived from the ledger — the running credit total. */
98
102
  spent: number;
99
103
  error?: { code: string; detail: string; providerMessage?: string; issues?: Array<{ code: string; path: string }> };
@@ -106,10 +110,12 @@ export interface GenerationStartRequest {
106
110
  addresses?: string[];
107
111
  }
108
112
 
109
- /** The longest message, the most files and addresses a generation starts from (Start's limits). */
113
+ /** The longest message, the most files and addresses a generation starts from (Start's limits), and how many plans
114
+ * one build may ask for (a Design pick, a Try again or "Build with these" each asks one). */
110
115
  export const GENERATION_LIMITS = {
111
116
  promptChars: 4_000,
112
117
  files: 12,
113
118
  addresses: 3,
114
119
  sentenceChars: 500,
120
+ plans: 5,
115
121
  } as const;
@@ -226,6 +226,29 @@ export function videoFrameCount(seconds: number): number {
226
226
  return Math.min(VIDEO_MAX_FRAMES, Math.max(2, Math.ceil(seconds * VIDEO_FRAMES_PER_SECOND)));
227
227
  }
228
228
 
229
+ /**
230
+ * Pictures per screen of scrolling at which a played film stays smooth as a glide slows to a stop. Measured on Doron's
231
+ * iPhone (2026-11-05): Studio Alma's film at about 11 a screen stepped, at about 55 it did not.
232
+ */
233
+ export const FILM_SMOOTH_PER_SCREEN = 40;
234
+ /** The fewest and most screens a film the build places holds the page for (SC6b): long enough to feel, short enough to pass. */
235
+ export const FILM_MIN_SCREENS = 1;
236
+ export const FILM_MAX_SCREENS = 4;
237
+ /** What the build prepares a film to: about 60 pictures a screen at the longest hold — denser than smooth, never wasted. */
238
+ export const FILM_PREPARED_PICTURES = FILM_MAX_SCREENS * 60 + 1;
239
+ /** The fewest pictures a placed film may have: smooth over the shortest hold. */
240
+ export const FILM_MIN_PICTURES = FILM_MIN_SCREENS * FILM_SMOOTH_PER_SCREEN + 1;
241
+
242
+ /**
243
+ * The screens a film of `count` pictures holds the page for (SC6b, the composer's density rule): the stretches between
244
+ * pictures are what scroll, so `(count − 1) ÷ 40`, rounded DOWN to a tenth (150 pictures over 3.75 screens measure 39.7 a
245
+ * screen, thin), within the hold's limits. Null: too few pictures to play smoothly over the shortest hold.
246
+ */
247
+ export function filmScreensFor(count: number): number | null {
248
+ if (!Number.isInteger(count) || count < FILM_MIN_PICTURES) return null;
249
+ return Math.min(FILM_MAX_SCREENS, Math.floor(((count - 1) / FILM_SMOOTH_PER_SCREEN) * 10) / 10);
250
+ }
251
+
229
252
  /** Which of `sourceCount` pictures make a set of `count`, spread evenly, first and last always in. */
230
253
  export function sampledFrameIndexes(sourceCount: number, count: number): number[] {
231
254
  if (count >= sourceCount) return Array.from({ length: sourceCount }, (_, i) => i);
@@ -1,4 +1,5 @@
1
1
  import { SceneRole } from '@vectoriox/iox-ui/composite-core';
2
+ import { FILM_SMOOTH_PER_SCREEN } from '@vectoriox/iox-ui/media-core';
2
3
 
3
4
  import { normalizeTracks, SCENE_REVEAL_DEFAULT, SceneKey, SceneNodeSource, SceneProp, SceneRevealFrom, SceneTrack, valueAt } from './scene';
4
5
 
@@ -238,11 +239,8 @@ export function sceneHeightForScreens(screens: number): string {
238
239
  return `${roundTo((held + 1) * 100, 1)}vh`;
239
240
  }
240
241
 
241
- /**
242
- * Pictures per screen of scrolling at which a played film stays smooth as a glide slows to a stop. Measured on Doron's
243
- * iPhone (2026-11-05): Studio Alma's film at about 11 a screen stepped, at about 55 it did not.
244
- */
245
- export const SCENE_FILM_SMOOTH = 40;
242
+ /** Pictures per screen of scrolling at which a played film stays smooth (media-core's, which the composer sizes by). */
243
+ export const SCENE_FILM_SMOOTH = FILM_SMOOTH_PER_SCREEN;
246
244
 
247
245
  export interface SceneFilmDensity {
248
246
  /** Pictures per screen of scrolling over most of the play: half its scroll holds at least this many. */