@vectoriox/iox-ui 5.68.0 → 5.70.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.
- package/composite-core/index.d.ts +9 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +13 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +39 -7
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs +471 -0
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -0
- package/fesm2022/vectoriox-iox-ui.mjs +2 -2
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +3 -0
- package/package.json +5 -1
- package/scene-core/index.d.ts +193 -0
- package/scene-core/package.json +3 -0
- package/src/composite-core/composite-kinds.ts +9 -0
- package/src/composite-core/structure.ts +4 -1
- package/src/generation-core/compose-site.ts +2 -1
- package/src/generation-core/plan-wire.ts +14 -5
- package/src/generation-core/plan-words.ts +3 -1
- package/src/generation-core/site-plan.model.ts +2 -0
- package/src/generation-core/site-spec.generated.ts +1 -0
- package/src/generation-core/validate-site-plan.ts +11 -0
- package/src/scene-core/public-api.ts +9 -0
- package/src/scene-core/scene-recipe.ts +100 -0
- package/src/scene-core/scene.ts +232 -0
- package/src/scene-core/scroll-driver.ts +220 -0
- package/styles/iox-design.css +7 -0
|
@@ -69,6 +69,7 @@ declare const SPEC_SITE_PLAN_SHAPE: {
|
|
|
69
69
|
readonly count: "integer ≥ 0";
|
|
70
70
|
readonly sample: "boolean — items are flagged isSample";
|
|
71
71
|
readonly isPublished: "boolean";
|
|
72
|
+
readonly "perItem?": "per field, one string per sample: real content that wins over the template";
|
|
72
73
|
readonly "[field]?": "the field's value — strings are templates: {n} = the item's 1-based number, {sampleImage:n} / {sampleImage:n+k} = a bundled sample image";
|
|
73
74
|
};
|
|
74
75
|
readonly cmsDataSource: {
|
|
@@ -4371,6 +4372,8 @@ interface ContentSeedPlan {
|
|
|
4371
4372
|
readonly count: number;
|
|
4372
4373
|
readonly sample: boolean;
|
|
4373
4374
|
readonly isPublished: boolean;
|
|
4375
|
+
/** Per field, one value per sample: real content where the items are about a subject anyone can know. Wins over the field's template. */
|
|
4376
|
+
readonly perItem?: Readonly<Record<string, ReadonlyArray<string>>>;
|
|
4374
4377
|
readonly [field: string]: unknown;
|
|
4375
4378
|
}
|
|
4376
4379
|
/** The part a field plays (contract `site.roles`, AI plan L0-3): what a pattern binds instead of a field name, so one
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vectoriox/iox-ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.70.0",
|
|
4
4
|
"peerDependencies": {
|
|
5
5
|
"@angular/common": ">=20.0.0",
|
|
6
6
|
"@angular/core": ">=20.0.0",
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"types": "./media-core/index.d.ts",
|
|
56
56
|
"default": "./fesm2022/vectoriox-iox-ui-media-core.mjs"
|
|
57
57
|
},
|
|
58
|
+
"./scene-core": {
|
|
59
|
+
"types": "./scene-core/index.d.ts",
|
|
60
|
+
"default": "./fesm2022/vectoriox-iox-ui-scene-core.mjs"
|
|
61
|
+
},
|
|
58
62
|
"./seo-core": {
|
|
59
63
|
"types": "./seo-core/index.d.ts",
|
|
60
64
|
"default": "./fesm2022/vectoriox-iox-ui-seo-core.mjs"
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { CompositeNode, CompositeNodeFactory } from '@vectoriox/iox-ui/composite-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Scene's data and maths (premium-sites plan §5b). A Scene is a recipe of ordinary nodes — a tall Section, a pinned
|
|
5
|
+
* stage inside it, ordinary nodes as layers — so every word stays real, bound and translatable, and all three render
|
|
6
|
+
* paths already draw it. What moves a layer is its OWN `sceneTracks`, never a list on the root naming layer ids: a clone
|
|
7
|
+
* copies them with the layer, a delete takes them with it, and there is no id to remap.
|
|
8
|
+
*
|
|
9
|
+
* The vocabulary is closed. Every property is one the compositor can run without layout (CLS stays 0): a saved value
|
|
10
|
+
* outside it is dropped when read, so a page document can never write CSS through a track.
|
|
11
|
+
*/
|
|
12
|
+
declare enum SceneProp {
|
|
13
|
+
/** Along the inline axis, in % of the stage's width. Positive runs toward the inline END (mirrored in RTL). */
|
|
14
|
+
X = "x",
|
|
15
|
+
/** Down, in % of the stage's height. */
|
|
16
|
+
Y = "y",
|
|
17
|
+
Scale = "scale",
|
|
18
|
+
/** Degrees. */
|
|
19
|
+
Rotate = "rotate",
|
|
20
|
+
/** 0–1. */
|
|
21
|
+
Opacity = "opacity",
|
|
22
|
+
/** Inset from every side, in % of the layer (0 = whole, 50 = gone). */
|
|
23
|
+
Clip = "clip",
|
|
24
|
+
/** The layer's background colour. */
|
|
25
|
+
Ground = "ground",
|
|
26
|
+
/** The layer's text colour. */
|
|
27
|
+
Ink = "ink"
|
|
28
|
+
}
|
|
29
|
+
/** One key: at progress `[0]` (0–1) the property holds `[1]`: a number, or a colour for Ground and Ink. */
|
|
30
|
+
type SceneKey = readonly [number, number | string];
|
|
31
|
+
interface SceneTrack {
|
|
32
|
+
prop: SceneProp;
|
|
33
|
+
/** Ascending by progress. Between two keys the value eases (smoothstep); outside them it holds the nearest. */
|
|
34
|
+
keys: ReadonlyArray<SceneKey>;
|
|
35
|
+
}
|
|
36
|
+
/** What a node carries (both a builder node and a saved one). */
|
|
37
|
+
interface SceneNodeSource {
|
|
38
|
+
role?: string | null;
|
|
39
|
+
/** How this layer moves, from the wide layout down to the phone's. */
|
|
40
|
+
sceneTracks?: ReadonlyArray<SceneTrack> | null;
|
|
41
|
+
/** How it moves at phone width instead (`SCENE_PHONE_MAX_WIDTH`). Absent = the same tracks. */
|
|
42
|
+
scenePhoneTracks?: ReadonlyArray<SceneTrack> | null;
|
|
43
|
+
}
|
|
44
|
+
/** A frame as inline-style properties (camelCase, as `CSSStyleDeclaration` and `Object.assign` take them). */
|
|
45
|
+
interface SceneFrame {
|
|
46
|
+
transform?: string;
|
|
47
|
+
opacity?: string;
|
|
48
|
+
clipPath?: string;
|
|
49
|
+
backgroundColor?: string;
|
|
50
|
+
color?: string;
|
|
51
|
+
}
|
|
52
|
+
declare enum SceneAttribute {
|
|
53
|
+
Root = "data-iox-scene",
|
|
54
|
+
Tracks = "data-iox-tracks",
|
|
55
|
+
PhoneTracks = "data-iox-tracks-phone"
|
|
56
|
+
}
|
|
57
|
+
/** At or under this stage width a layer plays its phone tracks. The `mobile` breakpoint's width. */
|
|
58
|
+
declare const SCENE_PHONE_MAX_WIDTH = 767;
|
|
59
|
+
/**
|
|
60
|
+
* The tracks a saved value really holds: unknown properties, bad keys and colours that are not colours are dropped,
|
|
61
|
+
* keys sorted by progress, a track left with no key dropped. Never throws on what a page document holds.
|
|
62
|
+
*/
|
|
63
|
+
declare function normalizeTracks(tracks: unknown): SceneTrack[];
|
|
64
|
+
/** A numeric track's value at progress `p`. */
|
|
65
|
+
declare function valueAt(keys: ReadonlyArray<SceneKey>, p: number): number;
|
|
66
|
+
/**
|
|
67
|
+
* A colour track's value at progress `p`. Mixed by the browser, never computed here, so a design token stays a live
|
|
68
|
+
* reference (a re-theme reaches a Scene, as it reaches every saved page) and the path is perceptual.
|
|
69
|
+
*/
|
|
70
|
+
declare function colourAt(keys: ReadonlyArray<SceneKey>, p: number): string;
|
|
71
|
+
interface SceneFrameOptions {
|
|
72
|
+
/** -1 when the stage reads right-to-left: the inline axis mirrors. */
|
|
73
|
+
inlineSign?: 1 | -1;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A layer's frame at progress `p`. Lengths are container-query units, so one frame is right at any width, under any
|
|
77
|
+
* canvas zoom, on the server and with no measuring: the stage is the size container (`iox-design.css`).
|
|
78
|
+
*/
|
|
79
|
+
declare function sceneFrame(tracks: ReadonlyArray<SceneTrack>, p: number, options?: SceneFrameOptions): SceneFrame;
|
|
80
|
+
/** A frame as kebab-case style declarations, for a string renderer. */
|
|
81
|
+
declare function sceneFrameDeclarations(frame: SceneFrame): Record<string, string>;
|
|
82
|
+
/** The attributes a node's own element carries — the same decision in all three render paths. The runtime reads them,
|
|
83
|
+
* so it needs no node tree and is the same on every path. Empty for any node that is no part of a Scene. */
|
|
84
|
+
declare function sceneNodeAttributes(node: SceneNodeSource | null | undefined): Record<string, string>;
|
|
85
|
+
/** The frame a layer ships in from the server: where it stands before anyone has scrolled. Without it the page would
|
|
86
|
+
* paint every layer at once, and then jump when the script arrives. */
|
|
87
|
+
declare function sceneFirstFrame(node: SceneNodeSource | null | undefined): SceneFrame;
|
|
88
|
+
/**
|
|
89
|
+
* Make an element carry its node's Scene attributes: the live sync on the canvas and the component path's stamp.
|
|
90
|
+
* Removes stale ones, touches nothing else.
|
|
91
|
+
*/
|
|
92
|
+
declare function syncSceneAttributes(el: HTMLElement | null | undefined, node: SceneNodeSource | null | undefined): void;
|
|
93
|
+
/** Stamp a layer's first frame on its element (the component path, on the server too). */
|
|
94
|
+
declare function applySceneFirstFrame(el: HTMLElement | null | undefined, node: SceneNodeSource | null | undefined): void;
|
|
95
|
+
/**
|
|
96
|
+
* Where nothing will drive it (reduced motion, no script) a Scene is ordinary content: it flows, every layer in its
|
|
97
|
+
* place and in its own look, every word readable. `!important` because it must beat a node's compiled height and
|
|
98
|
+
* position, and the inline first frame. The engine wraps it in a reduced-motion query and in `<noscript>`.
|
|
99
|
+
*/
|
|
100
|
+
declare const SCENE_FLOW_RULES: string;
|
|
101
|
+
/** Under reduced motion the driver never starts, so the same rules hold for the whole visit. */
|
|
102
|
+
declare const SCENE_REDUCED_MOTION_CSS = "@media (prefers-reduced-motion: reduce){.iox-role-scene{height:auto!important}.iox-role-scene-stage{position:relative!important;top:auto!important;height:auto!important;overflow:visible!important;container-type:normal!important}[data-iox-tracks]{transform:none!important;opacity:1!important;clip-path:none!important}}";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The SCENE recipe: ordinary nodes, no new type, so all three render paths and every panel already know it.
|
|
106
|
+
*
|
|
107
|
+
* Section role scene height = how long it plays (× the viewport)
|
|
108
|
+
* └─ Container role scene.stage pinned, one viewport tall, clips its layers, the size container for their lengths
|
|
109
|
+
* ├─ Heading a layer with its own tracks
|
|
110
|
+
* └─ Text a layer with its own tracks
|
|
111
|
+
*
|
|
112
|
+
* How long it plays IS the root's height, so it follows the width system the way every other size does. The stage is a
|
|
113
|
+
* track-carrying layer too: its Ground is the colour the whole Scene moves through.
|
|
114
|
+
*/
|
|
115
|
+
type SceneLayerNode = CompositeNode & SceneNodeSource;
|
|
116
|
+
interface SceneWords {
|
|
117
|
+
title: string;
|
|
118
|
+
line: string;
|
|
119
|
+
}
|
|
120
|
+
/** Generic words a fresh Scene starts with: the author's to replace. */
|
|
121
|
+
declare const SCENE_WORDS: Readonly<SceneWords>;
|
|
122
|
+
/** How long a fresh Scene plays: 4 viewports wide, 3 on a phone (a thumb travels less). */
|
|
123
|
+
declare const SCENE_LENGTH = "400vh";
|
|
124
|
+
declare const SCENE_PHONE_LENGTH = "300vh";
|
|
125
|
+
/** Small viewport height: a phone's toolbar never pushes the stage's bottom out of sight. */
|
|
126
|
+
declare const SCENE_STAGE_HEIGHT = "100svh";
|
|
127
|
+
/** A fresh Scene: it already moves, so the author sees what the thing is before touching it. */
|
|
128
|
+
declare function buildSceneRecipe(create: CompositeNodeFactory, words?: Partial<SceneWords>): SceneLayerNode;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The ONE scroll loop (premium-sites plan §5b, SC0). Every Scene on a page reads one progress value from it, on the
|
|
132
|
+
* canvas and on the live site alike. It reads where the root is and nothing else, so it has one code path: under the
|
|
133
|
+
* canvas's zoom the same rectangle divides by the same zoom, and the answer is the same at any scale.
|
|
134
|
+
*
|
|
135
|
+
* - The value is DAMPED: the scroll stops and the page settles a moment later. This is much of the prototype's weight,
|
|
136
|
+
* and why this is not a CSS scroll timeline, which cannot do it.
|
|
137
|
+
* - The live site pins with `position: sticky`, on the compositor. The canvas cannot (its page never scrolls itself,
|
|
138
|
+
* its Lenis scrolls a wrapper around a zoomed frame), so `pin` holds the stage there with `translate` instead.
|
|
139
|
+
* `translate`, not `transform`: a Stage may carry tracks of its own.
|
|
140
|
+
* - It sleeps. No frame is asked for unless something moved, so a page holding a settled Scene costs nothing.
|
|
141
|
+
* - Reads come first in a frame and writes after, and a write only ever touches compositor properties.
|
|
142
|
+
*/
|
|
143
|
+
declare const SCENE_DAMPING = 9;
|
|
144
|
+
/** One frame of the damping: frame-rate independent, so 60 Hz and 120 Hz settle in the same time. */
|
|
145
|
+
declare function dampToward(current: number, target: number, seconds: number, rate?: number): number;
|
|
146
|
+
/** Where a root stands: scrolled `local` page px into its `travel`. `zoom` undoes the canvas's scale. */
|
|
147
|
+
declare function sceneScrollState(rootTop: number, originTop: number, zoom: number, travel: number): {
|
|
148
|
+
local: number;
|
|
149
|
+
progress: number;
|
|
150
|
+
};
|
|
151
|
+
interface ScrollDriverOptions {
|
|
152
|
+
/** Hold the stage with `translate` (the canvas). The live site's stage is sticky and needs nothing. */
|
|
153
|
+
pin?: boolean;
|
|
154
|
+
/** The element a root scrolls under, when that is not the window (the canvas's frame, Preview's page). */
|
|
155
|
+
originOf?: (root: HTMLElement) => HTMLElement | null;
|
|
156
|
+
/** Follow what changes inside a root: tracks edited, layers added (the canvas). */
|
|
157
|
+
observe?: boolean;
|
|
158
|
+
damping?: number;
|
|
159
|
+
/** The window, for a test. */
|
|
160
|
+
host?: Window;
|
|
161
|
+
}
|
|
162
|
+
declare class ScrollDriver {
|
|
163
|
+
private readonly options;
|
|
164
|
+
private readonly scenes;
|
|
165
|
+
private readonly host;
|
|
166
|
+
private readonly resize;
|
|
167
|
+
private frame;
|
|
168
|
+
private lastTime;
|
|
169
|
+
private readonly wakeUp;
|
|
170
|
+
constructor(options?: ScrollDriverOptions);
|
|
171
|
+
/** Drive a Scene's root. Idempotent: adding a root again re-reads it. */
|
|
172
|
+
add(root: HTMLElement): void;
|
|
173
|
+
remove(root: HTMLElement): void;
|
|
174
|
+
/** The damped progress a root's layers stand at. */
|
|
175
|
+
progressOf(root: HTMLElement): number | null;
|
|
176
|
+
/** Something moved that no scroll or resize announces (the canvas's zoom). */
|
|
177
|
+
wake(): void;
|
|
178
|
+
destroy(): void;
|
|
179
|
+
/** Read a root's stage and layers from its DOM: its own Scene's only, never a nested one's. */
|
|
180
|
+
private reread;
|
|
181
|
+
private readLayer;
|
|
182
|
+
private remeasureAll;
|
|
183
|
+
private measure;
|
|
184
|
+
private readonly tick;
|
|
185
|
+
private read;
|
|
186
|
+
private write;
|
|
187
|
+
}
|
|
188
|
+
/** A page's Scenes, driven by one driver; null when it holds none, or when the visitor asked for less motion (the
|
|
189
|
+
* page's own CSS then lets each Scene flow). The live site's door. */
|
|
190
|
+
declare function attachScenes(container: HTMLElement, options?: ScrollDriverOptions): ScrollDriver | null;
|
|
191
|
+
|
|
192
|
+
export { SCENE_DAMPING, SCENE_FLOW_RULES, SCENE_LENGTH, SCENE_PHONE_LENGTH, SCENE_PHONE_MAX_WIDTH, SCENE_REDUCED_MOTION_CSS, SCENE_STAGE_HEIGHT, SCENE_WORDS, SceneAttribute, SceneProp, ScrollDriver, applySceneFirstFrame, attachScenes, buildSceneRecipe, colourAt, dampToward, normalizeTracks, sceneFirstFrame, sceneFrame, sceneFrameDeclarations, sceneNodeAttributes, sceneScrollState, syncSceneAttributes, valueAt };
|
|
193
|
+
export type { SceneFrame, SceneFrameOptions, SceneKey, SceneLayerNode, SceneNodeSource, SceneTrack, SceneWords, ScrollDriverOptions };
|
|
@@ -66,6 +66,15 @@ export enum SegmentedRole {
|
|
|
66
66
|
Badge = 'segmented.badge',
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A SCENE (premium-sites plan §5b): a stage held still while the page scrolls past it. The stage is the root's only
|
|
71
|
+
* part; everything inside the stage is an ordinary node. Its maths and its runtime are `@vectoriox/iox-ui/scene-core`.
|
|
72
|
+
*/
|
|
73
|
+
export enum SceneRole {
|
|
74
|
+
Scene = 'scene',
|
|
75
|
+
Stage = 'scene.stage',
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
interface RoleLike { role?: string | null }
|
|
70
79
|
|
|
71
80
|
/** The roles each open/close kind plays in the shared runtime. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AccordionRole, DisclosureRole, SegmentedRole, TabsRole } from './composite-kinds';
|
|
1
|
+
import { AccordionRole, DisclosureRole, SceneRole, SegmentedRole, TabsRole } from './composite-kinds';
|
|
2
2
|
import { CardPart, CardRole } from './card';
|
|
3
3
|
import { SLIDER_TYPE, SliderRole } from './slider';
|
|
4
4
|
|
|
@@ -56,6 +56,9 @@ export const COMPOSITE_ROLE_SPECS: Readonly<Record<string, CompositeRoleSpec>> =
|
|
|
56
56
|
[SegmentedRole.Segmented]: { parent: null, accepts: [SegmentedRole.Option] },
|
|
57
57
|
[SegmentedRole.Option]: { parent: SegmentedRole.Segmented, pinned: true },
|
|
58
58
|
[SegmentedRole.Badge]: { parent: SegmentedRole.Option, pinned: true },
|
|
59
|
+
// A Scene always keeps its stage: everything it pins and clips lives there.
|
|
60
|
+
[SceneRole.Scene]: { parent: null },
|
|
61
|
+
[SceneRole.Stage]: { parent: SceneRole.Scene, locked: true },
|
|
59
62
|
// CC3 — a Slider's controls are its own direct children (Swiper is handed them). The pause control stays while
|
|
60
63
|
// Autoplay is on (WCAG 2.2.2): the Autoplay switch removes it, never Delete. The arrows and the dots follow
|
|
61
64
|
// their switches and may be deleted.
|
|
@@ -1109,7 +1109,8 @@ function composeItems(type: ContentTypePlan, fillEmpty: boolean): ComposedItem[]
|
|
|
1109
1109
|
for (const field of type.fields) {
|
|
1110
1110
|
const given = seed[field.name];
|
|
1111
1111
|
const empty = !(field.name in seed) || given === '' || (Array.isArray(given) && !given.length);
|
|
1112
|
-
const
|
|
1112
|
+
const own = seed.perItem?.[field.name]?.[n - 1];
|
|
1113
|
+
const value = own || (empty && fillEmpty ? defaultSeed(type, field) : given);
|
|
1113
1114
|
if (value !== undefined) item[field.name] = expandSeedValue(value, n);
|
|
1114
1115
|
}
|
|
1115
1116
|
items.push({ ...item, isPublished: seed.isPublished, isSample: seed.sample });
|
|
@@ -130,6 +130,7 @@ const WIRE_KEYS: Readonly<Record<`${PlanWireEntry}`, Readonly<Record<string, Wir
|
|
|
130
130
|
seedValue: {
|
|
131
131
|
field: { refs: ['seed.[field]'], text: 'string — a field of the type' },
|
|
132
132
|
value: { refs: ['seed.[field]'], text: `string | string[] — ${describeShapeKey(PLAN_SHAPE['seed'], '[field]')} (a list for an array field)` },
|
|
133
|
+
each: { refs: ['seed.perItem'], text: 'string[] — one value per sample, in order, when each sample has its own content', none: '[]' },
|
|
133
134
|
},
|
|
134
135
|
dataSource: {
|
|
135
136
|
alias: { refs: ['cmsDataSource.alias', 'relativeDataSource.alias'] },
|
|
@@ -284,7 +285,7 @@ const shapedAs = (entry: `${PlanWireEntry}`, options: WireOptions, properties: R
|
|
|
284
285
|
};
|
|
285
286
|
|
|
286
287
|
export function wireContentTypeJsonSchema(options: WireOptions = {}): JsonSchema {
|
|
287
|
-
const value = shaped(PlanWireEntry.SeedValue, { field: jsonText(), value: jsonAnyOf([jsonText(), jsonListOf(jsonText())]) });
|
|
288
|
+
const value = shaped(PlanWireEntry.SeedValue, { field: jsonText(), value: jsonAnyOf([jsonText(), jsonListOf(jsonText())]), each: jsonListOf(jsonText()) });
|
|
288
289
|
return shapedAs(PlanWireEntry.ContentType, options, {
|
|
289
290
|
name: jsonText(),
|
|
290
291
|
label: jsonText(),
|
|
@@ -412,8 +413,16 @@ export function expandWireContentType(wire: unknown, options: WireOptions = {}):
|
|
|
412
413
|
if (!isPlainObject(seed)) return seed === undefined ? out : { ...out, seed };
|
|
413
414
|
const { values, ...fixed } = seed;
|
|
414
415
|
if (fixed['count'] === 0 && emptyList(values)) return out;
|
|
415
|
-
const
|
|
416
|
-
|
|
416
|
+
const perItem: Record<string, unknown> = {};
|
|
417
|
+
const plain = Array.isArray(values) ? values.map(row => {
|
|
418
|
+
if (!isPlainObject(row)) return row;
|
|
419
|
+
const { each, ...rest } = row;
|
|
420
|
+
if (Array.isArray(each) && each.length) perItem[String(rest['field'])] = each;
|
|
421
|
+
return rest;
|
|
422
|
+
}) : values;
|
|
423
|
+
const map = listToMap(plain ?? [], 'field', 'value');
|
|
424
|
+
const items = Object.keys(perItem).length ? { perItem } : {};
|
|
425
|
+
return { ...out, seed: isPlainObject(map) ? { ...fixed, ...items, ...map } : { ...fixed, values } };
|
|
417
426
|
}
|
|
418
427
|
|
|
419
428
|
export function expandWireDataSource(wire: unknown): DataSourcePlan | unknown {
|
|
@@ -501,10 +510,10 @@ export function fieldToWire(field: ContentFieldPlan): PlainObject {
|
|
|
501
510
|
export function contentTypeToWire(type: ContentTypePlan, options: WireOptions = {}): PlainObject {
|
|
502
511
|
const played = type.roles as Readonly<Record<string, string | undefined>>;
|
|
503
512
|
const roles = ROLE_KEYS.filter(role => played[role]).map(role => ({ role, field: played[role] }));
|
|
504
|
-
const { count = 0, sample = false, isPublished = false, ...values } = type.seed ?? {};
|
|
513
|
+
const { count = 0, sample = false, isPublished = false, perItem = {}, ...values } = type.seed ?? {};
|
|
505
514
|
return {
|
|
506
515
|
name: type.name, label: type.label, roles, ...(options.children === false ? {} : { fields: type.fields.map(fieldToWire) }),
|
|
507
|
-
seed: { count, sample, isPublished, values: mapToList(values, 'field', 'value') },
|
|
516
|
+
seed: { count, sample, isPublished, values: mapToList(values, 'field', 'value').map(row => ({ ...row, each: perItem[String(row['field'])] ?? [] })) },
|
|
508
517
|
};
|
|
509
518
|
}
|
|
510
519
|
|
|
@@ -45,7 +45,9 @@ export function writtenTexts(plan: SitePlan): WrittenText[] {
|
|
|
45
45
|
});
|
|
46
46
|
plan.contentTypes?.forEach((type, t) => {
|
|
47
47
|
add(`contentTypes[${t}].label`, type.label);
|
|
48
|
-
|
|
48
|
+
const { perItem, ...seed }: Record<string, unknown> & { perItem?: Readonly<Record<string, ReadonlyArray<string>>> } = type.seed ?? {};
|
|
49
|
+
for (const [field, list] of Object.entries(perItem ?? {})) list.forEach((v, k) => add(`contentTypes[${t}].seed.perItem.${field}[${k}]`, v));
|
|
50
|
+
for (const [field, value] of Object.entries(seed)) {
|
|
49
51
|
const values = Array.isArray(value) ? value : [value];
|
|
50
52
|
values.forEach((v, k) => add(`contentTypes[${t}].seed.${field}${Array.isArray(value) ? `[${k}]` : ''}`, v));
|
|
51
53
|
}
|
|
@@ -79,6 +79,8 @@ export interface ContentSeedPlan {
|
|
|
79
79
|
readonly count: number;
|
|
80
80
|
readonly sample: boolean;
|
|
81
81
|
readonly isPublished: boolean;
|
|
82
|
+
/** Per field, one value per sample: real content where the items are about a subject anyone can know. Wins over the field's template. */
|
|
83
|
+
readonly perItem?: Readonly<Record<string, ReadonlyArray<string>>>;
|
|
82
84
|
readonly [field: string]: unknown;
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -69,6 +69,7 @@ export const SPEC_SITE_PLAN_SHAPE = {
|
|
|
69
69
|
"count": "integer ≥ 0",
|
|
70
70
|
"sample": "boolean — items are flagged isSample",
|
|
71
71
|
"isPublished": "boolean",
|
|
72
|
+
"perItem?": "per field, one string per sample: real content that wins over the template",
|
|
72
73
|
"[field]?": "the field's value — strings are templates: {n} = the item's 1-based number, {sampleImage:n} / {sampleImage:n+k} = a bundled sample image"
|
|
73
74
|
},
|
|
74
75
|
"cmsDataSource": {
|
|
@@ -103,6 +103,7 @@ const ROLE_KEYS = shapeKeys(SHAPE['roles']);
|
|
|
103
103
|
const PAGE_SEO_KEYS = shapeKeys(SHAPE['pageSeo']);
|
|
104
104
|
const ARRAY_TYPE = 'array';
|
|
105
105
|
const FIELD_KEYS = shapeKeys(SHAPE['field']);
|
|
106
|
+
const SLUG_FIELD = 'slug';
|
|
106
107
|
const SEED_FIXED_KEYS = shapeKeys(SHAPE['seed']);
|
|
107
108
|
const CMS_SOURCE_KEYS = shapeKeys(SHAPE['cmsDataSource']);
|
|
108
109
|
const RELATIVE_SOURCE_KEYS = shapeKeys(SHAPE['relativeDataSource']);
|
|
@@ -226,6 +227,16 @@ function validateContentTypes(c: Collector, list: unknown[]): Map<string, Conten
|
|
|
226
227
|
else {
|
|
227
228
|
if (!Number.isInteger(seed['count']) || (seed['count'] as number) < 0) c.add(SitePlanIssueCode.Shape, `${path}.seed.count`, 'must be a whole number');
|
|
228
229
|
for (const key of ['sample', 'isPublished']) if (typeof seed[key] !== 'boolean') c.add(SitePlanIssueCode.Shape, `${path}.seed.${key}`, 'must be a boolean');
|
|
230
|
+
const perItem = seed['perItem'];
|
|
231
|
+
if (perItem !== undefined) {
|
|
232
|
+
if (!isObject(perItem)) c.add(SitePlanIssueCode.Shape, `${path}.seed.perItem`, 'must be an object');
|
|
233
|
+
else for (const [field, list] of Object.entries(perItem)) {
|
|
234
|
+
const at = `${path}.seed.perItem.${field}`;
|
|
235
|
+
if (!fieldSet.has(field)) c.add(SitePlanIssueCode.UnknownField, at, `"${field}" is not a field of ${name}`);
|
|
236
|
+
else if (!Array.isArray(list) || list.length !== seed['count'] || list.some(v => typeof v !== 'string')) c.add(SitePlanIssueCode.Shape, at, `must be ${String(seed['count'])} strings, one per sample`);
|
|
237
|
+
else if (field === SLUG_FIELD && new Set(list).size !== list.length) c.add(SitePlanIssueCode.Shape, at, 'each sample needs its own slug');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
229
240
|
for (const key of Object.keys(seed)) {
|
|
230
241
|
if (!SEED_FIXED_KEYS.includes(key) && !fieldSet.has(key)) c.add(SitePlanIssueCode.UnknownField, `${path}.seed.${key}`, `"${key}" is not a field of ${name}`);
|
|
231
242
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vectoriox/iox-ui/scene-core — a Scene: a stage that holds still while the page scrolls past it, its layers moving on
|
|
3
|
+
* ONE progress value (premium-sites plan §5b). FRAMEWORK-FREE: the builder canvas, both engine paths and (from SC6) the
|
|
4
|
+
* composer read the same tracks, frames and recipe; the driver is DOM-only and is the ONE scroll loop.
|
|
5
|
+
* ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig paths.
|
|
6
|
+
*/
|
|
7
|
+
export * from './scene';
|
|
8
|
+
export * from './scene-recipe';
|
|
9
|
+
export * from './scroll-driver';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { CompositeNode, CompositeNodeFactory, makeCompositePart, NO_ELEMENT_STYLE, SceneRole, writeLayerStyles, writeStyles } from '@vectoriox/iox-ui/composite-core';
|
|
2
|
+
|
|
3
|
+
import { SceneNodeSource, SceneProp, SceneTrack } from './scene';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The SCENE recipe: ordinary nodes, no new type, so all three render paths and every panel already know it.
|
|
7
|
+
*
|
|
8
|
+
* Section role scene height = how long it plays (× the viewport)
|
|
9
|
+
* └─ Container role scene.stage pinned, one viewport tall, clips its layers, the size container for their lengths
|
|
10
|
+
* ├─ Heading a layer with its own tracks
|
|
11
|
+
* └─ Text a layer with its own tracks
|
|
12
|
+
*
|
|
13
|
+
* How long it plays IS the root's height, so it follows the width system the way every other size does. The stage is a
|
|
14
|
+
* track-carrying layer too: its Ground is the colour the whole Scene moves through.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type SceneLayerNode = CompositeNode & SceneNodeSource;
|
|
18
|
+
|
|
19
|
+
export interface SceneWords {
|
|
20
|
+
title: string;
|
|
21
|
+
line: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Generic words a fresh Scene starts with: the author's to replace. */
|
|
25
|
+
export const SCENE_WORDS: Readonly<SceneWords> = {
|
|
26
|
+
title: 'One idea, held still',
|
|
27
|
+
line: 'The page keeps moving. This stays until you have read it.',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** How long a fresh Scene plays: 4 viewports wide, 3 on a phone (a thumb travels less). */
|
|
31
|
+
export const SCENE_LENGTH = '400vh';
|
|
32
|
+
export const SCENE_PHONE_LENGTH = '300vh';
|
|
33
|
+
/** Small viewport height: a phone's toolbar never pushes the stage's bottom out of sight. */
|
|
34
|
+
export const SCENE_STAGE_HEIGHT = '100svh';
|
|
35
|
+
const PHONE_BREAKPOINT = 'mobile';
|
|
36
|
+
|
|
37
|
+
enum Type {
|
|
38
|
+
Section = 'Section',
|
|
39
|
+
Container = 'Container',
|
|
40
|
+
Heading = 'Heading',
|
|
41
|
+
Text = 'Text',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ROOT_LAYOUT: Readonly<Record<string, string>> = {
|
|
45
|
+
position: 'relative',
|
|
46
|
+
display: 'block',
|
|
47
|
+
width: '100%',
|
|
48
|
+
height: SCENE_LENGTH,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const STAGE_LAYOUT: Readonly<Record<string, string>> = {
|
|
52
|
+
position: 'sticky',
|
|
53
|
+
top: '0px',
|
|
54
|
+
display: 'flex',
|
|
55
|
+
flexDirection: 'column',
|
|
56
|
+
alignItems: 'center',
|
|
57
|
+
justifyContent: 'center',
|
|
58
|
+
width: '100%',
|
|
59
|
+
height: SCENE_STAGE_HEIGHT,
|
|
60
|
+
overflow: 'hidden',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const TITLE_TRACKS: ReadonlyArray<SceneTrack> = [
|
|
64
|
+
{ prop: SceneProp.Opacity, keys: [[0, 0], [0.2, 1], [0.75, 1], [1, 0]] },
|
|
65
|
+
{ prop: SceneProp.Y, keys: [[0, 8], [0.2, 0], [1, -6]] },
|
|
66
|
+
{ prop: SceneProp.Scale, keys: [[0, 0.96], [0.6, 1], [1, 1.04]] },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const LINE_TRACKS: ReadonlyArray<SceneTrack> = [
|
|
70
|
+
{ prop: SceneProp.Opacity, keys: [[0.25, 0], [0.45, 1], [0.8, 1], [1, 0]] },
|
|
71
|
+
{ prop: SceneProp.Y, keys: [[0.25, 6], [0.45, 0], [1, -4]] },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const STAGE_TRACKS: ReadonlyArray<SceneTrack> = [
|
|
75
|
+
{ prop: SceneProp.Ground, keys: [[0, 'var(--iox-surface-2)'], [0.6, 'var(--iox-page-bg)']] },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const copyTracks = (tracks: ReadonlyArray<SceneTrack>): SceneTrack[] => tracks.map(t => ({ prop: t.prop, keys: t.keys.map(k => [k[0], k[1]] as const) }));
|
|
79
|
+
|
|
80
|
+
/** A fresh Scene: it already moves, so the author sees what the thing is before touching it. */
|
|
81
|
+
export function buildSceneRecipe(create: CompositeNodeFactory, words: Partial<SceneWords> = {}): SceneLayerNode {
|
|
82
|
+
const text = { ...SCENE_WORDS, ...words };
|
|
83
|
+
const root = makeCompositePart(create, { key: 'root', type: Type.Section, elementStyle: NO_ELEMENT_STYLE }, SceneRole.Scene, 'Scene', ROOT_LAYOUT) as SceneLayerNode;
|
|
84
|
+
writeLayerStyles(root, PHONE_BREAKPOINT, { height: SCENE_PHONE_LENGTH });
|
|
85
|
+
|
|
86
|
+
const stage = makeCompositePart(create, { key: 'stage', type: Type.Container, elementStyle: NO_ELEMENT_STYLE }, SceneRole.Stage, 'Stage', STAGE_LAYOUT) as SceneLayerNode;
|
|
87
|
+
stage.sceneTracks = copyTracks(STAGE_TRACKS);
|
|
88
|
+
|
|
89
|
+
const title = makeCompositePart(create, { key: 'title', type: Type.Heading, props: { text: text.title, tag: 'h2' } }, null, 'Title') as SceneLayerNode;
|
|
90
|
+
title.sceneTracks = copyTracks(TITLE_TRACKS);
|
|
91
|
+
writeStyles(title, { textAlign: 'center' });
|
|
92
|
+
|
|
93
|
+
const line = makeCompositePart(create, { key: 'line', type: Type.Text, props: { text: text.line } }, null, 'Line') as SceneLayerNode;
|
|
94
|
+
line.sceneTracks = copyTracks(LINE_TRACKS);
|
|
95
|
+
writeStyles(line, { textAlign: 'center' });
|
|
96
|
+
|
|
97
|
+
stage.children = [title, line];
|
|
98
|
+
root.children = [stage];
|
|
99
|
+
return root;
|
|
100
|
+
}
|