kuinetic 0.1.2 → 0.1.4

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 (31) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/{chunk-WPU33HDL.mjs → chunk-7WMNPIOZ.mjs} +1724 -240
  3. package/dist/esm/{chunk-LWS4OSLX.mjs → chunk-JT4PZL3A.mjs} +50 -7
  4. package/dist/esm/{chunk-5EVG7CIY.mjs → chunk-TJDDIQRG.mjs} +42 -16
  5. package/dist/esm/core/index.mjs +2 -2
  6. package/dist/esm/effects/index.mjs +2 -2
  7. package/dist/esm/index.mjs +3 -3
  8. package/dist/kuinetic.all.js +1813 -262
  9. package/dist/kuinetic.css +1491 -94
  10. package/dist/kuinetic.js +1812 -261
  11. package/dist/types/core/instances.d.ts +34 -3
  12. package/dist/types/core/path-morph.d.ts +19 -0
  13. package/dist/types/core/style-plan.d.ts +6 -1
  14. package/dist/types/core/types.d.ts +99 -1
  15. package/dist/types/effects/catalog/background-media.d.ts +152 -0
  16. package/dist/types/effects/catalog/index.d.ts +0 -6
  17. package/dist/types/effects/catalog/media-shared.d.ts +124 -0
  18. package/dist/types/effects/catalog/media.d.ts +4 -0
  19. package/dist/types/effects/catalog/numbers-shared.d.ts +5 -1
  20. package/dist/types/effects/catalog/shared.d.ts +2 -3
  21. package/dist/types/effects/catalog/subtree-capture.d.ts +23 -0
  22. package/dist/types/effects/catalog/text-shared.d.ts +11 -2
  23. package/dist/types/effects/gestures/index.d.ts +0 -1
  24. package/dist/types/effects/layout/index.d.ts +0 -2
  25. package/dist/types/effects/scroll-mechanics/index.d.ts +0 -4
  26. package/dist/types/effects/scroll-mechanics/scroll-spy.d.ts +26 -0
  27. package/dist/types/effects/scroll-mechanics/tracker.d.ts +61 -3
  28. package/dist/types/effects/shared.d.ts +40 -0
  29. package/dist/types/effects/step-marking.d.ts +99 -0
  30. package/dist/types/effects/three-d/index.d.ts +0 -7
  31. package/package.json +9 -2
@@ -3,6 +3,7 @@ import {
3
3
  CHANNEL,
4
4
  Registry,
5
5
  clamp01,
6
+ continuousSetup,
6
7
  createAttributeLedger,
7
8
  createMeasureCache,
8
9
  createStyleLedger,
@@ -11,12 +12,16 @@ import {
11
12
  inertInstance,
12
13
  isSameOriginPath,
13
14
  toPixels
14
- } from "./chunk-LWS4OSLX.mjs";
15
+ } from "./chunk-JT4PZL3A.mjs";
15
16
 
16
17
  // src/effects/shared.ts
18
+ var TRIGGER_DELAY_PARAM = {
19
+ delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" }
20
+ };
21
+ var TIMELINE_AGNOSTIC = ["time", "view", "scroll", "pin"];
17
22
  var COMMON = {
18
23
  duration: { type: "time", default: "600ms", cssProperty: "--kui-duration" },
19
- delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" },
24
+ ...TRIGGER_DELAY_PARAM,
20
25
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
21
26
  stagger: { type: "time", default: "0ms", cssProperty: "--kui-stagger" }
22
27
  };
@@ -44,13 +49,27 @@ function cssPrimitive(id, channels, options = {}) {
44
49
  // src/effects/catalog/ambient.ts
45
50
  var drift = {
46
51
  duration: { type: "time", default: "10s", cssProperty: "--kui-duration" },
47
- ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" }
52
+ ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
53
+ from: { type: "color", default: "", cssProperty: "--kui-ambient-c1" },
54
+ to: { type: "color", default: "", cssProperty: "--kui-ambient-c2" }
55
+ };
56
+ var tint = {
57
+ duration: { type: "time", default: "10s", cssProperty: "--kui-duration" },
58
+ ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
59
+ from: { type: "color", default: "", cssProperty: "--kui-ambient-c1" }
48
60
  };
49
61
  var float = {
50
62
  duration: { type: "time", default: "4s", cssProperty: "--kui-duration" },
51
63
  ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
52
64
  distance: { type: "length", default: "14px", cssProperty: "--kui-distance" }
53
65
  };
66
+ var orbit = {
67
+ duration: { type: "time", default: "3.5s", cssProperty: "--kui-duration" },
68
+ // `linear` by default, unlike every other ambient primitive: a continuous rotation that eases
69
+ // visibly stutters once per revolution, because the ease restarts at each iteration boundary.
70
+ ease: { type: "easing", default: "linear", cssProperty: "--kui-ease" },
71
+ angle: { type: "angle", default: "360deg", cssProperty: "--kui-to-angle" }
72
+ };
54
73
  var pulse = {
55
74
  duration: { type: "time", default: "2.2s", cssProperty: "--kui-duration" },
56
75
  ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
@@ -63,12 +82,46 @@ var AMBIENT_PRIMITIVES = [
63
82
  reducedMotion: "disable",
64
83
  perfClass: "continuous"
65
84
  }),
85
+ /*
86
+ * `gradient-rotate-border` and `gradient-border` are not background fills, and a second
87
+ * primitive is how this catalog says so — the same reason `ambient-tint` exists beside
88
+ * `ambient-gradient` above. Channels are per-primitive, so a different channel set means a
89
+ * different primitive; folding these into `ambient-gradient` would make `gradient-mesh` and
90
+ * `aurora` claim a mask and a box they never touch, and `aurora, pin` would start reporting a
91
+ * conflict that isn't there.
92
+ *
93
+ * What the ring rules actually write (`ambient.css`) beyond the gradient: `mask` +
94
+ * `mask-composite`, which subtract the element's own content box to leave a ring — the same
95
+ * physical property `media-mask` claims under the `'mask'` channel — and `position: relative`
96
+ * plus a `padding` that *is* the ring's thickness, which is a claim on the host's box in the
97
+ * sense `pin` and `background-media` already use `'layout'` for. Declared only as
98
+ * `background`, a `gradient-border, pin-section` pair composed silently while both decided the
99
+ * host's `position`, and `gradient-border, mask-reveal` while both wrote `mask`.
100
+ */
101
+ cssPrimitive("ambient-gradient-ring", [CHANNEL.background, "mask", "layout"], {
102
+ parameters: drift,
103
+ defaultActivation: "load",
104
+ reducedMotion: "disable",
105
+ perfClass: "continuous"
106
+ }),
107
+ cssPrimitive("ambient-tint", [CHANNEL.background], {
108
+ parameters: tint,
109
+ defaultActivation: "load",
110
+ reducedMotion: "disable",
111
+ perfClass: "continuous"
112
+ }),
66
113
  cssPrimitive("ambient-float", [CHANNEL.translate], {
67
114
  parameters: float,
68
115
  defaultActivation: "load",
69
116
  reducedMotion: "disable",
70
117
  perfClass: "continuous"
71
118
  }),
119
+ cssPrimitive("ambient-orbit", [CHANNEL.rotate], {
120
+ parameters: orbit,
121
+ defaultActivation: "load",
122
+ reducedMotion: "disable",
123
+ perfClass: "continuous"
124
+ }),
72
125
  cssPrimitive("ambient-pulse", [CHANNEL.scale, CHANNEL.opacity], {
73
126
  parameters: pulse,
74
127
  defaultActivation: "load",
@@ -81,49 +134,58 @@ var AMBIENT_PRESETS = [
81
134
  { name: "aurora", primitive: "ambient-gradient", keyframes: "kui-aurora" },
82
135
  {
83
136
  name: "gradient-rotate-border",
84
- primitive: "ambient-gradient",
137
+ primitive: "ambient-gradient-ring",
85
138
  keyframes: "kui-gradient-rotate-border",
86
139
  params: { duration: "6s", ease: "linear" }
87
140
  },
141
+ {
142
+ // Not `gradient`: this rule masks its own content box away (`mask-composite: exclude`) to leave
143
+ // a ring, so putting the name on real content deletes the content. `-border` says that out loud,
144
+ // matching `gradient-rotate-border` and `beam-border`.
145
+ name: "gradient-border",
146
+ primitive: "ambient-gradient-ring",
147
+ keyframes: "kui-gradient-border",
148
+ params: { duration: "6s", ease: "linear" }
149
+ },
88
150
  {
89
151
  name: "noise-overlay",
90
- primitive: "ambient-gradient",
152
+ primitive: "ambient-tint",
91
153
  keyframes: "kui-noise-overlay",
92
154
  params: { duration: "650ms", ease: "steps(6)" }
93
155
  },
94
156
  {
95
157
  name: "scanline",
96
- primitive: "ambient-gradient",
158
+ primitive: "ambient-tint",
97
159
  keyframes: "kui-scanline",
98
160
  params: { duration: "3.5s", ease: "linear" }
99
161
  },
100
162
  {
101
163
  name: "dot-grid-drift",
102
- primitive: "ambient-gradient",
164
+ primitive: "ambient-tint",
103
165
  keyframes: "kui-dot-grid-drift",
104
166
  params: { duration: "16s", ease: "linear" }
105
167
  },
106
168
  {
107
169
  name: "line-grid-drift",
108
- primitive: "ambient-gradient",
170
+ primitive: "ambient-tint",
109
171
  keyframes: "kui-line-grid-drift",
110
172
  params: { duration: "16s", ease: "linear" }
111
173
  },
112
174
  {
113
175
  name: "starfield",
114
- primitive: "ambient-gradient",
176
+ primitive: "ambient-tint",
115
177
  keyframes: "kui-starfield",
116
178
  params: { duration: "40s", ease: "linear" }
117
179
  },
118
180
  {
119
181
  name: "spotlight-follow",
120
- primitive: "ambient-gradient",
182
+ primitive: "ambient-tint",
121
183
  keyframes: "kui-spotlight-follow",
122
184
  params: { duration: "9s" }
123
185
  },
124
186
  {
125
187
  name: "wave-blob",
126
- primitive: "ambient-gradient",
188
+ primitive: "ambient-tint",
127
189
  keyframes: "kui-wave-blob",
128
190
  params: { duration: "12s" }
129
191
  },
@@ -140,6 +202,7 @@ var AMBIENT_PRESETS = [
140
202
  keyframes: "kui-floating-shapes",
141
203
  params: { duration: "6s", distance: "10px" }
142
204
  },
205
+ { name: "orbit", primitive: "ambient-orbit", keyframes: "kui-orbit" },
143
206
  { name: "glow-pulse", primitive: "ambient-pulse", keyframes: "kui-glow-pulse" }
144
207
  ];
145
208
  function registerAmbient(registry) {
@@ -456,6 +519,13 @@ var hoverTiming = {
456
519
  var liftParams = {
457
520
  distance: { type: "length", default: "6px", cssProperty: "--kui-lift-distance" }
458
521
  };
522
+ var popParams = {
523
+ scale: { type: "number", default: "1.06", cssProperty: "--kui-pop-scale", finite: true, minimum: 0 }
524
+ };
525
+ var beamParams = {
526
+ color: { type: "color", default: "", cssProperty: "--kui-beam-border-c1" },
527
+ outset: { type: "length", default: "", cssProperty: "--kui-beam-border-outset" }
528
+ };
459
529
  function hoverPrimitive(id, channels, extraParams = {}) {
460
530
  return {
461
531
  id,
@@ -477,12 +547,13 @@ function hoverPrimitive(id, channels, extraParams = {}) {
477
547
  }
478
548
  var HOVER_PRIMITIVES = [
479
549
  hoverPrimitive("lift", ["translate"], liftParams),
550
+ hoverPrimitive("pop", ["scale"], popParams),
480
551
  hoverPrimitive("lift-shadow", ["translate", "shadow"], liftParams),
481
552
  hoverPrimitive("shine-sweep", ["sweep"]),
482
553
  hoverPrimitive("split-flap", ["rotate"]),
483
554
  hoverPrimitive("border-draw", ["border"]),
484
555
  hoverPrimitive("border-glow", ["shadow"]),
485
- hoverPrimitive("beam-border", ["border"]),
556
+ hoverPrimitive("beam-border", ["border"], beamParams),
486
557
  hoverPrimitive("underline-slide", ["scale"]),
487
558
  hoverPrimitive("underline-center", ["scale"]),
488
559
  hoverPrimitive("icon-wiggle", ["rotate"]),
@@ -498,7 +569,7 @@ var CONTINUOUS_BORDER_PRIMITIVES = [
498
569
  id: "beam-border-auto",
499
570
  renderer: "javascript",
500
571
  channels: ["border"],
501
- parameters: hoverTiming,
572
+ parameters: { ...hoverTiming, ...beamParams },
502
573
  supportedTimelines: ["time"],
503
574
  supportedActivations: ["load"],
504
575
  defaultActivation: "load",
@@ -787,12 +858,344 @@ function registerInteraction(registry) {
787
858
  return registry.registerPrimitives(INTERACTION_PRIMITIVES).registerPresets(INTERACTION_PRESETS);
788
859
  }
789
860
 
861
+ // src/effects/catalog/background-media.ts
862
+ var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
863
+ var VIDEO_PAGE_HOSTS = /* @__PURE__ */ new Set([
864
+ "youtube.com",
865
+ "www.youtube.com",
866
+ "m.youtube.com",
867
+ "youtube-nocookie.com",
868
+ "www.youtube-nocookie.com",
869
+ "youtu.be",
870
+ "www.youtu.be"
871
+ ]);
872
+ function normalizeUrl(value) {
873
+ const stripped = value.replace(/[\t\n\r]/g, "");
874
+ let start = 0;
875
+ while (start < stripped.length && stripped.charCodeAt(start) <= 32) start += 1;
876
+ return stripped.slice(start);
877
+ }
878
+ function hostOf(value) {
879
+ const withoutScheme = normalizeUrl(value).replace(/^[a-z][a-z0-9+.-]*:/i, "").replace(/^\/\//, "");
880
+ const end = withoutScheme.search(/[/?#]/);
881
+ const authority = end === -1 ? withoutScheme : withoutScheme.slice(0, end);
882
+ return authority.slice(authority.lastIndexOf("@") + 1).toLowerCase();
883
+ }
884
+ function isVideoPageUrl(value) {
885
+ return VIDEO_PAGE_HOSTS.has(hostOf(value));
886
+ }
887
+ var FOCAL_POINTS = {
888
+ center: "50% 50%",
889
+ top: "50% 0%",
890
+ bottom: "50% 100%",
891
+ left: "0% 50%",
892
+ right: "100% 50%",
893
+ "top-left": "0% 0%",
894
+ "top-right": "100% 0%",
895
+ "bottom-left": "0% 100%",
896
+ "bottom-right": "100% 100%"
897
+ };
898
+ var FOCAL_POINT_NAMES = Object.keys(FOCAL_POINTS);
899
+ function focalPosition(focus) {
900
+ return FOCAL_POINTS[focus] ?? FOCAL_POINTS.center;
901
+ }
902
+ function paintsOverlay(options) {
903
+ return options.overlay !== "transparent" && options.overlayOpacity > 0;
904
+ }
905
+ function isVideoSource(src) {
906
+ const path = src.split("?")[0].split("#")[0];
907
+ const dot = path.lastIndexOf(".");
908
+ if (dot <= path.lastIndexOf("/")) return false;
909
+ return VIDEO_EXTENSIONS.has(path.slice(dot + 1).toLowerCase());
910
+ }
911
+ var ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["http", "https"]);
912
+ function schemeOf(value) {
913
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec(normalizeUrl(value));
914
+ return match ? match[1].toLowerCase() : "";
915
+ }
916
+ function mediaSource(authored, name, ctx) {
917
+ if (!authored) return authored;
918
+ if (isVideoPageUrl(authored)) {
919
+ ctx.warn(
920
+ `background-media "${name}": "${authored}" is a video *page*, not a media file \u2014 a YouTube URL cannot be played by <video> and needs an iframe embed instead. Point "${name}" at an .mp4/.webm file, or use a YouTube facade alongside this effect.`
921
+ );
922
+ return "";
923
+ }
924
+ const scheme = schemeOf(authored);
925
+ if (!scheme || ALLOWED_SCHEMES.has(scheme)) return authored;
926
+ ctx.warn(
927
+ `background-media "${name}": "${scheme}:" URLs are not allowed \u2014 use https:, http:, or a path such as "/media/hero.mp4".`
928
+ );
929
+ return "";
930
+ }
931
+ function styleLayer(node) {
932
+ const { style } = node;
933
+ style.setProperty("position", "absolute");
934
+ style.setProperty("top", "0");
935
+ style.setProperty("left", "0");
936
+ style.setProperty("width", "100%");
937
+ style.setProperty("height", "100%");
938
+ style.setProperty("border-radius", "inherit");
939
+ style.setProperty("pointer-events", "none");
940
+ style.setProperty("z-index", "-1");
941
+ }
942
+ function createOverlay(doc, options) {
943
+ const scrim = doc.createElement("div");
944
+ styleLayer(scrim);
945
+ scrim.style.setProperty("background", options.overlay);
946
+ scrim.style.setProperty("opacity", String(options.overlayOpacity));
947
+ scrim.setAttribute("aria-hidden", "true");
948
+ return scrim;
949
+ }
950
+ function createVideo(doc, options) {
951
+ const video = doc.createElement("video");
952
+ video.muted = true;
953
+ video.setAttribute("muted", "");
954
+ video.playsInline = true;
955
+ video.setAttribute("playsinline", "");
956
+ video.loop = options.loop;
957
+ video.defaultPlaybackRate = options.rate;
958
+ video.playbackRate = options.rate;
959
+ video.preload = "metadata";
960
+ if (options.poster) video.poster = options.poster;
961
+ video.src = options.src;
962
+ return video;
963
+ }
964
+ function play(video) {
965
+ const started = video.play();
966
+ if (started) void started.catch(() => {
967
+ });
968
+ }
969
+ function startPlayback(video, win, options) {
970
+ if (options.reducedMotion || options.autoplay === "never") return () => {
971
+ };
972
+ if (options.autoplay === "always") {
973
+ play(video);
974
+ return () => {
975
+ if (!video.paused) video.pause();
976
+ };
977
+ }
978
+ return autoplayInView(video, win);
979
+ }
980
+ function createImage(doc, options) {
981
+ const image = doc.createElement("img");
982
+ image.alt = "";
983
+ image.decoding = "async";
984
+ image.src = options.src;
985
+ return image;
986
+ }
987
+ function autoplayInView(video, win) {
988
+ const Observer = win.IntersectionObserver;
989
+ if (!Observer) return () => {
990
+ };
991
+ const observer = new Observer(
992
+ (entries) => {
993
+ for (const entry of entries) {
994
+ if (entry.isIntersecting) play(video);
995
+ else if (!video.paused) video.pause();
996
+ }
997
+ },
998
+ { threshold: 0 }
999
+ );
1000
+ observer.observe(video);
1001
+ return () => {
1002
+ observer.disconnect();
1003
+ if (!video.paused) video.pause();
1004
+ };
1005
+ }
1006
+ function installBackgroundMedia(el, ctx, options) {
1007
+ const doc = el.ownerDocument;
1008
+ const video = isVideoSource(options.src) ? createVideo(doc, options) : null;
1009
+ const node = video ?? createImage(doc, options);
1010
+ styleLayer(node);
1011
+ node.style.setProperty("object-fit", options.fit);
1012
+ node.style.setProperty("object-position", options.position);
1013
+ node.setAttribute("aria-hidden", "true");
1014
+ node.setAttribute("data-kui-background", "");
1015
+ el.append(node);
1016
+ const overlay = paintsOverlay(options) ? createOverlay(doc, options) : null;
1017
+ if (overlay) {
1018
+ overlay.setAttribute("data-kui-background-overlay", "");
1019
+ el.append(overlay);
1020
+ }
1021
+ const stopPlayback = video ? startPlayback(video, ctx.win, options) : () => {
1022
+ };
1023
+ return {
1024
+ remove: () => {
1025
+ stopPlayback();
1026
+ node.remove();
1027
+ overlay?.remove();
1028
+ }
1029
+ };
1030
+ }
1031
+
1032
+ // src/effects/catalog/media-shared.ts
1033
+ var AXIS_DEGREES = { vertical: 0, horizontal: 90 };
1034
+ var UNIT_DEGREES = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 };
1035
+ var BAND_OVERLAP_PX = 1;
1036
+ var STAGE_CLASS = "kui-slat-stage";
1037
+ var SLAT_CLASS = "kui-slat-item";
1038
+ var GOLDEN_RATIO_CONJUGATE = 0.6180339887498949;
1039
+ function slatOrder(index, count, from) {
1040
+ if (count <= 1) return 0;
1041
+ switch (from) {
1042
+ case "start":
1043
+ return index;
1044
+ case "end":
1045
+ return count - 1 - index;
1046
+ case "edges":
1047
+ return Math.min(index, count - 1 - index);
1048
+ case "random-ish":
1049
+ return Math.floor(index * GOLDEN_RATIO_CONJUGATE % 1 * count);
1050
+ case "alternate":
1051
+ default:
1052
+ return zigzagRank(index, count);
1053
+ }
1054
+ }
1055
+ function zigzagRank(index, count) {
1056
+ const fromStart = index;
1057
+ const fromEnd = count - 1 - index;
1058
+ const pair = Math.min(fromStart, fromEnd);
1059
+ return fromStart <= fromEnd ? pair * 2 : pair * 2 + 1;
1060
+ }
1061
+ function slatAngleDegrees(authored, axis) {
1062
+ const trimmed = authored.trim();
1063
+ if (!trimmed) return AXIS_DEGREES[axis];
1064
+ const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(deg|rad|grad|turn)?$/.exec(trimmed);
1065
+ if (!match) return AXIS_DEGREES[axis];
1066
+ const value = Number(match[1]);
1067
+ if (!Number.isFinite(value)) return AXIS_DEGREES[axis];
1068
+ const unit = match[2] ?? "deg";
1069
+ const degrees = value * UNIT_DEGREES[unit];
1070
+ return (degrees % 180 + 180) % 180;
1071
+ }
1072
+ function slatTravelVector(angleDegrees) {
1073
+ const theta = angleDegrees * Math.PI / 180;
1074
+ return { x: -Math.sin(theta), y: Math.cos(theta) };
1075
+ }
1076
+ function slatBandClip(index, count, angleDegrees, box) {
1077
+ const { width, height } = box;
1078
+ const theta = angleDegrees * Math.PI / 180;
1079
+ const nx = Math.cos(theta);
1080
+ const ny = Math.sin(theta);
1081
+ const { x: dx, y: dy } = slatTravelVector(angleDegrees);
1082
+ const span = Math.abs(nx) * width + Math.abs(ny) * height;
1083
+ const centreX = width / 2;
1084
+ const centreY = height / 2;
1085
+ const step = span / count;
1086
+ const near = index * step - span / 2 - BAND_OVERLAP_PX;
1087
+ const far = (index + 1) * step - span / 2 + BAND_OVERLAP_PX;
1088
+ const reach = width + height;
1089
+ const corner = (along, across) => `${(centreX + along * nx + across * dx).toFixed(2)}px ${(centreY + along * ny + across * dy).toFixed(2)}px`;
1090
+ return `polygon(${corner(near, reach)}, ${corner(near, -reach)}, ${corner(far, -reach)}, ${corner(far, reach)})`;
1091
+ }
1092
+ function axisLabel(angleDegrees) {
1093
+ if (angleDegrees === 0) return "vertical";
1094
+ if (angleDegrees === 90) return "horizontal";
1095
+ return "diagonal";
1096
+ }
1097
+ function syncStageToImage(stage, img, bands) {
1098
+ const { slats, angleDegrees } = bands;
1099
+ const width = img.offsetWidth;
1100
+ const height = img.offsetHeight;
1101
+ stage.style.top = `${img.offsetTop}px`;
1102
+ stage.style.left = `${img.offsetLeft}px`;
1103
+ stage.style.width = `${width}px`;
1104
+ stage.style.height = `${height}px`;
1105
+ slats.forEach((slat, index) => {
1106
+ slat.style.clipPath = slatBandClip(index, slats.length, angleDegrees, { width, height });
1107
+ });
1108
+ }
1109
+ function watchImageBox(stage, img, win, bands) {
1110
+ const handler = () => syncStageToImage(stage, img, bands);
1111
+ win.addEventListener("resize", handler, { passive: true });
1112
+ const stopWindow = () => win.removeEventListener("resize", handler);
1113
+ const ResizeObserverCtor = win.ResizeObserver;
1114
+ if (!ResizeObserverCtor) return stopWindow;
1115
+ const observer = new ResizeObserverCtor(handler);
1116
+ observer.observe(img);
1117
+ return () => {
1118
+ stopWindow();
1119
+ observer.disconnect();
1120
+ };
1121
+ }
1122
+ function imagePaintStyle(img, win) {
1123
+ const computed = win.getComputedStyle(img);
1124
+ const position = computed.objectPosition || "50% 50%";
1125
+ const fit = computed.objectFit;
1126
+ if (fit === "cover") return { size: "cover", position };
1127
+ if (fit === "contain" || fit === "scale-down") return { size: "contain", position };
1128
+ if (fit === "none") return { size: "auto", position };
1129
+ return { size: "100% 100%", position };
1130
+ }
1131
+ function installSlatStage(el, doc, win, options) {
1132
+ const img = el.querySelector("img");
1133
+ if (!img) return null;
1134
+ const url = img.currentSrc || img.getAttribute("src") || "";
1135
+ if (!url) return null;
1136
+ const { count, angleDegrees, from, fold } = options;
1137
+ const travel = slatTravelVector(angleDegrees);
1138
+ const stage = doc.createElement("div");
1139
+ stage.className = STAGE_CLASS;
1140
+ stage.setAttribute("aria-hidden", "true");
1141
+ stage.dataset.kuiSlatAxis = axisLabel(angleDegrees);
1142
+ stage.dataset.kuiSlatAngle = String(angleDegrees);
1143
+ stage.dataset.kuiSlatFold = String(fold);
1144
+ stage.style.setProperty("--kui-slat-count", String(count));
1145
+ stage.style.setProperty("--kui-slat-dx", travel.x.toFixed(4));
1146
+ stage.style.setProperty("--kui-slat-dy", travel.y.toFixed(4));
1147
+ const paint = imagePaintStyle(img, win);
1148
+ const slats = [];
1149
+ for (let index = 0; index < count; index++) {
1150
+ const slat = doc.createElement("div");
1151
+ slat.className = SLAT_CLASS;
1152
+ slat.style.setProperty("--kui-slat-index", String(index));
1153
+ slat.style.setProperty("--kui-i", String(slatOrder(index, count, from)));
1154
+ slat.style.backgroundImage = `url("${url}")`;
1155
+ slat.style.backgroundSize = paint.size;
1156
+ slat.style.backgroundPosition = paint.position;
1157
+ stage.append(slat);
1158
+ slats.push(slat);
1159
+ }
1160
+ el.append(stage);
1161
+ const bands = { slats, angleDegrees };
1162
+ syncStageToImage(stage, img, bands);
1163
+ const stopWatching = watchImageBox(stage, img, win, bands);
1164
+ const imageStyles = createStyleLedger(img);
1165
+ imageStyles.set("visibility", "hidden");
1166
+ return {
1167
+ stage,
1168
+ slats,
1169
+ restore: () => {
1170
+ stopWatching();
1171
+ imageStyles.restore();
1172
+ stage.remove();
1173
+ }
1174
+ };
1175
+ }
1176
+ function applySlatTimingVars(stage, params) {
1177
+ const { durationMs, delayMs, easing } = params.timing;
1178
+ const duration = durationMs === void 0 ? params.text("duration", "500ms") : `${durationMs}ms`;
1179
+ const delay = delayMs === void 0 ? params.text("delay", "0ms") : `${delayMs}ms`;
1180
+ stage.style.setProperty("--kui-duration", duration);
1181
+ stage.style.setProperty("--kui-delay", delay);
1182
+ stage.style.setProperty("--kui-ease", easing ?? params.text("ease", "ease-out"));
1183
+ stage.style.setProperty("--kui-stagger", params.text("stagger", "60ms"));
1184
+ }
1185
+ function slatAssembleFinishMs(params, count) {
1186
+ if (count === 0) return 0;
1187
+ const durationMs = params.timing.durationMs ?? params.ms("duration", 500);
1188
+ const delayMs = params.timing.delayMs ?? params.ms("delay", 0);
1189
+ const staggerMs = params.ms("stagger", 60);
1190
+ return delayMs + (count - 1) * staggerMs + durationMs;
1191
+ }
1192
+
790
1193
  // src/effects/catalog/media.ts
791
1194
  var geometry = {
792
1195
  distance: { type: "length", default: "24px", cssProperty: "--kui-distance" },
793
1196
  scale: { type: "number", default: "1.12", cssProperty: "--kui-to-scale" }
794
1197
  };
795
- var MEDIA_PRIMITIVES = [
1198
+ var MEDIA_CSS_PRIMITIVES = [
796
1199
  cssPrimitive("media-wipe", [CHANNEL.clip]),
797
1200
  cssPrimitive("media-mask", ["mask"], { perfClass: "paint" }),
798
1201
  // Not `reducedMotion: 'disable'` — that policy means "no finite duration would make sense,
@@ -813,33 +1216,66 @@ var MEDIA_PRIMITIVES = [
813
1216
  defaultActivation: "hover",
814
1217
  perfClass: "paint"
815
1218
  }),
1219
+ // `geometry.distance` only, not `...geometry`: `kui-blur-up` (media.css) reads `--kui-distance`
1220
+ // and `--kui-blur` but never `--kui-to-scale` — this primitive doesn't even declare
1221
+ // `CHANNEL.scale`. Spreading the whole shared object used to expose `scale:` as an
1222
+ // apparently-valid, silently-inert parameter, the same shape `flip-3d`'s dead `perspective`
1223
+ // parameter was (`entrance.css`'s comment on `kui-flip-in-x`).
816
1224
  cssPrimitive("media-blur-up", [CHANNEL.translate, CHANNEL.filter], {
817
- parameters: { ...geometry, blur: { type: "length", default: "16px", cssProperty: "--kui-blur" } },
1225
+ parameters: {
1226
+ distance: geometry.distance,
1227
+ blur: { type: "length", default: "16px", cssProperty: "--kui-blur" }
1228
+ },
818
1229
  perfClass: "paint"
819
1230
  }),
1231
+ // No `defaultActivation` — same convention `core.ts`'s `parallax`/`parallax-scale`/
1232
+ // `parallax-rotate`/`scroll-fade`/`desaturate`/`skew`/`progress`/`progress-stroke` already use
1233
+ // for every other `timelines: ['view', 'scroll', ...]` primitive. `resolveActivation`
1234
+ // (`animator.ts`) only consults `defaultActivation` when the author named no activation, and
1235
+ // falls through to `element-config.ts`'s hardcoded `'enter'` when a primitive declares none.
1236
+ // Setting it to `'manual'` here (matching `activations: ['manual']`) looked like the obviously
1237
+ // correct pairing, but it is what actually broke the effect: `effectiveActivation`
1238
+ // (`style-plan.ts`) only converts a stuck `'manual'` into `'enter'` when `config.timeline !==
1239
+ // 'time'` — i.e. only once the author has actually written `timeline:view`/`timeline:scroll`.
1240
+ // Authored bare (no `timeline:`, the sweep's own probe and the likely first thing anyone
1241
+ // tries), `config.timeline` stays the default `'time'`, that conversion never fires, and the
1242
+ // element sits at `data-kui-state="ready"` forever — which is also the state
1243
+ // `entrance.css`'s `[data-kui-state='ready'] { --kui-distance: 0px !important; }` targets, so
1244
+ // every sample read the same permanently-zeroed `--kui-distance`: not a paused animation, a
1245
+ // zeroed one. `timeline:view`/`timeline:scroll` usage is unaffected either way, since a native
1246
+ // timeline resolves to the `'native-timeline'` gate before activation is even consulted.
1247
+ // `geometry.distance` only: `kui-image-parallax-frame` never reads `--kui-to-scale` and this
1248
+ // primitive doesn't declare `CHANNEL.scale` — same dead-parameter shape as `media-blur-up` above.
820
1249
  cssPrimitive("media-parallax-frame", [CHANNEL.translate], {
821
- parameters: geometry,
1250
+ parameters: { distance: geometry.distance },
822
1251
  timelines: ["view", "scroll"],
823
1252
  activations: ["manual"],
824
- defaultActivation: "manual",
825
1253
  reducedMotion: "disable"
826
1254
  }),
1255
+ // Its own `scale` parameter, not `geometry`: `kui-lightbox-open` opens from a fixed 0.92, never
1256
+ // reading `--kui-to-scale` (or any `distance`, since this primitive doesn't animate position at
1257
+ // all) — the whole shared object was dead weight here. Unlike `media-blur-up`/
1258
+ // `media-parallax-frame`, this primitive *does* declare `CHANNEL.scale`, so the fix is to wire
1259
+ // the keyframe up to a real parameter rather than remove the promise: `--kui-from-scale`, the
1260
+ // same name and "starts at this scale, animates to 1" meaning `scale`/`scale-move`
1261
+ // (`catalog/core.ts`) already use, so `lightbox-open scale:0.8` now does what it looks like it
1262
+ // should. See `kui-lightbox-open` in `media.css`.
827
1263
  cssPrimitive("media-lightbox", [CHANNEL.opacity, CHANNEL.scale], {
828
- parameters: geometry
1264
+ parameters: { scale: { type: "number", default: "0.92", cssProperty: "--kui-from-scale" } }
829
1265
  })
830
1266
  ];
831
- var MEDIA_PRESETS = [
832
- { name: "wipe-up", primitive: "media-wipe", keyframes: "kui-wipe-up" },
833
- { name: "wipe-down", primitive: "media-wipe", keyframes: "kui-wipe-down" },
834
- { name: "wipe-left", primitive: "media-wipe", keyframes: "kui-wipe-left" },
835
- { name: "wipe-right", primitive: "media-wipe", keyframes: "kui-wipe-right" },
836
- { name: "wipe-circle", primitive: "media-wipe", keyframes: "kui-wipe-circle" },
837
- { name: "wipe-diagonal", primitive: "media-wipe", keyframes: "kui-wipe-diagonal" },
838
- { name: "mask-reveal", primitive: "media-mask", keyframes: "kui-mask-reveal" },
839
- { name: "curtain-reveal", primitive: "media-wipe", keyframes: "kui-curtain-reveal" },
1267
+ var MEDIA_CSS_PRESETS = [
1268
+ { name: "wipe-up", primitive: "media-wipe", keyframes: "kui-wipe-up", cloak: true },
1269
+ { name: "wipe-down", primitive: "media-wipe", keyframes: "kui-wipe-down", cloak: true },
1270
+ { name: "wipe-left", primitive: "media-wipe", keyframes: "kui-wipe-left", cloak: true },
1271
+ { name: "wipe-right", primitive: "media-wipe", keyframes: "kui-wipe-right", cloak: true },
1272
+ { name: "wipe-circle", primitive: "media-wipe", keyframes: "kui-wipe-circle", cloak: true },
1273
+ { name: "wipe-diagonal", primitive: "media-wipe", keyframes: "kui-wipe-diagonal", cloak: true },
1274
+ { name: "mask-reveal", primitive: "media-mask", keyframes: "kui-mask-reveal", cloak: true },
1275
+ { name: "curtain-reveal", primitive: "media-wipe", keyframes: "kui-curtain-reveal", cloak: true },
840
1276
  { name: "ken-burns", primitive: "media-ken-burns", keyframes: "kui-ken-burns" },
841
1277
  { name: "ken-burns-out", primitive: "media-ken-burns", keyframes: "kui-ken-burns-out" },
842
- { name: "blur-up", primitive: "media-blur-up", keyframes: "kui-blur-up" },
1278
+ { name: "blur-up", primitive: "media-blur-up", keyframes: "kui-blur-up", cloak: true },
843
1279
  { name: "duotone-hover", primitive: "media-filter", keyframes: "kui-duotone-hover" },
844
1280
  { name: "grayscale-hover", primitive: "media-filter", keyframes: "kui-grayscale-hover" },
845
1281
  { name: "saturate-hover", primitive: "media-filter", keyframes: "kui-saturate-hover" },
@@ -851,14 +1287,316 @@ var MEDIA_PRESETS = [
851
1287
  { name: "before-after-wipe", primitive: "media-wipe", keyframes: "kui-before-after-wipe" },
852
1288
  { name: "lightbox-open", primitive: "media-lightbox", keyframes: "kui-lightbox-open" }
853
1289
  ];
1290
+ var slatParams = {
1291
+ slats: {
1292
+ type: "number",
1293
+ default: "8",
1294
+ cssProperty: "--kui-slats",
1295
+ minimum: 2,
1296
+ maximum: 24,
1297
+ integer: true
1298
+ },
1299
+ axis: {
1300
+ type: "keyword",
1301
+ default: "vertical",
1302
+ cssProperty: "--kui-axis",
1303
+ values: ["vertical", "horizontal"]
1304
+ },
1305
+ /*
1306
+ * The general form of `axis:`, in degrees: `0deg` is `axis:vertical`, `90deg` is
1307
+ * `axis:horizontal`, and anything between cuts the picture into diagonal bands. An authored
1308
+ * angle wins; leaving it off reads `axis:`, so every existing attribute keeps its meaning.
1309
+ *
1310
+ * `text`, not `angle`, for one reason: `readParams` pre-fills every declared parameter with its
1311
+ * schema default, so a typed default is indistinguishable from an authored value and there would
1312
+ * be no way to tell `angle:0deg` from "no angle, use the axis". An empty default is only possible
1313
+ * on a type that is never written to a stylesheet, which is exactly what `text` is for — and this
1314
+ * value never reaches CSS anyway. `slatAngleDegrees` does the parsing and the range clamp.
1315
+ */
1316
+ angle: { type: "text", default: "", cssProperty: "--kui-slat-angle" },
1317
+ from: {
1318
+ type: "keyword",
1319
+ default: "alternate",
1320
+ cssProperty: "--kui-from",
1321
+ values: ["alternate", "start", "end", "edges", "random-ish"]
1322
+ },
1323
+ fold: { type: "keyword", default: "false", cssProperty: "--kui-fold", values: ["true", "false"] },
1324
+ duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" },
1325
+ ...TRIGGER_DELAY_PARAM,
1326
+ ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
1327
+ stagger: { type: "time", default: "60ms", cssProperty: "--kui-stagger" }
1328
+ };
1329
+ function prepareSlatAssemble(el, params, ctx) {
1330
+ const doc = el.ownerDocument;
1331
+ const count = Math.min(24, Math.max(2, Math.round(params.num("slats", 8))));
1332
+ const axis = params.text("axis", "vertical");
1333
+ const angleDegrees = slatAngleDegrees(params.text("angle", ""), axis);
1334
+ const from = params.text("from", "alternate");
1335
+ const fold = params.is("fold");
1336
+ const node = el;
1337
+ if (ctx.win.getComputedStyle(node).position === "static") ctx.style.set("position", "relative");
1338
+ const built = installSlatStage(el, doc, ctx.win, { count, angleDegrees, from, fold });
1339
+ if (!built) return () => {
1340
+ };
1341
+ const { stage } = built;
1342
+ applySlatTimingVars(stage, params);
1343
+ stage.classList.add("kui-slat-animating");
1344
+ let settle2;
1345
+ const finished = new Promise((resolve) => {
1346
+ settle2 = resolve;
1347
+ });
1348
+ let landed = false;
1349
+ const land = () => {
1350
+ if (landed) return;
1351
+ landed = true;
1352
+ stage.classList.remove("kui-slat-animating");
1353
+ built.restore();
1354
+ settle2();
1355
+ };
1356
+ const timer = ctx.win.setTimeout(land, slatAssembleFinishMs(params, count));
1357
+ return {
1358
+ cleanup: () => {
1359
+ ctx.win.clearTimeout(timer);
1360
+ if (landed) return;
1361
+ landed = true;
1362
+ built.restore();
1363
+ },
1364
+ finished,
1365
+ finish: () => {
1366
+ ctx.win.clearTimeout(timer);
1367
+ land();
1368
+ }
1369
+ };
1370
+ }
1371
+ var backgroundMediaParams = {
1372
+ /*
1373
+ * `text`, and same-origin-checked at the point of use rather than by the type — identical
1374
+ * shape and identical reasoning to `media-scrub`'s own `src` (`scroll-mechanics/primitives.ts`).
1375
+ * A URL has no lexical shape to validate against, and `type: 'text'` is the one type that never
1376
+ * reaches a stylesheet, which is what makes accepting arbitrary path characters safe.
1377
+ */
1378
+ src: { type: "text", default: "", cssProperty: "--kui-src" },
1379
+ /*
1380
+ * The still a `<video>` shows before its first frame decodes. Not optional polish: without it a
1381
+ * background clip paints as an empty box for as long as the network takes, and that box is the
1382
+ * backdrop to the author's text — the one place on the page where a flash of nothing is most
1383
+ * visible. Every background video in this repo's own demo pages is authored with one.
1384
+ */
1385
+ poster: { type: "text", default: "", cssProperty: "--kui-poster" },
1386
+ /*
1387
+ * `fill`, `none` and `scale-down` are deliberately absent. `fill` is the only `object-fit` value
1388
+ * that distorts — it stretches the picture to the box rather than cropping it — and the standing
1389
+ * rule for imagery in this project is to crop, never stretch. The other two leave the media at
1390
+ * its intrinsic size inside a box sized to something else, which for a *backdrop* is a gap, not
1391
+ * a layout. Adding them would be offering three ways to get a broken background.
1392
+ */
1393
+ fit: {
1394
+ type: "keyword",
1395
+ default: "cover",
1396
+ cssProperty: "--kui-fit",
1397
+ values: ["cover", "contain"]
1398
+ },
1399
+ /*
1400
+ * Which part of the picture a `cover` crop keeps. Nine named points rather than a free
1401
+ * `object-position` string, because a free string would have to be `type: 'text'` — the one type
1402
+ * that is explicitly never written to a stylesheet (see `core/params.ts`) — and this value is
1403
+ * written to one. A keyword list is validated against its own `values`, so the author gets real
1404
+ * focal control and the CSS surface stays closed.
1405
+ */
1406
+ focus: {
1407
+ type: "keyword",
1408
+ default: "center",
1409
+ cssProperty: "--kui-focus",
1410
+ values: FOCAL_POINT_NAMES
1411
+ },
1412
+ /*
1413
+ * The scrim. This is the parameter that makes the whole effect usable, because the point of a
1414
+ * backdrop here is animated text on top of it, and text over unmodified footage is illegible
1415
+ * about half the time — a light frame arrives and the headline vanishes for those seconds.
1416
+ *
1417
+ * `type: 'color'` so it goes through the same validator every other colour does. `transparent`
1418
+ * as the default rather than an empty string for the same reason: `''` is not a colour, and a
1419
+ * default that its own type would reject is a lie the schema cannot catch. It is also the honest
1420
+ * spelling of "no scrim", and no scrim node is created for it.
1421
+ */
1422
+ overlay: { type: "color", default: "transparent", cssProperty: "--kui-overlay" },
1423
+ /*
1424
+ * Separate from the colour rather than folded into it. `overlay:rgb(0 0 0 / 45%)` does parse —
1425
+ * the tokenizer is paren-aware — but `overlay:black overlay-opacity:45%` is the spelling someone
1426
+ * reaches for while tuning legibility, and tuning is exactly what this value is for.
1427
+ */
1428
+ "overlay-opacity": { type: "percentage", default: "100%", cssProperty: "--kui-overlay-opacity" },
1429
+ /*
1430
+ * The opt-out for the play-while-visible behaviour. `in-view` pairs the clip with the viewport
1431
+ * and is right for a long section. `always` is for a short hero clip that must never be caught
1432
+ * mid-stall by a visibility heuristic. `never` installs the clip and leaves it on its poster,
1433
+ * which is also where any mode lands under a reduced-motion preference.
1434
+ */
1435
+ autoplay: {
1436
+ type: "keyword",
1437
+ default: "in-view",
1438
+ cssProperty: "--kui-autoplay",
1439
+ values: ["in-view", "always", "never"]
1440
+ },
1441
+ /*
1442
+ * Bounded at both ends: `0` is a clip that is loaded, decoding, and permanently frozen — worse
1443
+ * than `autoplay:never`, which at least says so — and browsers stop honouring rates past roughly
1444
+ * 4 anyway, so a larger number is a silent no-op rather than a faster clip.
1445
+ */
1446
+ rate: {
1447
+ type: "number",
1448
+ default: "1",
1449
+ cssProperty: "--kui-rate",
1450
+ finite: true,
1451
+ minimum: 0.25,
1452
+ maximum: 4
1453
+ },
1454
+ loop: { type: "keyword", default: "true", cssProperty: "--kui-loop", values: ["true", "false"] }
1455
+ /*
1456
+ * There is deliberately no `controls:`. The layer this primitive builds paints at `z-index: -1`
1457
+ * behind the author's own children, so a native control bar there is focusable by keyboard and
1458
+ * occluded by whatever the page happens to put over it — a player you can tab into and cannot
1459
+ * see. A clip meant to be controlled is a content `<video controls>` the author writes, not a
1460
+ * background one.
1461
+ */
1462
+ };
1463
+ function prepareBackgroundMedia(el, params, ctx) {
1464
+ const authored = params.text("src");
1465
+ if (!authored) {
1466
+ ctx.warn('background-media needs a "src:" \u2014 nothing installed');
1467
+ return () => {
1468
+ };
1469
+ }
1470
+ const src = mediaSource(authored, "src", ctx);
1471
+ if (!src) return () => {
1472
+ };
1473
+ const node = el;
1474
+ if (ctx.win.getComputedStyle(node).position === "static") ctx.style.set("position", "relative");
1475
+ ctx.style.set("isolation", "isolate");
1476
+ const layer = installBackgroundMedia(el, ctx, {
1477
+ src,
1478
+ poster: mediaSource(params.text("poster"), "poster", ctx),
1479
+ fit: params.is("fit", "contain") ? "contain" : "cover",
1480
+ position: focalPosition(params.text("focus", "center")),
1481
+ overlay: params.text("overlay", "transparent"),
1482
+ // `num` returns a percentage as a 0–1 ratio, which is exactly what `opacity` takes.
1483
+ overlayOpacity: Math.min(1, Math.max(0, params.num("overlay-opacity", 1))),
1484
+ autoplay: params.text("autoplay", "in-view"),
1485
+ rate: params.num("rate", 1),
1486
+ // `!is('loop', 'false')`, not `is('loop')`. Every other read here names its own fallback, and
1487
+ // this one has to as well: `is()` takes no fallback argument, so a bare `is('loop')` is only
1488
+ // true when something already filled the schema default in. That holds on the animator's path
1489
+ // (`readEffectParams` pre-fills every declared parameter) and not on `createParams`, so the
1490
+ // positive spelling silently defaulted a true-by-default parameter to false for any caller
1491
+ // handing over raw values. Reading it as "loop unless explicitly told not to" states the
1492
+ // default at the point of use, where it cannot drift.
1493
+ loop: !params.is("loop", "false"),
1494
+ reducedMotion: ctx.reducedMotion
1495
+ });
1496
+ return continuousSetup(layer.remove);
1497
+ }
1498
+ var MEDIA_JS_PRIMITIVES = [
1499
+ {
1500
+ id: "slat-assemble",
1501
+ renderer: "javascript",
1502
+ channels: [CHANNEL.opacity, CHANNEL.translate, CHANNEL.rotate],
1503
+ parameters: slatParams,
1504
+ supportedTimelines: ["time"],
1505
+ supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
1506
+ defaultActivation: "enter",
1507
+ perfClass: "dom-transform",
1508
+ // Same reasoning as every JS-rendered primitive in `text.ts`: nothing here declares a CSS
1509
+ // `animation-duration` the reduced-motion policy layer could shorten, and `disable` is what
1510
+ // stops `installSlatStage`'s DOM surgery from ever running at all under reduced motion — the
1511
+ // animator never calls `activate()`, so the wrapped `<img>` is simply left exactly as authored.
1512
+ reducedMotion: "disable",
1513
+ // Land()ing hands the picture back to the real `<img>` and tears down every slat — see
1514
+ // `prepareSlatAssemble`'s `land()`. Declared, not assumed: see `restoresOnFinish`'s own comment
1515
+ // in `core/types.ts` for why the catalog's default is the opposite of this.
1516
+ restoresOnFinish: true,
1517
+ prepare: deferPrepare(prepareSlatAssemble)
1518
+ },
1519
+ {
1520
+ id: "background-media",
1521
+ /*
1522
+ * `media` is the same word `media-scrub` uses for "this effect owns what the element shows",
1523
+ * and it is what makes `background-media, video-scrub` on one element a reported conflict
1524
+ * rather than two effects silently fighting over the same picture.
1525
+ *
1526
+ * `layout` is claimed for the same reason `pin` claims it: preparation writes `position` and
1527
+ * `isolation` on the *host*, which is a stacking-context claim on someone else's element. Left
1528
+ * undeclared, `background-media, pin-section` composed silently while both decided what
1529
+ * `position` the host has — the conflict detector cannot report a claim it was never told about.
1530
+ */
1531
+ channels: ["media", "layout"],
1532
+ renderer: "javascript",
1533
+ parameters: backgroundMediaParams,
1534
+ // Not a claim to support four timelines — an abstention. A backdrop is not driven by progress
1535
+ // of any kind and this primitive never reads `Timeline`; the list exists only so that
1536
+ // `data-kui="background-media src:/hero.mp4, parallax"` plus a `timeline:view` survives
1537
+ // `compile.ts`'s `intersect`. See `TIMELINE_AGNOSTIC` (`effects/shared.ts`), shared with the
1538
+ // scroll-mechanics drivers, which abstain for the same reason.
1539
+ supportedTimelines: TIMELINE_AGNOSTIC,
1540
+ supportedActivations: ["load", "enter", "manual"],
1541
+ /*
1542
+ * `'load'`, not the catalog's usual `'enter'`, and this is the difference between working and
1543
+ * not. A backdrop is the element's appearance, so gating it on an IntersectionObserver means a
1544
+ * section that is already on screen at page load, one in a background tab (no IO callbacks
1545
+ * fire at all until the tab is foregrounded), or one whose own box is still zero-area waits an
1546
+ * unbounded time to have any background — and unlike a missed reveal, that is a visibly broken
1547
+ * page. An author who *wants* a heavy clip deferred can still write `on:enter`.
1548
+ */
1549
+ defaultActivation: "load",
1550
+ perfClass: "paint",
1551
+ /*
1552
+ * `'shorten'`, unlike every other JS-rendered primitive in this file and in `text.ts`, and
1553
+ * deliberately so. `'disable'` means the animator never calls `activate()` under a reduced
1554
+ * motion preference, which for an animation is exactly right and for this is not: it would
1555
+ * leave the element with no backdrop at all rather than a calmer one. There is no CSS duration
1556
+ * here for `'shorten'` to shorten, so the policy is inert and the effect installs normally;
1557
+ * `ctx.reducedMotion` is then read inside, where it suppresses the one genuinely motion-y part
1558
+ * — a clip's autoplay — and leaves the poster frame standing. See `autoplayInView`.
1559
+ */
1560
+ reducedMotion: "shorten",
1561
+ prepare: deferPrepare(prepareBackgroundMedia)
1562
+ }
1563
+ ];
1564
+ var MEDIA_JS_PRESETS = [
1565
+ { name: "slat-assemble", primitive: "slat-assemble", cloak: true },
1566
+ /*
1567
+ * Two names, one primitive — the alias shape the catalog already uses everywhere (`pin-until`,
1568
+ * `pin-spacer` and `stacking-cards` are three names over the one `pin` primitive; six `wipe-*`
1569
+ * names share `media-wipe`). A preset row is the alias mechanism, so a second spelling costs a
1570
+ * table entry and nothing else: no duplicated implementation to keep in sync, and both names
1571
+ * resolve to the same `prepare`.
1572
+ *
1573
+ * No `cloak` on either: the pre-JS cloak rule hides an element until the runtime installs the
1574
+ * effect's from-state, and this element is the author's own content. Cloaking it would blank
1575
+ * their text for as long as the bundle takes to arrive, to hide a backdrop that has no
1576
+ * from-state at all.
1577
+ */
1578
+ { name: "bg", primitive: "background-media" },
1579
+ { name: "background", primitive: "background-media" }
1580
+ ];
1581
+ var MEDIA_PRIMITIVES = [...MEDIA_CSS_PRIMITIVES, ...MEDIA_JS_PRIMITIVES];
1582
+ var MEDIA_PRESETS = [...MEDIA_CSS_PRESETS, ...MEDIA_JS_PRESETS];
854
1583
  function registerMedia(registry) {
855
1584
  return registry.registerPrimitives(MEDIA_PRIMITIVES).registerPresets(MEDIA_PRESETS);
856
1585
  }
857
1586
 
1587
+ // src/effects/catalog/subtree-capture.ts
1588
+ function captureChildren(el) {
1589
+ const authored = Array.from(el.childNodes);
1590
+ return () => {
1591
+ el.replaceChildren(...authored);
1592
+ };
1593
+ }
1594
+
858
1595
  // src/effects/catalog/numbers-shared.ts
859
1596
  var SR_ONLY_CLASS = "kui-sr-only";
860
1597
  var DECORATIVE_CLASS = "kui-count-decorative";
861
1598
  function installCountLayers(el, doc) {
1599
+ const restoreChildren = captureChildren(el);
862
1600
  const decorative = doc.createElement("span");
863
1601
  decorative.setAttribute("aria-hidden", "true");
864
1602
  decorative.className = DECORATIVE_CLASS;
@@ -870,9 +1608,7 @@ function installCountLayers(el, doc) {
870
1608
  return {
871
1609
  decorative,
872
1610
  srOnly,
873
- restore: () => {
874
- el.textContent = srOnly.textContent;
875
- }
1611
+ restore: restoreChildren
876
1612
  };
877
1613
  }
878
1614
  function easeOutCubic(t) {
@@ -983,6 +1719,7 @@ var COUNT_STEP_MS = 16;
983
1719
  var REDUCED_MOTION_DURATION_MS = 1;
984
1720
  var countParams = {
985
1721
  duration: { type: "time", default: "1600ms", cssProperty: "--kui-duration" },
1722
+ ...TRIGGER_DELAY_PARAM,
986
1723
  from: { type: "number", default: "0", cssProperty: "--kui-from" },
987
1724
  to: { type: "number", default: "100", cssProperty: "--kui-to" },
988
1725
  decimals: {
@@ -1003,6 +1740,7 @@ var countParams = {
1003
1740
  };
1004
1741
  var odometerParams = {
1005
1742
  duration: { type: "time", default: "1600ms", cssProperty: "--kui-duration" },
1743
+ ...TRIGGER_DELAY_PARAM,
1006
1744
  from: { type: "number", default: "0", cssProperty: "--kui-from" },
1007
1745
  to: { type: "number", default: "100", cssProperty: "--kui-to" }
1008
1746
  };
@@ -1029,7 +1767,9 @@ function tweenDurationMs(params, ctx) {
1029
1767
  function tweenTimingFor(params, ctx) {
1030
1768
  return {
1031
1769
  durationMs: tweenDurationMs(params, ctx),
1032
- delayMs: Math.max(0, params.timing.delayMs ?? 0),
1770
+ // Positional first, then the same-named parameter — the two-spellings rule `effectDurationMs`
1771
+ // applies to `duration` just above, now applied to `delay` as well.
1772
+ delayMs: Math.max(0, params.timing.delayMs ?? params.ms("delay", 0)),
1033
1773
  easing: resolveEasing(params.timing.easing, ctx.warn)
1034
1774
  };
1035
1775
  }
@@ -1182,7 +1922,12 @@ var COUNT_PRESETS = [
1182
1922
  ];
1183
1923
  var METER_PRIMITIVES = [
1184
1924
  cssPrimitive("stroke-sweep", [CHANNEL.stroke]),
1185
- cssPrimitive("meter-bar", [CHANNEL.scale]),
1925
+ // `from` gives `progress-bar` a real knob on its start scale — it had none before. `--kui-bar-from`
1926
+ // is also what the `[data-kui-fx~='progress-bar'][data-kui-state='ready']` gate in numbers.css
1927
+ // neutralizes; see that rule's comment for the on:enter fix this parameter doubles as.
1928
+ cssPrimitive("meter-bar", [CHANNEL.scale], {
1929
+ parameters: { from: { type: "number", default: "0", cssProperty: "--kui-bar-from" } }
1930
+ }),
1186
1931
  cssPrimitive("meter-segments", [CHANNEL.opacity]),
1187
1932
  cssPrimitive("meter-stars", [CHANNEL.clip])
1188
1933
  ];
@@ -1191,7 +1936,13 @@ var METER_PRESETS = [
1191
1936
  { name: "gauge-sweep", primitive: "stroke-sweep", keyframes: "kui-gauge-sweep" },
1192
1937
  { name: "donut-sweep", primitive: "stroke-sweep", keyframes: "kui-donut-sweep" },
1193
1938
  { name: "sparkline-draw", primitive: "stroke-sweep", keyframes: "kui-sparkline-draw" },
1194
- { name: "progress-bar", primitive: "meter-bar", keyframes: "kui-progress-bar" },
1939
+ // `cloak: true`: `kui-progress-bar`'s `from { scale: 0 1 }` (numbers.css) is a zero-width box
1940
+ // while paused, not just an invisible one — so while it waits it occupies no space in layout at
1941
+ // all. Not, despite the tidier story, because an observer refuses to fire on it: Chromium was
1942
+ // measured resolving a zero-area target's `intersectionRatio` to `1`. See numbers.css's
1943
+ // `[data-kui-fx~='progress-bar'][data-kui-state='ready']` rule for the geometry half of the fix;
1944
+ // `cloak` keeps the pre-JS and post-JS "waiting" look the same (invisible) either side of it.
1945
+ { name: "progress-bar", primitive: "meter-bar", keyframes: "kui-progress-bar", cloak: true },
1195
1946
  { name: "progress-segments", primitive: "meter-segments", keyframes: "kui-progress-segments" },
1196
1947
  { name: "star-rating-fill", primitive: "meter-stars", keyframes: "kui-star-rating-fill" }
1197
1948
  ];
@@ -1206,6 +1957,7 @@ var SR_ONLY_CLASS2 = "kui-sr-only";
1206
1957
  var DECORATIVE_CLASS2 = "kui-split-decorative";
1207
1958
  function installSplitLayers(el, doc) {
1208
1959
  const originalText = el.textContent.trim();
1960
+ const restoreChildren = captureChildren(el);
1209
1961
  const decorative = doc.createElement("span");
1210
1962
  decorative.setAttribute("aria-hidden", "true");
1211
1963
  decorative.className = DECORATIVE_CLASS2;
@@ -1217,9 +1969,7 @@ function installSplitLayers(el, doc) {
1217
1969
  return {
1218
1970
  decorative,
1219
1971
  originalText,
1220
- restore: () => {
1221
- el.textContent = originalText;
1222
- }
1972
+ restore: restoreChildren
1223
1973
  };
1224
1974
  }
1225
1975
  function segmentGraphemes(text) {
@@ -1279,15 +2029,22 @@ function createStepRunner(win, options) {
1279
2029
  function appendCharSpans(container, doc, text) {
1280
2030
  const spans = [];
1281
2031
  let index = 0;
2032
+ let wordWrapper = null;
1282
2033
  for (const grapheme of segmentGraphemes(text)) {
1283
2034
  if (grapheme.trim() === "") {
1284
2035
  container.append(doc.createTextNode(grapheme));
2036
+ wordWrapper = null;
1285
2037
  continue;
1286
2038
  }
2039
+ if (!wordWrapper) {
2040
+ wordWrapper = doc.createElement("span");
2041
+ wordWrapper.className = "kui-split-word";
2042
+ container.append(wordWrapper);
2043
+ }
1287
2044
  const span = doc.createElement("span");
1288
2045
  markItem(span, index);
1289
2046
  span.textContent = grapheme;
1290
- container.append(span);
2047
+ wordWrapper.append(span);
1291
2048
  spans.push(span);
1292
2049
  index++;
1293
2050
  }
@@ -1334,7 +2091,13 @@ function appendLineSpans(container, doc, text) {
1334
2091
  return buckets.map((nodes, index) => {
1335
2092
  const line = doc.createElement("span");
1336
2093
  markItem(line, index, "kui-split-line");
1337
- for (const node of nodes) line.append(node);
2094
+ for (const node of nodes) {
2095
+ if (node instanceof HTMLElement) {
2096
+ node.removeAttribute("class");
2097
+ node.style.removeProperty("--kui-i");
2098
+ }
2099
+ line.append(node);
2100
+ }
1338
2101
  container.append(line);
1339
2102
  return line;
1340
2103
  });
@@ -1387,14 +2150,43 @@ var extrudeParams = {
1387
2150
  distance: { type: "length", default: "32px", cssProperty: "--kui-distance" }
1388
2151
  };
1389
2152
  var TEXT_CSS_PRIMITIVES = [
1390
- cssPrimitive("text-shimmer", [CHANNEL.background], { reducedMotion: "disable" }),
2153
+ // `color`, alongside `background`: both presets' unconditional rule sets `-webkit-text-fill-color:
2154
+ // transparent` so the `background-image` gradient shows through the glyphs (the standard
2155
+ // gradient-text technique). That is a real claim on the glyph fill, the same physical property
2156
+ // `text-outline-fill` animates on its own `color` channel below — without this, the two looked
2157
+ // disjoint to the compiler (`background` vs `stroke`+`color`) and composing them would let
2158
+ // whichever applied last silently win the glyph fill instead of being flagged as a conflict.
2159
+ cssPrimitive("text-shimmer", [CHANNEL.background, CHANNEL.color], { reducedMotion: "disable" }),
2160
+ // `gradient-sweep` is the only one of the three `text-sweep`-family presets whose keyframe
2161
+ // touches `-webkit-text-fill-color` (see text.css) — `highlight-sweep` and `underline-draw` only
2162
+ // paint a `background-image`. Claiming `color` for all three made the compiler reject compositions
2163
+ // like `underline-draw, text-outline-fill` as a glyph-fill conflict even though they touch disjoint
2164
+ // properties. Split so only the preset that actually claims the fill declares the channel.
2165
+ cssPrimitive("text-gradient-sweep", [CHANNEL.background, CHANNEL.color], { parameters: textSweepParams }),
1391
2166
  cssPrimitive("text-sweep", [CHANNEL.background], { parameters: textSweepParams }),
1392
2167
  cssPrimitive("text-outline-fill", [CHANNEL.stroke, CHANNEL.color]),
1393
2168
  cssPrimitive("var-weight", ["font"], { parameters: fontWeightParams }),
1394
2169
  cssPrimitive("var-width", ["font"], { parameters: fontWidthParams }),
1395
2170
  cssPrimitive("var-slant", ["font"], { parameters: fontSlantParams }),
2171
+ /*
2172
+ * `defaultActivation: 'load'` for the same reason every ambient primitive declares it, and it
2173
+ * was missing here: a marquee is continuous motion, so `reducedMotion: 'disable'` is only half
2174
+ * the rule `ambient.ts` spells out — the other half is starting on `load` rather than waiting on
2175
+ * a scroll-triggered `enter`.
2176
+ *
2177
+ * Without it, a bare `data-kui="marquee 42s"` resolved to `enter`, which `style-plan.ts`'s
2178
+ * `resolveGate` sends down the `deferred` path and stamps `animation-play-state: paused`. It
2179
+ * compiled correctly, reported `data-kui-state="ready"`, and never ran — measured on a marquee
2180
+ * fully in view, so this was not an observer that had simply not fired yet. Every page carrying
2181
+ * one had to know to write `on:load`, which is exactly the kind of thing an author cannot be
2182
+ * expected to guess.
2183
+ *
2184
+ * `marquee-scroll-linked` is unaffected: its position comes from `animation-timeline: scroll()`,
2185
+ * not from an activation.
2186
+ */
1396
2187
  cssPrimitive("text-marquee", [CHANNEL.translate], {
1397
2188
  timelines: ["time", "scroll"],
2189
+ defaultActivation: "load",
1398
2190
  reducedMotion: "disable"
1399
2191
  }),
1400
2192
  cssPrimitive("redaction-reveal", [CHANNEL.clip]),
@@ -1402,7 +2194,7 @@ var TEXT_CSS_PRIMITIVES = [
1402
2194
  ];
1403
2195
  var TEXT_CSS_PRESETS = [
1404
2196
  { name: "gradient-shimmer", primitive: "text-shimmer", keyframes: "kui-gradient-shimmer" },
1405
- { name: "gradient-sweep", primitive: "text-sweep", keyframes: "kui-gradient-sweep" },
2197
+ { name: "gradient-sweep", primitive: "text-gradient-sweep", keyframes: "kui-gradient-sweep" },
1406
2198
  {
1407
2199
  name: "highlight-sweep",
1408
2200
  primitive: "text-sweep",
@@ -1439,7 +2231,7 @@ function jsTextPrimitive(id, channels, options) {
1439
2231
  }
1440
2232
  var splitTiming = {
1441
2233
  duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" },
1442
- delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" },
2234
+ ...TRIGGER_DELAY_PARAM,
1443
2235
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
1444
2236
  stagger: { type: "time", default: "30ms", cssProperty: "--kui-stagger" },
1445
2237
  unit: {
@@ -1457,6 +2249,11 @@ var splitTiming = {
1457
2249
  };
1458
2250
  var motionParams = {
1459
2251
  stagger: { type: "time", default: "40ms", cssProperty: "--kui-stagger" },
2252
+ // `duration`/`ease` are deliberately *not* declared beside the delay: text.css pins both for
2253
+ // wave and jitter on a higher-specificity `[data-kui-split-fx='wave'] .kui-split-item` rule, so
2254
+ // declaring them would advertise two knobs that the stylesheet then overrides. `animation-delay`
2255
+ // is the one the phase-start rule leaves alone, which is what lets `applyStaggerVars` honour it.
2256
+ ...TRIGGER_DELAY_PARAM,
1460
2257
  motion: {
1461
2258
  type: "keyword",
1462
2259
  default: "wave",
@@ -1466,10 +2263,15 @@ var motionParams = {
1466
2263
  };
1467
2264
  var typewriterParams = {
1468
2265
  step: { type: "time", default: "55ms", cssProperty: "--kui-step" },
1469
- loop: { type: "keyword", default: "false", cssProperty: "--kui-loop", values: ["true", "false"] }
2266
+ loop: { type: "keyword", default: "false", cssProperty: "--kui-loop", values: ["true", "false"] },
2267
+ ...TRIGGER_DELAY_PARAM
1470
2268
  };
1471
2269
  var scrambleParams = {
1472
2270
  step: { type: "time", default: "40ms", cssProperty: "--kui-step" },
2271
+ // `duration` gets no such shared declaration: `stepMsFor` reads its authored-or-not distinction
2272
+ // to decide between a whole-effect time and a per-tick `step:`, and a schema default would erase
2273
+ // that distinction. A `0ms` delay default has no equivalent problem.
2274
+ ...TRIGGER_DELAY_PARAM,
1473
2275
  revealEvery: {
1474
2276
  type: "number",
1475
2277
  default: "2",
@@ -1486,7 +2288,11 @@ var scrambleParams = {
1486
2288
  };
1487
2289
  var wordCyclerParams = {
1488
2290
  words: { type: "text", default: "", cssProperty: "--kui-words" },
1489
- interval: { type: "time", default: "2200ms", cssProperty: "--kui-interval" }
2291
+ interval: { type: "time", default: "2200ms", cssProperty: "--kui-interval" },
2292
+ // Load-bearing here, not just for symmetry: a cycler has no authored `duration` — `interval:`
2293
+ // paces it — so the positional "duration then delay" slot only reached a delay when the author
2294
+ // wrote a throwaway first value, `word-cycler 0ms 300ms`.
2295
+ ...TRIGGER_DELAY_PARAM
1490
2296
  };
1491
2297
  function prepareSplitText(el, params, ctx) {
1492
2298
  const doc = el.ownerDocument;
@@ -1532,7 +2338,7 @@ function prepareTypewriter(el, params, ctx) {
1532
2338
  layers.decorative.textContent = graphemes.slice(0, count).join("");
1533
2339
  };
1534
2340
  const run = createStepRunner(ctx.win, {
1535
- delayMs: params.timing.delayMs ?? 0,
2341
+ delayMs: params.timing.delayMs ?? params.ms("delay", 0),
1536
2342
  stepMs: stepMsFor(params, graphemes.length, 55),
1537
2343
  tick: () => {
1538
2344
  const step = nextTypeState(state, graphemes.length, loop2);
@@ -1556,6 +2362,16 @@ function prepareTypewriter(el, params, ctx) {
1556
2362
  function prepareScramble(el, params, ctx) {
1557
2363
  const charset = SCRAMBLE_CHARSETS[params.text("charset", "upper")];
1558
2364
  const revealEvery = Math.max(1, Math.round(params.num("revealEvery", 2)));
2365
+ const node = el;
2366
+ const authoredMinWidth = node.style.getPropertyValue("min-width");
2367
+ const authoredMinHeight = node.style.getPropertyValue("min-height");
2368
+ const restRect = el.getBoundingClientRect();
2369
+ ctx.style.set("min-width", `${restRect.width}px`);
2370
+ ctx.style.set("min-height", `${restRect.height}px`);
2371
+ const releaseSizeLock = () => {
2372
+ ctx.style.set("min-width", authoredMinWidth);
2373
+ ctx.style.set("min-height", authoredMinHeight);
2374
+ };
1559
2375
  const layers = installSplitLayers(el, el.ownerDocument);
1560
2376
  layers.decorative.classList.add("kui-scramble");
1561
2377
  const graphemes = segmentGraphemes(layers.originalText);
@@ -1565,26 +2381,31 @@ function prepareScramble(el, params, ctx) {
1565
2381
  layers.decorative.textContent = scrambledFrame(graphemes, resolved, charset, Math.random);
1566
2382
  };
1567
2383
  render();
2384
+ const totalTicks = Math.max(1, graphemes.length * revealEvery);
1568
2385
  const run = createStepRunner(ctx.win, {
1569
- delayMs: params.timing.delayMs ?? 0,
1570
- stepMs: stepMsFor(params, graphemes.length * revealEvery, 40),
2386
+ delayMs: params.timing.delayMs ?? params.ms("delay", 0),
2387
+ stepMs: stepMsFor(params, totalTicks, Math.max(1, 700 / totalTicks)),
1571
2388
  tick: () => {
1572
2389
  ticks++;
1573
2390
  if (ticks % revealEvery === 0) resolved++;
1574
2391
  render();
1575
- return resolved >= graphemes.length;
2392
+ const done = resolved >= graphemes.length;
2393
+ if (done) releaseSizeLock();
2394
+ return done;
1576
2395
  }
1577
2396
  });
1578
2397
  return {
1579
2398
  cleanup: () => {
1580
2399
  run.stop();
1581
2400
  layers.restore();
2401
+ releaseSizeLock();
1582
2402
  },
1583
2403
  finished: run.finished,
1584
2404
  finish: () => {
1585
2405
  run.stop();
1586
2406
  resolved = graphemes.length;
1587
2407
  render();
2408
+ releaseSizeLock();
1588
2409
  }
1589
2410
  };
1590
2411
  }
@@ -1592,12 +2413,12 @@ function prepareWordCycler(el, params, ctx) {
1592
2413
  const words = params.text("words", "").split("|").map((word) => word.trim()).filter(Boolean);
1593
2414
  if (words.length === 0) return () => {
1594
2415
  };
1595
- const original = el.textContent;
2416
+ const restoreChildren = captureChildren(el);
1596
2417
  const swapMs = 150;
1597
2418
  let index = 0;
1598
2419
  el.textContent = words[0];
1599
2420
  const run = createStepRunner(ctx.win, {
1600
- delayMs: params.timing.delayMs ?? 0,
2421
+ delayMs: params.timing.delayMs ?? params.ms("delay", 0),
1601
2422
  stepMs: params.ms("interval", 2200),
1602
2423
  tick: () => {
1603
2424
  el.classList.add("kui-word-cycler-swap");
@@ -1613,7 +2434,7 @@ function prepareWordCycler(el, params, ctx) {
1613
2434
  cleanup: () => {
1614
2435
  run.stop();
1615
2436
  el.classList.remove("kui-word-cycler-swap");
1616
- el.textContent = original;
2437
+ restoreChildren();
1617
2438
  },
1618
2439
  finished: run.finished,
1619
2440
  finish: () => run.stop()
@@ -1645,13 +2466,16 @@ var TEXT_JS_PRIMITIVES = [
1645
2466
  perfClass: "continuous"
1646
2467
  })
1647
2468
  ];
2469
+ var CHARS_STAGGER = "30ms";
2470
+ var WORDS_STAGGER = "90ms";
2471
+ var LINES_STAGGER = "160ms";
1648
2472
  var TEXT_JS_PRESETS = [
1649
- { name: "split-chars", primitive: "split-text", params: { unit: "chars", direction: "fade" } },
1650
- { name: "split-words", primitive: "split-text", params: { unit: "words", direction: "fade" } },
1651
- { name: "split-lines", primitive: "split-text", params: { unit: "lines", direction: "fade" } },
1652
- { name: "text-reveal-up", primitive: "split-text", params: { unit: "words", direction: "up" } },
1653
- { name: "text-reveal-down", primitive: "split-text", params: { unit: "words", direction: "down" } },
1654
- { name: "text-reveal-mask", primitive: "split-text", params: { unit: "lines", direction: "mask" } },
2473
+ { name: "split-chars", primitive: "split-text", params: { unit: "chars", direction: "fade", stagger: CHARS_STAGGER } },
2474
+ { name: "split-words", primitive: "split-text", params: { unit: "words", direction: "fade", stagger: WORDS_STAGGER } },
2475
+ { name: "split-lines", primitive: "split-text", params: { unit: "lines", direction: "fade", stagger: LINES_STAGGER } },
2476
+ { name: "text-reveal-up", primitive: "split-text", params: { unit: "words", direction: "up", stagger: WORDS_STAGGER }, cloak: true },
2477
+ { name: "text-reveal-down", primitive: "split-text", params: { unit: "words", direction: "down", stagger: WORDS_STAGGER }, cloak: true },
2478
+ { name: "text-reveal-mask", primitive: "split-text", params: { unit: "lines", direction: "mask", stagger: LINES_STAGGER }, cloak: true },
1655
2479
  { name: "text-wave", primitive: "split-text-motion", params: { motion: "wave" } },
1656
2480
  { name: "text-jitter", primitive: "split-text-motion", params: { motion: "jitter" } },
1657
2481
  { name: "typewriter", primitive: "typewriter", params: { loop: "false" } },
@@ -1683,66 +2507,130 @@ var distance = {
1683
2507
  distance: { type: "length", default: "24px", cssProperty: "--kui-distance" },
1684
2508
  opacity: { type: "number", default: "0", cssProperty: "--kui-from-opacity" }
1685
2509
  };
2510
+ var ENTRANCE_TIMELINES = ["time", "view", "scroll", "pin"];
1686
2511
  var PRIMITIVES = [
1687
2512
  // --- entrance / exit -------------------------------------------------------------------
1688
- cssPrimitive("reveal", [CHANNEL.opacity, CHANNEL.translate], { parameters: distance }),
2513
+ cssPrimitive("reveal", [CHANNEL.opacity, CHANNEL.translate], { timelines: ENTRANCE_TIMELINES, parameters: distance }),
1689
2514
  cssPrimitive("scale", [CHANNEL.scale], {
2515
+ timelines: ENTRANCE_TIMELINES,
1690
2516
  parameters: { scale: { type: "number", default: "0.92", cssProperty: "--kui-from-scale" } }
1691
2517
  }),
1692
2518
  // Separate from `scale` because it claims translate as well and so composes differently.
2519
+ // `distance.distance` only, not `...distance`: `kui-zoom-in-up`/`-down` (entrance.css) read
2520
+ // `--kui-distance` and `--kui-from-scale` but never `--kui-from-opacity` — this primitive
2521
+ // doesn't declare `CHANNEL.opacity`. Spreading the whole shared `distance` object used to expose
2522
+ // `opacity:` as an apparently-valid, silently-inert parameter (same dead-parameter shape as
2523
+ // `flip-3d`'s old `perspective`, fixed above).
1693
2524
  cssPrimitive("scale-move", [CHANNEL.scale, CHANNEL.translate], {
2525
+ timelines: ENTRANCE_TIMELINES,
1694
2526
  parameters: {
1695
- ...distance,
2527
+ distance: distance.distance,
1696
2528
  scale: { type: "number", default: "0.92", cssProperty: "--kui-from-scale" }
1697
2529
  }
1698
2530
  }),
1699
2531
  cssPrimitive("rotate", [CHANNEL.rotate], {
2532
+ timelines: ENTRANCE_TIMELINES,
1700
2533
  parameters: { angle: { type: "angle", default: "-8deg", cssProperty: "--kui-from-angle" } }
1701
2534
  }),
2535
+ // `distance.distance` only, not `...distance`: `kui-roll-in`/`-out` (entrance.css) write
2536
+ // `rotate`/`translate`, never `opacity` — this primitive doesn't declare `CHANNEL.opacity`. Same
2537
+ // dead-parameter shape as `scale-move` above.
1702
2538
  cssPrimitive("roll", [CHANNEL.rotate, CHANNEL.translate], {
2539
+ timelines: ENTRANCE_TIMELINES,
1703
2540
  parameters: {
1704
- ...distance,
2541
+ distance: distance.distance,
1705
2542
  angle: { type: "angle", default: "-120deg", cssProperty: "--kui-from-angle" }
1706
2543
  }
1707
2544
  }),
1708
- cssPrimitive("flip-3d", [CHANNEL.rotate], {
2545
+ // `skew`, not `rotate`: entrance.css's keyframes write `transform: perspective(...)
2546
+ // rotateX/Y(...)`, not the individual `rotate:` property — the `perspective` parameter below
2547
+ // used to compile cleanly and do nothing, because nothing read it. See entrance.css's own
2548
+ // comment on `kui-flip-in-x` for the fix; `CHANNEL.skew` is this catalog's name for "claims the
2549
+ // whole `transform` shorthand" (`core/types.ts`), shared with `scroll-skew` and `flip-face`.
2550
+ cssPrimitive("flip-3d", [CHANNEL.skew], {
2551
+ timelines: ENTRANCE_TIMELINES,
1709
2552
  parameters: {
1710
2553
  angle: { type: "angle", default: "90deg", cssProperty: "--kui-from-angle" },
1711
2554
  perspective: { type: "length", default: "1200px", cssProperty: "--kui-perspective" }
1712
2555
  }
1713
2556
  }),
1714
2557
  cssPrimitive("blur", [CHANNEL.filter], {
2558
+ timelines: ENTRANCE_TIMELINES,
1715
2559
  parameters: { blur: { type: "length", default: "12px", cssProperty: "--kui-blur" } }
1716
2560
  }),
1717
2561
  // Purpose-built combination: one keyframe, so opacity is written once instead of twice.
1718
2562
  cssPrimitive("reveal-blur", [CHANNEL.opacity, CHANNEL.translate, CHANNEL.filter], {
2563
+ timelines: ENTRANCE_TIMELINES,
1719
2564
  parameters: { ...distance, blur: { type: "length", default: "12px", cssProperty: "--kui-blur" } }
1720
2565
  }),
1721
2566
  // --- scroll-linked ---------------------------------------------------------------------
1722
2567
  // These are progress-linked, not time-triggered: they reverse as the user scrolls back.
1723
2568
  // That is by design and is why `timeline:` is a different axis from `on:`.
2569
+ // `distance.distance` only, not the whole `distance` object: `kui-parallax-y`/`-x` (scroll.css)
2570
+ // write only `translate` — this primitive doesn't declare `CHANNEL.opacity`. Same dead-parameter
2571
+ // shape as `scale-move`/`roll` above; `parallax-y`/`parallax-x`/`depth-layer` never read
2572
+ // `--kui-from-opacity`.
1724
2573
  cssPrimitive("parallax", [CHANNEL.translate], {
1725
- parameters: distance,
1726
- timelines: ["view", "scroll"],
2574
+ parameters: { distance: distance.distance },
2575
+ timelines: ["view", "scroll", "pin"],
1727
2576
  activations: ["manual"],
1728
2577
  reducedMotion: "disable",
1729
2578
  perfClass: "compositor"
1730
2579
  }),
2580
+ // `from` exists because the resting end was hardcoded (scale 1 / rotate 0deg), which fixed
2581
+ // these to "grow slightly" and "tilt slightly" — a scroll-driven element that should sweep in
2582
+ // from a quarter-size or from half a turn away had no way to say so. Same `--kui-from-*`
2583
+ // properties the entrance primitives already use, so the two stay spellable the same way.
1731
2584
  cssPrimitive("parallax-scale", [CHANNEL.scale], {
1732
- parameters: { scale: { type: "number", default: "1.2", cssProperty: "--kui-to-scale" } },
1733
- timelines: ["view", "scroll"],
2585
+ parameters: {
2586
+ scale: { type: "number", default: "1.2", cssProperty: "--kui-to-scale" },
2587
+ from: { type: "number", default: "1", cssProperty: "--kui-from-scale" }
2588
+ },
2589
+ timelines: ["view", "scroll", "pin"],
1734
2590
  activations: ["manual"],
1735
2591
  reducedMotion: "disable"
1736
2592
  }),
1737
2593
  cssPrimitive("parallax-rotate", [CHANNEL.rotate], {
1738
- parameters: { angle: { type: "angle", default: "12deg", cssProperty: "--kui-to-angle" } },
1739
- timelines: ["view", "scroll"],
2594
+ parameters: {
2595
+ angle: { type: "angle", default: "12deg", cssProperty: "--kui-to-angle" },
2596
+ from: { type: "angle", default: "0deg", cssProperty: "--kui-from-angle" }
2597
+ },
2598
+ timelines: ["view", "scroll", "pin"],
1740
2599
  activations: ["manual"],
1741
2600
  reducedMotion: "disable"
1742
2601
  }),
1743
2602
  cssPrimitive("scroll-fade", [CHANNEL.opacity], {
1744
2603
  parameters: { opacity: { type: "number", default: "0", cssProperty: "--kui-from-opacity" } },
1745
- timelines: ["view", "scroll"],
2604
+ timelines: ["view", "scroll", "pin"],
2605
+ activations: ["manual"],
2606
+ reducedMotion: "disable"
2607
+ }),
2608
+ // `filter`, not `opacity` — it collides with `blur`, and declaring the real channel is what
2609
+ // makes `channels.ts` say so instead of letting the two silently overwrite each other's
2610
+ // `filter` declaration.
2611
+ cssPrimitive("desaturate", [CHANNEL.filter], {
2612
+ parameters: {
2613
+ from: { type: "percentage", default: "100%", cssProperty: "--kui-from-grayscale" },
2614
+ to: { type: "percentage", default: "0%", cssProperty: "--kui-to-grayscale" }
2615
+ },
2616
+ timelines: ["view", "scroll", "pin"],
2617
+ activations: ["manual"],
2618
+ reducedMotion: "disable",
2619
+ perfClass: "paint"
2620
+ }),
2621
+ /*
2622
+ * `transform`, not one of the independent transform properties, because CSS never shipped a
2623
+ * standalone `skew:`. That is also why `skew` is its own channel rather than folded in with
2624
+ * `rotate`: writing `transform` replaces the entire shorthand, so a skew composed with anything
2625
+ * else that wrote `transform` would silently win. Nothing else in the catalog does — every other
2626
+ * transform in the library goes through `translate`/`rotate`/`scale` — so the shorthand is free.
2627
+ */
2628
+ cssPrimitive("skew", [CHANNEL.skew], {
2629
+ parameters: {
2630
+ from: { type: "angle", default: "8deg", cssProperty: "--kui-from-skew" },
2631
+ to: { type: "angle", default: "0deg", cssProperty: "--kui-to-skew" }
2632
+ },
2633
+ timelines: ["view", "scroll", "pin"],
1746
2634
  activations: ["manual"],
1747
2635
  reducedMotion: "disable"
1748
2636
  }),
@@ -1762,13 +2650,14 @@ var PRIMITIVES = [
1762
2650
  })
1763
2651
  ];
1764
2652
  var p = (name, primitive, keyframes, params) => ({ name, primitive, keyframes, ...params ? { params } : {} });
2653
+ var pIn = (name, primitive, keyframes, params) => ({ ...p(name, primitive, keyframes, params), cloak: true });
1765
2654
  var FADE = [
1766
- p("fade-in", "reveal", "kui-in"),
2655
+ pIn("fade-in", "reveal", "kui-in"),
1767
2656
  p("fade-out", "reveal", "kui-out"),
1768
- p("fade-up", "reveal", "kui-in-up"),
1769
- p("fade-down", "reveal", "kui-in-down"),
1770
- p("fade-left", "reveal", "kui-in-left"),
1771
- p("fade-right", "reveal", "kui-in-right"),
2657
+ pIn("fade-up", "reveal", "kui-in-up"),
2658
+ pIn("fade-down", "reveal", "kui-in-down"),
2659
+ pIn("fade-left", "reveal", "kui-in-left"),
2660
+ pIn("fade-right", "reveal", "kui-in-right"),
1772
2661
  p("fade-out-up", "reveal", "kui-out-up"),
1773
2662
  p("fade-out-down", "reveal", "kui-out-down"),
1774
2663
  p("fade-out-left", "reveal", "kui-out-left"),
@@ -1776,56 +2665,56 @@ var FADE = [
1776
2665
  ];
1777
2666
  var SLIDE_PARAMS = { distance: "100px", opacity: "1" };
1778
2667
  var SLIDE = [
1779
- p("slide-up", "reveal", "kui-in-up", SLIDE_PARAMS),
1780
- p("slide-down", "reveal", "kui-in-down", SLIDE_PARAMS),
1781
- p("slide-left", "reveal", "kui-in-left", SLIDE_PARAMS),
1782
- p("slide-right", "reveal", "kui-in-right", SLIDE_PARAMS),
2668
+ pIn("slide-up", "reveal", "kui-in-up", SLIDE_PARAMS),
2669
+ pIn("slide-down", "reveal", "kui-in-down", SLIDE_PARAMS),
2670
+ pIn("slide-left", "reveal", "kui-in-left", SLIDE_PARAMS),
2671
+ pIn("slide-right", "reveal", "kui-in-right", SLIDE_PARAMS),
1783
2672
  p("slide-out-up", "reveal", "kui-out-up", SLIDE_PARAMS),
1784
2673
  p("slide-out-down", "reveal", "kui-out-down", SLIDE_PARAMS),
1785
2674
  p("slide-out-left", "reveal", "kui-out-left", SLIDE_PARAMS),
1786
2675
  p("slide-out-right", "reveal", "kui-out-right", SLIDE_PARAMS)
1787
2676
  ];
1788
2677
  var LOGICAL = [
1789
- p("slide-inline-start", "reveal", "kui-in-inline-start", SLIDE_PARAMS),
1790
- p("slide-inline-end", "reveal", "kui-in-inline-end", SLIDE_PARAMS),
1791
- p("slide-block-start", "reveal", "kui-in-up", SLIDE_PARAMS),
1792
- p("slide-block-end", "reveal", "kui-in-down", SLIDE_PARAMS)
2678
+ pIn("slide-inline-start", "reveal", "kui-in-inline-start", SLIDE_PARAMS),
2679
+ pIn("slide-inline-end", "reveal", "kui-in-inline-end", SLIDE_PARAMS),
2680
+ pIn("slide-block-start", "reveal", "kui-in-up", SLIDE_PARAMS),
2681
+ pIn("slide-block-end", "reveal", "kui-in-down", SLIDE_PARAMS)
1793
2682
  ];
1794
2683
  var ZOOM = [
1795
- p("zoom-in", "scale", "kui-zoom-in"),
2684
+ pIn("zoom-in", "scale", "kui-zoom-in"),
1796
2685
  p("zoom-out", "scale", "kui-zoom-out"),
1797
- p("pop-in", "scale", "kui-zoom-in", { scale: "0.6", ease: "back-out" }),
2686
+ pIn("pop-in", "scale", "kui-zoom-in", { scale: "0.6", ease: "back-out" }),
1798
2687
  p("pop-out", "scale", "kui-zoom-out", { scale: "0.6", ease: "back-in" }),
1799
- p("zoom-in-up", "scale-move", "kui-zoom-in-up"),
1800
- p("zoom-in-down", "scale-move", "kui-zoom-in-down")
2688
+ pIn("zoom-in-up", "scale-move", "kui-zoom-in-up"),
2689
+ pIn("zoom-in-down", "scale-move", "kui-zoom-in-down")
1801
2690
  ];
1802
2691
  var FLIP = [
1803
- p("flip-in-x", "flip-3d", "kui-flip-in-x"),
1804
- p("flip-in-y", "flip-3d", "kui-flip-in-y"),
2692
+ pIn("flip-in-x", "flip-3d", "kui-flip-in-x"),
2693
+ pIn("flip-in-y", "flip-3d", "kui-flip-in-y"),
1805
2694
  p("flip-out-x", "flip-3d", "kui-flip-out-x"),
1806
2695
  p("flip-out-y", "flip-3d", "kui-flip-out-y")
1807
2696
  ];
1808
2697
  var ROTATE = [
1809
- p("rotate-in", "rotate", "kui-rotate-in"),
2698
+ pIn("rotate-in", "rotate", "kui-rotate-in"),
1810
2699
  p("rotate-out", "rotate", "kui-rotate-out"),
1811
- p("rotate-in-left", "rotate", "kui-rotate-in", { angle: "-45deg" }),
1812
- p("rotate-in-right", "rotate", "kui-rotate-in", { angle: "45deg" }),
1813
- p("roll-in", "roll", "kui-roll-in"),
2700
+ pIn("rotate-in-left", "rotate", "kui-rotate-in", { angle: "-45deg" }),
2701
+ pIn("rotate-in-right", "rotate", "kui-rotate-in", { angle: "45deg" }),
2702
+ pIn("roll-in", "roll", "kui-roll-in"),
1814
2703
  p("roll-out", "roll", "kui-roll-out"),
1815
- p("swing-in", "rotate", "kui-swing-in", { angle: "-15deg", ease: "back-out" })
2704
+ pIn("swing-in", "rotate", "kui-swing-in", { angle: "-15deg", ease: "back-out" })
1816
2705
  ];
1817
2706
  var BLUR = [
1818
- p("blur-in", "blur", "kui-blur-in"),
2707
+ pIn("blur-in", "blur", "kui-blur-in"),
1819
2708
  p("blur-out", "blur", "kui-blur-out"),
1820
- p("fade-blur-up", "reveal-blur", "kui-fade-blur-up"),
1821
- p("fade-blur-in", "reveal-blur", "kui-fade-blur-in")
2709
+ pIn("fade-blur-up", "reveal-blur", "kui-fade-blur-up"),
2710
+ pIn("fade-blur-in", "reveal-blur", "kui-fade-blur-in")
1822
2711
  ];
1823
2712
  var CHARACTER = [
1824
- p("bounce-in", "scale", "kui-zoom-in", { scale: "0.3", ease: "back-out" }),
1825
- p("bounce-in-up", "reveal", "kui-in-up", { distance: "60px", ease: "back-out" }),
1826
- p("bounce-in-down", "reveal", "kui-in-down", { distance: "60px", ease: "back-out" }),
1827
- p("back-in-up", "reveal", "kui-in-up", { distance: "120px", ease: "expo-out" }),
1828
- p("back-in-down", "reveal", "kui-in-down", { distance: "120px", ease: "expo-out" })
2713
+ pIn("bounce-in", "scale", "kui-zoom-in", { scale: "0.3", ease: "bounce" }),
2714
+ pIn("bounce-in-up", "reveal", "kui-in-up", { distance: "60px", ease: "back-out" }),
2715
+ pIn("bounce-in-down", "reveal", "kui-in-down", { distance: "60px", ease: "back-out" }),
2716
+ pIn("back-in-up", "reveal", "kui-in-up", { distance: "120px", ease: "expo-out" }),
2717
+ pIn("back-in-down", "reveal", "kui-in-down", { distance: "120px", ease: "expo-out" })
1829
2718
  ];
1830
2719
  var SCROLL = [
1831
2720
  p("parallax-y", "parallax", "kui-parallax-y"),
@@ -1834,11 +2723,14 @@ var SCROLL = [
1834
2723
  p("parallax-rotate", "parallax-rotate", "kui-parallax-rotate"),
1835
2724
  p("depth-layer", "parallax", "kui-parallax-y", { distance: "200px" }),
1836
2725
  p("scroll-fade", "scroll-fade", "kui-scroll-fade"),
2726
+ p("scroll-desaturate", "desaturate", "kui-desaturate"),
2727
+ p("scroll-skew", "skew", "kui-scroll-skew"),
1837
2728
  p("scroll-progress-bar", "progress", "kui-progress-x"),
2729
+ p("scroll-progress-bar-y", "progress", "kui-progress-y"),
1838
2730
  p("scroll-progress-ring", "progress-stroke", "kui-progress-ring"),
1839
2731
  // `reveal-repeat` was removed: it was byte-identical to `reveal-once`, and the activation
1840
2732
  // binder unobserves after first entry, so a repeating reveal is not implementable yet.
1841
- p("reveal-once", "reveal", "kui-in-up")
2733
+ pIn("reveal-once", "reveal", "kui-in-up")
1842
2734
  ];
1843
2735
  var PRESETS = [
1844
2736
  ...FADE,
@@ -1861,10 +2753,63 @@ function registerCore(registry) {
1861
2753
  return registry;
1862
2754
  }
1863
2755
 
2756
+ // src/effects/step-marking.ts
2757
+ var STEP_STATE_ATTR = "data-kui-step-state";
2758
+ function selectorBreadth(selector, doc) {
2759
+ try {
2760
+ if (doc.documentElement.matches(selector)) return "document-wide";
2761
+ if (doc.body?.matches(selector)) return "document-wide";
2762
+ return "ok";
2763
+ } catch {
2764
+ return "invalid";
2765
+ }
2766
+ }
2767
+ function resolveTarget(selector, ctx, effect) {
2768
+ if (!selector) return selector;
2769
+ const breadth = selectorBreadth(selector, ctx.doc);
2770
+ if (breadth === "invalid") {
2771
+ ctx.warn(`${effect} target "${selector}" is not a valid selector and will be ignored`);
2772
+ return "";
2773
+ }
2774
+ if (breadth === "document-wide") {
2775
+ ctx.warn(`${effect} target "${selector}" matches the whole document and will be ignored`);
2776
+ return "";
2777
+ }
2778
+ return selector;
2779
+ }
2780
+ function createStepMarker(resolve) {
2781
+ const ledgers = /* @__PURE__ */ new Map();
2782
+ return {
2783
+ mark(index) {
2784
+ const seen = /* @__PURE__ */ new Map();
2785
+ for (const node of resolve()) {
2786
+ const parent = node.parentElement;
2787
+ const position = seen.get(parent) ?? 0;
2788
+ seen.set(parent, position + 1);
2789
+ let ledger = ledgers.get(node);
2790
+ if (!ledger) {
2791
+ ledger = createAttributeLedger(node);
2792
+ ledgers.set(node, ledger);
2793
+ }
2794
+ ledger.set(STEP_STATE_ATTR, stepStateFor(position, index));
2795
+ }
2796
+ },
2797
+ restore() {
2798
+ for (const ledger of ledgers.values()) ledger.restore();
2799
+ ledgers.clear();
2800
+ }
2801
+ };
2802
+ }
2803
+ function stepStateFor(position, index) {
2804
+ if (position < index) return "before";
2805
+ if (position === index) return "active";
2806
+ return "after";
2807
+ }
2808
+
1864
2809
  // src/effects/forms/primitives.ts
1865
2810
  var timing = {
1866
2811
  duration: { type: "time", default: "400ms", cssProperty: "--kui-duration" },
1867
- delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" },
2812
+ ...TRIGGER_DELAY_PARAM,
1868
2813
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
1869
2814
  };
1870
2815
  var NATIVE_STATE_PRIMITIVE = {
@@ -1979,10 +2924,18 @@ var RANGE_FILL_PRIMITIVE = jsInputPrimitive(
1979
2924
  function nextStep(step, total) {
1980
2925
  return total > 0 ? (step + 1) % total : 0;
1981
2926
  }
1982
- function prepareStepProgress(el, params) {
2927
+ function prepareStepProgress(el, params, ctx) {
1983
2928
  const total = Math.max(1, Math.round(params.num("steps", 4)));
2929
+ const selector = resolveTarget(params.text("target"), ctx, "step-progress");
2930
+ const marker = createStepMarker(
2931
+ () => selector ? ctx.doc.querySelectorAll(selector) : el.children
2932
+ );
2933
+ const self = createAttributeLedger(el);
1984
2934
  let step = 0;
1985
- const render = () => el.setAttribute("data-kui-step", String(step));
2935
+ const render = () => {
2936
+ self.set("data-kui-step", String(step));
2937
+ marker.mark(step);
2938
+ };
1986
2939
  const advance = () => {
1987
2940
  step = nextStep(step, total);
1988
2941
  render();
@@ -1991,13 +2944,17 @@ function prepareStepProgress(el, params) {
1991
2944
  render();
1992
2945
  return () => {
1993
2946
  el.removeEventListener("click", advance);
1994
- el.removeAttribute("data-kui-step");
2947
+ self.restore();
2948
+ marker.restore();
1995
2949
  };
1996
2950
  }
1997
2951
  var STEP_PROGRESS_PRIMITIVE = jsInputPrimitive(
1998
2952
  "step-progress",
1999
2953
  ["state"],
2000
- { steps: { type: "number", default: "4", cssProperty: "--kui-steps", minimum: 1, maximum: 20, integer: true } },
2954
+ {
2955
+ steps: { type: "number", default: "4", cssProperty: "--kui-steps", minimum: 1, maximum: 20, integer: true },
2956
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
2957
+ },
2001
2958
  deferPrepare(prepareStepProgress)
2002
2959
  );
2003
2960
  function nextSubmitStage(stage) {
@@ -2254,6 +3211,7 @@ function prepareDraggable(el, params, ctx) {
2254
3211
  const resistance = params.num("resistance", 0.55);
2255
3212
  const momentum = params.num("momentum", 0.2);
2256
3213
  const offset = { x: 0, y: 0 };
3214
+ const pickup = { x: 0, y: 0 };
2257
3215
  const deps = springDeps(ctx);
2258
3216
  const runners = {
2259
3217
  x: createSpringRunner(config, (value) => write({ ...offset, x: value }), deps),
@@ -2270,10 +3228,15 @@ function prepareDraggable(el, params, ctx) {
2270
3228
  onStart() {
2271
3229
  runners.x.stop();
2272
3230
  runners.y.stop();
3231
+ pickup.x = offset.x;
3232
+ pickup.y = offset.y;
2273
3233
  el.setAttribute("data-kui-dragging", "true");
2274
3234
  },
2275
3235
  onMove(vector) {
2276
- write({ x: resist(vector.dx, bounds, resistance), y: resist(vector.dy, bounds, resistance) });
3236
+ write({
3237
+ x: resist(pickup.x + vector.dx, bounds, resistance),
3238
+ y: resist(pickup.y + vector.dy, bounds, resistance)
3239
+ });
2277
3240
  },
2278
3241
  onEnd(vector) {
2279
3242
  el.setAttribute("data-kui-dragging", "false");
@@ -2301,8 +3264,9 @@ function settle(runners, offset, vector, mode) {
2301
3264
  runners.y.to(0);
2302
3265
  return;
2303
3266
  }
2304
- runners.x.to(offset.x + vector.vx * mode.momentum);
2305
- runners.y.to(offset.y + vector.vy * mode.momentum);
3267
+ const carry = mode.inertia ? mode.momentum : 0;
3268
+ runners.x.to(offset.x + vector.vx * carry);
3269
+ runners.y.to(offset.y + vector.vy * carry);
2306
3270
  }
2307
3271
  function prepareSwipeable(el, params) {
2308
3272
  const stop = recognise(
@@ -2586,9 +3550,12 @@ function prepareAutoHeight(el, params, ctx) {
2586
3550
  ctx.style.claim("height");
2587
3551
  const duration = effectDurationMs(params, 400);
2588
3552
  let animation = null;
3553
+ let previous = node.getBoundingClientRect().height;
2589
3554
  const observer = watchAttribute(node, params.text("attribute"), () => {
2590
3555
  animation?.cancel();
2591
- animation = animateHeight(node, duration, params.text("ease"));
3556
+ const endpoints = heightEndpoints(node, previous);
3557
+ previous = endpoints.to;
3558
+ animation = animateHeight(node, endpoints, duration, params.text("ease"));
2592
3559
  });
2593
3560
  ctx.invalidate();
2594
3561
  return () => {
@@ -2596,13 +3563,19 @@ function prepareAutoHeight(el, params, ctx) {
2596
3563
  animation?.cancel();
2597
3564
  };
2598
3565
  }
2599
- function animateHeight(node, duration, easing) {
2600
- const from = `${node.getBoundingClientRect().height}px`;
3566
+ function heightEndpoints(node, previous) {
2601
3567
  node.style.removeProperty("height");
2602
- const to = `${node.scrollHeight}px`;
3568
+ const to = node.getBoundingClientRect().height;
3569
+ return { from: previous, to };
3570
+ }
3571
+ function animateHeight(node, endpoints, duration, easing) {
2603
3572
  const animate = node.animate;
2604
3573
  if (typeof animate !== "function") return null;
2605
- return animate.call(node, [{ height: from }, { height: to }], { duration, easing, fill: "none" });
3574
+ return animate.call(
3575
+ node,
3576
+ [{ height: `${endpoints.from}px` }, { height: `${endpoints.to}px` }],
3577
+ { duration, easing, fill: "none" }
3578
+ );
2606
3579
  }
2607
3580
  function prepareIndicator(el, params, ctx) {
2608
3581
  const node = el;
@@ -2732,27 +3705,42 @@ function subscribeScrollTop(el, ctx, onScrollTop) {
2732
3705
  }
2733
3706
  function prepareHeaderShrink(el, params, ctx) {
2734
3707
  const offset = params.num("offset", 120);
2735
- return subscribeScrollTop(el, ctx, (top) => {
3708
+ const state = createAttributeLedger(el);
3709
+ const unsubscribe = subscribeScrollTop(el, ctx, (top) => {
2736
3710
  const progress = offset > 0 ? Math.min(1, Math.max(0, top / offset)) : 1;
2737
3711
  ctx.style.set("--kui-shrink", progress.toFixed(4));
2738
- el.setAttribute("data-kui-shrunk", String(progress >= 1));
3712
+ state.set("data-kui-shrunk", String(progress >= 1));
2739
3713
  });
3714
+ return () => {
3715
+ unsubscribe();
3716
+ state.restore();
3717
+ };
2740
3718
  }
2741
3719
  function prepareHeaderHide(el, params, ctx) {
2742
3720
  const minDelta = params.num("offset", 8);
2743
3721
  let last = 0;
2744
- return subscribeScrollTop(el, ctx, (top) => {
3722
+ const state = createAttributeLedger(el);
3723
+ const unsubscribe = subscribeScrollTop(el, ctx, (top) => {
2745
3724
  const delta = top - last;
2746
3725
  if (Math.abs(delta) < minDelta) return;
2747
- el.setAttribute("data-kui-hidden", String(delta > 0 && top > minDelta));
3726
+ state.set("data-kui-hidden", String(delta > 0 && top > minDelta));
2748
3727
  last = top;
2749
3728
  });
3729
+ return () => {
3730
+ unsubscribe();
3731
+ state.restore();
3732
+ };
2750
3733
  }
2751
3734
  function prepareBackToTop(el, params, ctx) {
2752
3735
  const offset = params.num("offset", 400);
2753
- return subscribeScrollTop(el, ctx, (top) => {
2754
- el.setAttribute("data-kui-visible", String(top > offset));
3736
+ const state = createAttributeLedger(el);
3737
+ const unsubscribe = subscribeScrollTop(el, ctx, (top) => {
3738
+ state.set("data-kui-visible", String(top > offset));
2755
3739
  });
3740
+ return () => {
3741
+ unsubscribe();
3742
+ state.restore();
3743
+ };
2756
3744
  }
2757
3745
  var NAV_JS_PRIMITIVES = [
2758
3746
  navPrimitive(
@@ -2790,13 +3778,38 @@ var domGeometry = (el) => {
2790
3778
  const rect = el.getBoundingClientRect();
2791
3779
  return { top: rect.top, height: rect.height };
2792
3780
  };
3781
+ var domPosition = (el) => {
3782
+ const view = el.ownerDocument?.defaultView;
3783
+ return view ? view.getComputedStyle(el).position : "static";
3784
+ };
3785
+ var domOffsetTop = (el) => {
3786
+ const view = el.ownerDocument?.defaultView;
3787
+ if (!view) return 0;
3788
+ const parsed = Number.parseFloat(view.getComputedStyle(el).top);
3789
+ return Number.isFinite(parsed) ? parsed : 0;
3790
+ };
3791
+ function geometrySource(el, positionOf) {
3792
+ let outermostSticky = null;
3793
+ for (let node = el; node; node = node.parentElement) {
3794
+ if (positionOf(node) === "sticky") outermostSticky = node;
3795
+ }
3796
+ return outermostSticky?.parentElement ?? el;
3797
+ }
3798
+ function sourceTop(el, box, measure, positionOf) {
3799
+ const source = geometrySource(el, positionOf);
3800
+ return source === el ? box.top : measure(source).top;
3801
+ }
2793
3802
  function trackProgress(el, ctx, options, onProgress) {
2794
3803
  const measure = options.measure ?? domGeometry;
3804
+ const positionOf = options.positionOf ?? domPosition;
3805
+ const offsetOf = options.offsetOf ?? domOffsetTop;
2795
3806
  let scrollTop = 0;
2796
3807
  let scrollportTop = 0;
2797
3808
  const geometry2 = createMeasureCache(() => {
2798
3809
  const box = measure(el);
2799
- return { contentTop: box.top - scrollportTop + scrollTop, height: box.height };
3810
+ const top = options.contentAnchor ? measure(options.contentAnchor).top - box.height : sourceTop(el, box, measure, positionOf);
3811
+ const stickyOffset = options.stickyEl ? offsetOf(options.stickyEl) : 0;
3812
+ return { contentTop: top - stickyOffset - scrollportTop + scrollTop, height: box.height };
2800
3813
  });
2801
3814
  return ctx.scheduler.subscribe(ctx.rootFor(el), (frame) => {
2802
3815
  scrollTop = frame.metrics.scrollTop;
@@ -2822,11 +3835,167 @@ function progressFrom(top, span) {
2822
3835
  return clamp01(-top / span);
2823
3836
  }
2824
3837
 
3838
+ // src/effects/scroll-mechanics/scroll-spy.ts
3839
+ function prepareScrollSpy(el, params, ctx) {
3840
+ const sectionsAuthored = params.text("sections");
3841
+ if (sectionsAuthored) {
3842
+ const sectionsSelector = resolveTarget(sectionsAuthored, ctx, "scroll-spy sections");
3843
+ return prepareScrollSpyContainer(el, params, ctx, sectionsSelector);
3844
+ }
3845
+ return prepareScrollSpySingle(el, params, ctx);
3846
+ }
3847
+ function prepareScrollSpySingle(el, params, ctx) {
3848
+ if (params.text("offset-top", "0px") !== "0px") {
3849
+ ctx.warn('scroll-spy "offset-top" has no effect without sections: and is ignored here');
3850
+ }
3851
+ const selector = resolveTarget(params.text("target"), ctx, "scroll-spy");
3852
+ const links = /* @__PURE__ */ new Map();
3853
+ const self = createAttributeLedger(el);
3854
+ let last;
3855
+ const untrack = trackProgress(el, ctx, { distance: params.text("distance") }, (progress) => {
3856
+ const active = progress > 0 && progress < 1;
3857
+ if (active === last) return;
3858
+ last = active;
3859
+ self.set("data-kui-active", String(active));
3860
+ if (selector) markLinks(ctx.doc, selector, active, links);
3861
+ });
3862
+ return continuousSetup(() => {
3863
+ untrack();
3864
+ self.restore();
3865
+ for (const ledger of links.values()) ledger.restore();
3866
+ });
3867
+ }
3868
+ function markLinks(doc, selector, active, links) {
3869
+ for (const link of doc.querySelectorAll(selector)) {
3870
+ let ledger = links.get(link);
3871
+ if (!ledger) {
3872
+ ledger = createAttributeLedger(link);
3873
+ links.set(link, ledger);
3874
+ }
3875
+ ledger.set("data-kui-active", String(active));
3876
+ }
3877
+ }
3878
+ function hrefHash(link) {
3879
+ const href = link.getAttribute("href") ?? "";
3880
+ const at = href.indexOf("#");
3881
+ return at === -1 ? "" : href.slice(at);
3882
+ }
3883
+ function pairSectionsWithLinks(sections, links, ctx, sectionsSelector) {
3884
+ const linksByHash = /* @__PURE__ */ new Map();
3885
+ for (const link of links) {
3886
+ const hash = hrefHash(link);
3887
+ if (hash) linksByHash.set(hash, link);
3888
+ }
3889
+ const claimed = /* @__PURE__ */ new Set();
3890
+ const pairs = sections.map((section) => {
3891
+ if (!section.id) {
3892
+ ctx.warn(
3893
+ `scroll-spy: a section matched by sections:"${sectionsSelector}" has no id and cannot be paired with a link`
3894
+ );
3895
+ return { section, link: null };
3896
+ }
3897
+ const hash = `#${section.id}`;
3898
+ const link = linksByHash.get(hash) ?? null;
3899
+ if (link) claimed.add(hash);
3900
+ return { section, link };
3901
+ });
3902
+ for (const [hash, link] of linksByHash) {
3903
+ if (!claimed.has(hash)) {
3904
+ ctx.warn(`scroll-spy: link "${link.getAttribute("href")}" matches no section id in sections:"${sectionsSelector}"`);
3905
+ }
3906
+ }
3907
+ return pairs;
3908
+ }
3909
+ function offsetTopPixels(authored, frame) {
3910
+ return toPixels(
3911
+ authored,
3912
+ {
3913
+ viewportWidth: frame.metrics.viewportWidth,
3914
+ viewportHeight: frame.metrics.viewportHeight,
3915
+ percentBasis: 0,
3916
+ fontSize: 16,
3917
+ rootFontSize: 16
3918
+ },
3919
+ 0
3920
+ );
3921
+ }
3922
+ function highestReachedIndex(tops, scrollTop, line) {
3923
+ let index = -1;
3924
+ for (let i = 0; i < tops.length; i++) {
3925
+ if (tops[i] - scrollTop - line <= 0) index = i;
3926
+ }
3927
+ return index;
3928
+ }
3929
+ function prepareScrollSpyContainer(el, params, ctx, sectionsSelector) {
3930
+ if (params.text("distance", "100vh") !== "100vh") {
3931
+ ctx.warn('scroll-spy "distance" has no effect with sections: \u2014 each section measures its own height');
3932
+ }
3933
+ const linksSelector = resolveTarget(params.text("target"), ctx, "scroll-spy target");
3934
+ const sections = sectionsSelector ? [...el.querySelectorAll(sectionsSelector)] : [];
3935
+ if (sectionsSelector && sections.length === 0) {
3936
+ ctx.warn(`scroll-spy sections:"${sectionsSelector}" matched nothing inside this element`);
3937
+ }
3938
+ const links = linksSelector ? [...el.querySelectorAll(linksSelector)] : [];
3939
+ const pairs = pairSectionsWithLinks(sections, links, ctx, sectionsSelector);
3940
+ const sectionLedgers = pairs.map((pair) => createAttributeLedger(pair.section));
3941
+ const linkLedgers = /* @__PURE__ */ new Map();
3942
+ for (const { link } of pairs) {
3943
+ if (link && !linkLedgers.has(link)) linkLedgers.set(link, createAttributeLedger(link));
3944
+ }
3945
+ const offsetAuthored = params.text("offset-top", "0px");
3946
+ let scrollTop = 0;
3947
+ let scrollportTop = 0;
3948
+ const contentTops = createMeasureCache(
3949
+ () => pairs.map(({ section }) => domGeometry(section).top - scrollportTop + scrollTop)
3950
+ );
3951
+ let active = -1;
3952
+ function setActive(index, value) {
3953
+ const pair = pairs[index];
3954
+ sectionLedgers[index].set("data-kui-active", String(value));
3955
+ if (pair.link) linkLedgers.get(pair.link).set("data-kui-active", String(value));
3956
+ }
3957
+ const untrack = ctx.scheduler.subscribe(ctx.rootFor(el), (frame) => {
3958
+ scrollTop = frame.metrics.scrollTop;
3959
+ scrollportTop = frame.metrics.viewportTop;
3960
+ const tops = contentTops.read(frame.epoch);
3961
+ const line = offsetTopPixels(offsetAuthored, frame);
3962
+ const next = highestReachedIndex(tops, scrollTop, line);
3963
+ if (next === active) return;
3964
+ if (active !== -1) setActive(active, false);
3965
+ if (next !== -1) setActive(next, true);
3966
+ active = next;
3967
+ });
3968
+ return continuousSetup(() => {
3969
+ untrack();
3970
+ for (const ledger of sectionLedgers) ledger.restore();
3971
+ for (const ledger of linkLedgers.values()) ledger.restore();
3972
+ });
3973
+ }
3974
+
2825
3975
  // src/effects/scroll-mechanics/primitives.ts
2826
3976
  var PROGRESS_VAR = "--kui-progress";
2827
3977
  var distanceParam = {
2828
3978
  distance: { type: "length", default: "100vh", cssProperty: "--kui-distance" }
2829
3979
  };
3980
+ var stickyParams = {
3981
+ "offset-top": {
3982
+ type: "length",
3983
+ default: "var(--kui-pin-offset, 0px)",
3984
+ cssProperty: "--kui-offset-top"
3985
+ },
3986
+ spacer: {
3987
+ type: "keyword",
3988
+ default: "false",
3989
+ cssProperty: "--kui-spacer",
3990
+ values: ["true", "false"]
3991
+ }
3992
+ };
3993
+ function installSticky(node, params, ctx) {
3994
+ ctx.style.set("position", "sticky");
3995
+ ctx.style.set("top", params.text("offset-top", "var(--kui-pin-offset, 0px)"));
3996
+ const inserted = params.is("spacer") ? insertSpacer(node, params.text("distance"), ctx) : null;
3997
+ return { spacer: inserted?.spacer ?? null, dispose: () => inserted?.remove() };
3998
+ }
2830
3999
  function scrollPrimitive(spec) {
2831
4000
  const { id, channels, parameters, prepare, perfClass = "compositor" } = spec;
2832
4001
  return {
@@ -2834,7 +4003,13 @@ function scrollPrimitive(spec) {
2834
4003
  renderer: "javascript",
2835
4004
  channels,
2836
4005
  parameters,
2837
- supportedTimelines: ["time", "view", "scroll"],
4006
+ // Accepted, never read: these primitives read scroll position themselves and are never driven
4007
+ // by an `animation-timeline`. The list exists so that composing the driver with the effects it
4008
+ // drives — `data-kui="pin-section distance:200vh, parallax-rotate ... "` plus `timeline:pin` —
4009
+ // survives `compile.ts`'s `intersect`. Without it the intersection empties, `style-plan.ts`
4010
+ // refuses the timeline, and the scrub silently degrades to a one-shot. See `TIMELINE_AGNOSTIC`
4011
+ // (`effects/shared.ts`) for why the name says abstention rather than support.
4012
+ supportedTimelines: TIMELINE_AGNOSTIC,
2838
4013
  supportedActivations: ["manual", "load", "enter"],
2839
4014
  defaultActivation: "load",
2840
4015
  perfClass,
@@ -2847,19 +4022,17 @@ function writeProgress(ctx, progress) {
2847
4022
  }
2848
4023
  function preparePin(el, params, ctx) {
2849
4024
  const node = el;
2850
- ctx.style.set("position", "sticky");
2851
- ctx.style.set("top", params.text("offset", "0px"));
2852
- const removeSpacer = params.is("spacer") ? insertSpacer(node, params.text("distance"), ctx) : null;
4025
+ const { dispose: unstick } = installSticky(node, params, ctx);
2853
4026
  const tracked = node.parentElement ?? el;
2854
- const untrack = trackProgress(tracked, ctx, { distance: params.text("distance") }, (progress) => {
4027
+ const untrack = trackProgress(tracked, ctx, { distance: params.text("distance"), stickyEl: node }, (progress) => {
2855
4028
  writeProgress(ctx, progress);
2856
4029
  el.setAttribute("data-kui-pinned", progress > 0 && progress < 1 ? "true" : "false");
2857
4030
  });
2858
- return () => {
4031
+ return continuousSetup(() => {
2859
4032
  untrack();
2860
- removeSpacer?.();
4033
+ unstick();
2861
4034
  el.removeAttribute("data-kui-pinned");
2862
- };
4035
+ });
2863
4036
  }
2864
4037
  function insertSpacer(node, distance2, ctx) {
2865
4038
  const spacer = ctx.doc.createElement("div");
@@ -2869,34 +4042,81 @@ function insertSpacer(node, distance2, ctx) {
2869
4042
  spacer.style.pointerEvents = "none";
2870
4043
  node.after(spacer);
2871
4044
  ctx.invalidate();
2872
- return () => {
2873
- spacer.remove();
2874
- ctx.invalidate();
4045
+ return {
4046
+ spacer,
4047
+ remove: () => {
4048
+ spacer.remove();
4049
+ ctx.invalidate();
4050
+ }
2875
4051
  };
2876
4052
  }
2877
4053
  function prepareProgress(el, params, ctx) {
2878
4054
  const steps = Math.max(0, Math.round(params.num("steps", 0)));
4055
+ const selector = resolveTarget(params.text("target"), ctx, "scrollytelling-step");
4056
+ const marker = createStepMarker(() => ctx.doc.querySelectorAll(selector));
4057
+ const self = createAttributeLedger(el);
4058
+ let lastIndex;
2879
4059
  const untrack = trackProgress(el, ctx, { distance: params.text("distance") }, (progress) => {
2880
4060
  writeProgress(ctx, progress);
2881
- if (steps > 0) {
2882
- const index = Math.min(steps - 1, Math.floor(progress * steps));
2883
- el.setAttribute("data-kui-step", String(index));
2884
- }
4061
+ if (steps === 0) return;
4062
+ const index = Math.min(steps - 1, Math.floor(progress * steps));
4063
+ if (index === lastIndex) return;
4064
+ lastIndex = index;
4065
+ self.set("data-kui-step", String(index));
4066
+ ctx.style.set("--kui-step", String(index));
4067
+ if (selector) marker.mark(index);
2885
4068
  });
2886
- return () => {
4069
+ return continuousSetup(() => {
2887
4070
  untrack();
2888
- el.removeAttribute("data-kui-step");
2889
- };
4071
+ self.restore();
4072
+ marker.restore();
4073
+ });
2890
4074
  }
2891
4075
  function prepareHorizontal(el, params, ctx) {
2892
- const node = el;
4076
+ const selector = resolveTarget(params.text("target"), ctx, "horizontal-scroll");
4077
+ if (!selector) return prepareBareTrack(el, params, ctx);
4078
+ const track = el.querySelector(selector);
4079
+ if (!track) {
4080
+ ctx.warn(`horizontal-scroll target "${selector}" matched nothing inside this element`);
4081
+ return () => {
4082
+ };
4083
+ }
4084
+ return prepareManagedTrack(el, track, params, ctx);
4085
+ }
4086
+ function prepareBareTrack(node, params, ctx) {
2893
4087
  const authored = params.text("travel", "auto");
2894
4088
  const travel = createMeasureCache(() => trackTravel(node, authored, node.ownerDocument));
2895
- const untrack = trackProgress(el, ctx, { distance: params.text("distance") }, (progress, frame) => {
2896
- ctx.style.set("translate", `${-progress * travel.read(frame.epoch)}px 0`);
4089
+ return continuousSetup(
4090
+ trackProgress(node, ctx, { distance: params.text("distance") }, (progress, frame) => {
4091
+ ctx.style.set("translate", `${-progress * travel.read(frame.epoch)}px 0`);
4092
+ writeProgress(ctx, progress);
4093
+ })
4094
+ );
4095
+ }
4096
+ function prepareManagedTrack(host, track, params, ctx) {
4097
+ const offsetTop = params.text("offset-top", "var(--kui-pin-offset, 0px)");
4098
+ ctx.style.set("position", "sticky");
4099
+ ctx.style.set("top", offsetTop);
4100
+ ctx.style.set("height", `calc(100vh - ${offsetTop})`);
4101
+ ctx.style.set("overflow", "hidden");
4102
+ ctx.style.set("display", "grid");
4103
+ ctx.style.set("align-content", "center");
4104
+ const { remove: removeSpacer } = insertSpacer(host, params.text("distance"), ctx);
4105
+ const rail = createStyleLedger(track);
4106
+ rail.set("display", "flex");
4107
+ rail.set("width", "max-content");
4108
+ const authored = params.text("travel", "auto");
4109
+ const travel = createMeasureCache(() => trackTravel(track, authored, track.ownerDocument));
4110
+ const tracked = host.parentElement ?? host;
4111
+ const untrack = trackProgress(tracked, ctx, { distance: params.text("distance"), stickyEl: host }, (progress, frame) => {
4112
+ rail.set("translate", `${-progress * travel.read(frame.epoch)}px 0`);
2897
4113
  writeProgress(ctx, progress);
2898
4114
  });
2899
- return untrack;
4115
+ return continuousSetup(() => {
4116
+ untrack();
4117
+ rail.restore();
4118
+ removeSpacer();
4119
+ });
2900
4120
  }
2901
4121
  function trackTravel(node, authored, doc) {
2902
4122
  if (authored && authored !== "auto") return toPixels(authored, ABSOLUTE_BASIS, 0);
@@ -2906,11 +4126,41 @@ function trackTravel(node, authored, doc) {
2906
4126
  return Math.max(0, node.scrollWidth - viewportWidth);
2907
4127
  }
2908
4128
  function prepareMediaScrub(el, params, ctx) {
4129
+ const managed = params.is("spacer") ? installSticky(el, params, ctx) : null;
4130
+ const contentAnchor = managed?.spacer ?? void 0;
4131
+ const stickyEl = managed ? el : void 0;
4132
+ const selector = resolveTarget(params.text("target"), ctx, "media-scrub");
4133
+ const scrub = selector ? prepareTargetScrub(el, params, ctx, { selector, contentAnchor, stickyEl }) : prepareSrcScrub(el, params, ctx, { contentAnchor, stickyEl });
4134
+ return continuousSetup(() => {
4135
+ scrub();
4136
+ managed?.dispose();
4137
+ });
4138
+ }
4139
+ function prepareTargetScrub(el, params, ctx, authored) {
4140
+ const { selector, contentAnchor, stickyEl } = authored;
4141
+ const marker = createStepMarker(() => ctx.doc.querySelectorAll(selector));
4142
+ const frames = Math.max(1, ctx.doc.querySelectorAll(selector).length);
4143
+ let lastIndex;
4144
+ const untrack = trackProgress(el, ctx, { distance: params.text("distance"), contentAnchor, stickyEl }, (progress) => {
4145
+ writeProgress(ctx, progress);
4146
+ const index = Math.min(frames - 1, Math.floor(progress * frames));
4147
+ if (index === lastIndex) return;
4148
+ lastIndex = index;
4149
+ marker.mark(index);
4150
+ });
4151
+ marker.mark(0);
4152
+ return () => {
4153
+ untrack();
4154
+ marker.restore();
4155
+ };
4156
+ }
4157
+ function prepareSrcScrub(el, params, ctx, anchors) {
4158
+ const { contentAnchor, stickyEl } = anchors;
2909
4159
  const frames = Math.max(1, Math.round(params.num("frames", 1)));
2910
4160
  const pattern = mediaSrcPattern(params.text("src"), ctx);
2911
4161
  const media = el;
2912
4162
  let lastIndex = -1;
2913
- const untrack = trackProgress(el, ctx, { distance: params.text("distance") }, (progress) => {
4163
+ const untrack = trackProgress(el, ctx, { distance: params.text("distance"), contentAnchor, stickyEl }, (progress) => {
2914
4164
  writeProgress(ctx, progress);
2915
4165
  const index = Math.min(frames - 1, Math.floor(progress * frames));
2916
4166
  if (index === lastIndex) return;
@@ -2934,80 +4184,37 @@ function applyFrame(media, write) {
2934
4184
  if (media.tagName !== "IMG") return;
2935
4185
  if (pattern) media.src = pattern.replace("{i}", String(index).padStart(String(frames).length, "0"));
2936
4186
  }
2937
- function prepareScrollSpy(el, params, ctx) {
2938
- const selector = spyTarget(params.text("target"), ctx);
2939
- const links = /* @__PURE__ */ new Map();
2940
- const self = createAttributeLedger(el);
2941
- let last;
2942
- const untrack = trackProgress(el, ctx, { distance: params.text("distance") }, (progress) => {
2943
- const active = progress > 0 && progress < 1;
2944
- if (active === last) return;
2945
- last = active;
2946
- self.set("data-kui-active", String(active));
2947
- if (selector) markLinks(ctx.doc, selector, active, links);
2948
- });
4187
+ function prepareSmoothScroll(el, params, ctx) {
4188
+ ctx.style.set("scroll-behavior", params.text("behavior", "smooth"));
2949
4189
  return () => {
2950
- untrack();
2951
- self.restore();
2952
- for (const ledger of links.values()) ledger.restore();
2953
4190
  };
2954
4191
  }
2955
- function spyTarget(selector, ctx) {
2956
- if (!selector) return selector;
2957
- const breadth = selectorBreadth(selector, ctx.doc);
2958
- if (breadth === "invalid") {
2959
- ctx.warn(`scroll-spy target "${selector}" is not a valid selector and will be ignored`);
2960
- return "";
2961
- }
2962
- if (breadth === "document-wide") {
2963
- ctx.warn(`scroll-spy target "${selector}" matches the whole document and will be ignored`);
2964
- return "";
2965
- }
2966
- return selector;
2967
- }
2968
- function selectorBreadth(selector, doc) {
2969
- try {
2970
- if (doc.documentElement.matches(selector)) return "document-wide";
2971
- if (doc.body?.matches(selector)) return "document-wide";
2972
- return "ok";
2973
- } catch {
2974
- return "invalid";
2975
- }
2976
- }
2977
- function markLinks(doc, selector, active, links) {
2978
- for (const link of doc.querySelectorAll(selector)) {
2979
- let ledger = links.get(link);
2980
- if (!ledger) {
2981
- ledger = createAttributeLedger(link);
2982
- links.set(link, ledger);
2983
- }
2984
- ledger.set("data-kui-active", String(active));
2985
- }
2986
- }
2987
4192
  function prepareSnap(el, params, ctx) {
2988
- ctx.style.set(
2989
- "scroll-snap-type",
2990
- `${params.is("axis", "x") ? "x" : "y"} ${params.text("strictness", "mandatory")}`
2991
- );
2992
- const childLedgers = [...el.children].map((child) => createStyleLedger(child));
4193
+ const axis = params.is("axis", "x") ? "x" : "y";
4194
+ ctx.style.set("scroll-snap-type", `${axis} ${params.text("strictness", "mandatory")}`);
4195
+ const selector = resolveTarget(params.text("target"), ctx, "scroll-snap");
4196
+ const items = selector ? [...el.querySelectorAll(selector)] : [...el.children];
4197
+ if (selector && items.length === 0) {
4198
+ ctx.warn(`scroll-snap target "${selector}" matched nothing inside this element`);
4199
+ }
4200
+ if (selector) installSnapContainer(axis, ctx);
4201
+ const childLedgers = items.map((child) => createStyleLedger(child));
2993
4202
  for (const ledger of childLedgers) ledger.set("scroll-snap-align", params.text("align", "start"));
2994
4203
  return () => {
2995
4204
  for (const ledger of childLedgers) ledger.restore();
2996
4205
  };
2997
4206
  }
4207
+ function installSnapContainer(axis, ctx) {
4208
+ ctx.style.set(axis === "x" ? "overflow-x" : "overflow-y", "auto");
4209
+ if (axis === "x") ctx.style.set("display", "flex");
4210
+ }
2998
4211
  var SCROLL_PRIMITIVES = [
2999
4212
  scrollPrimitive({
3000
4213
  id: "pin",
3001
4214
  channels: ["layout", "progress"],
3002
4215
  parameters: {
3003
4216
  ...distanceParam,
3004
- offset: { type: "length", default: "0px", cssProperty: "--kui-offset" },
3005
- spacer: {
3006
- type: "keyword",
3007
- default: "false",
3008
- cssProperty: "--kui-spacer",
3009
- values: ["true", "false"]
3010
- }
4217
+ ...stickyParams
3011
4218
  },
3012
4219
  prepare: deferPrepare(preparePin),
3013
4220
  perfClass: "layout"
@@ -3017,7 +4224,10 @@ var SCROLL_PRIMITIVES = [
3017
4224
  channels: ["progress"],
3018
4225
  parameters: {
3019
4226
  ...distanceParam,
3020
- steps: { type: "number", default: "0", cssProperty: "--kui-steps" }
4227
+ steps: { type: "number", default: "0", cssProperty: "--kui-steps" },
4228
+ // Same name, same shape and the same validation as scroll-spy's: one `target:` convention
4229
+ // across the library rather than a second word for "the elements this effect marks".
4230
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
3021
4231
  },
3022
4232
  prepare: deferPrepare(prepareProgress)
3023
4233
  }),
@@ -3026,7 +4236,16 @@ var SCROLL_PRIMITIVES = [
3026
4236
  channels: ["translate", "progress"],
3027
4237
  parameters: {
3028
4238
  ...distanceParam,
3029
- travel: { type: "text", default: "auto", cssProperty: "--kui-travel" }
4239
+ travel: { type: "text", default: "auto", cssProperty: "--kui-travel" },
4240
+ // Same name, same shape and the same validation as scroll-spy's and media-scrub's: one
4241
+ // `target:` convention across the library. Naming the row that moves is also what opts this
4242
+ // primitive into owning the stage, the sticky window and the row's layout itself.
4243
+ target: { type: "text", default: "", cssProperty: "--kui-target" },
4244
+ "offset-top": {
4245
+ type: "length",
4246
+ default: "var(--kui-pin-offset, 0px)",
4247
+ cssProperty: "--kui-offset-top"
4248
+ }
3030
4249
  },
3031
4250
  prepare: deferPrepare(prepareHorizontal)
3032
4251
  }),
@@ -3035,8 +4254,13 @@ var SCROLL_PRIMITIVES = [
3035
4254
  channels: ["media", "progress"],
3036
4255
  parameters: {
3037
4256
  ...distanceParam,
4257
+ // A scrub is a hold, so it needs the same two knobs a pin does. Declaring them here is what
4258
+ // lets `sequence-scrub` become one attribute with no wrapper at all.
4259
+ ...stickyParams,
3038
4260
  frames: { type: "number", default: "1", cssProperty: "--kui-frames" },
3039
- src: { type: "text", default: "", cssProperty: "--kui-src" }
4261
+ src: { type: "text", default: "", cssProperty: "--kui-src" },
4262
+ // The preferred form. `frames:`/`src:` remain for sequences too long to author as tags.
4263
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
3040
4264
  },
3041
4265
  prepare: deferPrepare(prepareMediaScrub),
3042
4266
  perfClass: "paint"
@@ -3045,11 +4269,44 @@ var SCROLL_PRIMITIVES = [
3045
4269
  id: "scroll-spy",
3046
4270
  channels: ["state"],
3047
4271
  parameters: {
4272
+ // `distance`: the per-section form only. `offset-top`: the container form only. Each is a
4273
+ // no-op — warned, not silent — in the other; see `prepareScrollSpySingle` and
4274
+ // `prepareScrollSpyContainer`.
3048
4275
  ...distanceParam,
3049
- target: { type: "text", default: "", cssProperty: "--kui-target" }
4276
+ // Same name and meaning in both forms: the link(s) this instance marks. Per-section, the
4277
+ // one link this section names; with `sections:`, every link `target:` matches, each paired
4278
+ // to its own section by `href`. See `prepareScrollSpyContainer`.
4279
+ target: { type: "text", default: "", cssProperty: "--kui-target" },
4280
+ // Presence, not value, selects the container form: authoring this at all switches
4281
+ // `prepareScrollSpy` from one-section-per-instance to one-instance-on-the-shared-ancestor.
4282
+ sections: { type: "text", default: "", cssProperty: "--kui-sections" },
4283
+ "offset-top": { type: "length", default: "0px", cssProperty: "--kui-offset-top" }
3050
4284
  },
3051
4285
  prepare: deferPrepare(prepareScrollSpy)
3052
4286
  }),
4287
+ scrollPrimitive({
4288
+ id: "smooth-scroll",
4289
+ /*
4290
+ * Its own channel, not the `'layout'` it used to share with `pin`, `stacking-cards` and
4291
+ * `scroll-snap`. The channel model exists to stop two effects fighting over the same CSS
4292
+ * property, and this one writes exactly `scroll-behavior` — a property that describes how a
4293
+ * *user-or-script-initiated* scroll is performed, and that no other primitive touches.
4294
+ *
4295
+ * On `'layout'` it made a legitimate pairing impossible. Both `smooth-scroll` and
4296
+ * `scroll-snap` have to sit on the document element to have any effect at all — neither
4297
+ * `scroll-behavior` nor `scroll-snap-type` is propagated to the viewport from `<body>` — so
4298
+ * "apply them to nested elements", the advice the conflict message gives, has no valid
4299
+ * nesting to offer here. `data-kui="smooth-scroll-to, scroll-snap-y"` on `<html>` is the
4300
+ * ordinary way to ask for smooth anchor jumps on a page that also snaps, and it was refused
4301
+ * for a collision that cannot happen: the two write disjoint properties.
4302
+ */
4303
+ channels: ["scroll-behavior"],
4304
+ parameters: {
4305
+ behavior: { type: "keyword", default: "smooth", cssProperty: "--kui-scroll-behavior", values: ["smooth", "auto"] }
4306
+ },
4307
+ prepare: deferPrepare(prepareSmoothScroll),
4308
+ perfClass: "layout"
4309
+ }),
3053
4310
  scrollPrimitive({
3054
4311
  id: "scroll-snap",
3055
4312
  channels: ["layout"],
@@ -3066,7 +4323,11 @@ var SCROLL_PRIMITIVES = [
3066
4323
  default: "start",
3067
4324
  cssProperty: "--kui-snap-align",
3068
4325
  values: ["start", "center", "end"]
3069
- }
4326
+ },
4327
+ // Names the snap items. Also what opts this primitive into owning the scroll container
4328
+ // itself — see `installSnapContainer`. Without it, the direct children are the items and
4329
+ // the page keeps its own `overflow`, exactly as before.
4330
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
3070
4331
  },
3071
4332
  prepare: deferPrepare(prepareSnap),
3072
4333
  perfClass: "layout"
@@ -3089,11 +4350,28 @@ var SCROLL_PRESETS = [
3089
4350
  // --- travel ------------------------------------------------------------------------------
3090
4351
  { name: "horizontal-scroll", primitive: "horizontal-track" },
3091
4352
  // --- media -------------------------------------------------------------------------------
3092
- { name: "sequence-scrub", primitive: "media-scrub" },
4353
+ /*
4354
+ * `spacer:true` is what deletes the `.scrub-stage` wrapper a page used to hand-write.
4355
+ *
4356
+ * It could not be switched on until the tracker stopped measuring against the parent. A scrub
4357
+ * makes itself sticky, and `geometrySource` escapes a sticky subtree by taking its parent — so
4358
+ * with the wrapper gone the scrub was measured against whatever section contained it. Measured
4359
+ * on `demo/scroll.html`: the parent started 926px above the scrub against a 1817px distance, so
4360
+ * progress reached 51% before the element had even stuck and half the sequence played off
4361
+ * screen. The wrapper was not ceremony; it was the tight box that made the parent honest.
4362
+ *
4363
+ * `trackProgress`'s `contentAnchor` is the fix. Progress is read from the spacer, which the
4364
+ * library inserts, is exactly `distance` tall, is never sticky, and moves with the content — so
4365
+ * there is no wrapper to write and nothing to disagree with.
4366
+ *
4367
+ * `video-scrub` stays off: a video positioned by the page is not asking the library for a box.
4368
+ */
4369
+ { name: "sequence-scrub", primitive: "media-scrub", params: { spacer: "true" } },
3093
4370
  { name: "video-scrub", primitive: "media-scrub" },
3094
4371
  // --- navigation --------------------------------------------------------------------------
3095
4372
  { name: "scroll-spy", primitive: "scroll-spy" },
3096
4373
  // --- native CSS passthroughs ---------------------------------------------------------------
4374
+ { name: "smooth-scroll-to", primitive: "smooth-scroll" },
3097
4375
  { name: "scroll-snap-x", primitive: "scroll-snap", params: { axis: "x" } },
3098
4376
  { name: "scroll-snap-y", primitive: "scroll-snap", params: { axis: "y" } }
3099
4377
  ];
@@ -3108,11 +4386,16 @@ var COMMAND = /([mlhvcz])|(-?(?:\d+(?:\.\d+)?|\.\d+))/gi;
3108
4386
  var UNSUPPORTED = /[AaSsQqTt]/;
3109
4387
  function parsePath(d) {
3110
4388
  if (UNSUPPORTED.test(d)) {
3111
- return { segments: [], reason: "arc and shorthand commands (A S Q T) are not supported" };
4389
+ return {
4390
+ segments: [],
4391
+ subpaths: [],
4392
+ reason: "arc and shorthand commands (A S Q T) are not supported"
4393
+ };
3112
4394
  }
3113
4395
  const tokens = [...d.matchAll(COMMAND)].map((m) => m[1] ?? m[2]);
3114
4396
  const state = {
3115
- segments: [],
4397
+ subpaths: [],
4398
+ open: void 0,
3116
4399
  current: { x: 0, y: 0 },
3117
4400
  start: { x: 0, y: 0 },
3118
4401
  command: ""
@@ -3126,18 +4409,33 @@ function parsePath(d) {
3126
4409
  index++;
3127
4410
  continue;
3128
4411
  }
3129
- if (!state.command) return { segments: [], reason: "path must start with a command letter" };
4412
+ if (!state.command) {
4413
+ return { segments: [], subpaths: [], reason: "path must start with a command letter" };
4414
+ }
3130
4415
  index = consume(tokens, index, state);
3131
4416
  }
3132
- if (state.segments.length === 0) return { segments: [], reason: "no drawable segments" };
3133
- return { segments: state.segments };
4417
+ if (state.subpaths.length === 0) {
4418
+ return { segments: [], subpaths: [], reason: "no drawable segments" };
4419
+ }
4420
+ return { segments: state.subpaths.flatMap((sub) => sub.segments), subpaths: state.subpaths };
4421
+ }
4422
+ function pushSegment(state, segment) {
4423
+ if (!state.open) {
4424
+ state.open = { segments: [], closed: false };
4425
+ state.subpaths.push(state.open);
4426
+ }
4427
+ state.open.segments.push(segment);
3134
4428
  }
3135
4429
  var ARITY = { m: 2, l: 2, h: 1, v: 1, c: 6, z: 0 };
3136
4430
  function closeSubpath(state) {
3137
- const { current, start } = state;
3138
- if (Math.abs(current.x - start.x) < 1e-6 && Math.abs(current.y - start.y) < 1e-6) return;
3139
- state.segments.push(lineToCubic(current, start));
4431
+ const { current, start, open } = state;
4432
+ if (!open) return;
4433
+ if (Math.abs(current.x - start.x) >= 1e-6 || Math.abs(current.y - start.y) >= 1e-6) {
4434
+ open.segments.push(lineToCubic(current, start));
4435
+ }
4436
+ open.closed = true;
3140
4437
  state.current = { ...start };
4438
+ state.open = void 0;
3141
4439
  }
3142
4440
  function consume(tokens, index, state) {
3143
4441
  const key = state.command.toLowerCase();
@@ -3149,10 +4447,11 @@ function consume(tokens, index, state) {
3149
4447
  if (key === "m") {
3150
4448
  state.current = next;
3151
4449
  state.start = next;
4450
+ state.open = void 0;
3152
4451
  state.command = relative ? "l" : "L";
3153
4452
  return index + arity;
3154
4453
  }
3155
- state.segments.push(straightOrCubic({ key, args, from: state.current, relative }, next));
4454
+ pushSegment(state, straightOrCubic({ key, args, from: state.current, relative }, next));
3156
4455
  state.current = next;
3157
4456
  return index + arity;
3158
4457
  }
@@ -3215,34 +4514,78 @@ function normaliseCount(segments, target) {
3215
4514
  function chordLength(segment) {
3216
4515
  return Math.hypot(segment.to.x - segment.from.x, segment.to.y - segment.from.y);
3217
4516
  }
3218
- function toPathData(segments) {
3219
- if (segments.length === 0) return "";
3220
- const head = segments[0];
3221
- const parts = [`M${round(head.from.x)},${round(head.from.y)}`];
3222
- for (const s of segments) {
3223
- parts.push(
3224
- `C${round(s.c1.x)},${round(s.c1.y)} ${round(s.c2.x)},${round(s.c2.y)} ${round(s.to.x)},${round(s.to.y)}`
3225
- );
4517
+ function round(value) {
4518
+ return Math.round(value * 100) / 100;
4519
+ }
4520
+ function subpathsToPathData(subpaths) {
4521
+ const parts = [];
4522
+ for (const sub of subpaths) {
4523
+ const head = sub.segments[0];
4524
+ parts.push(`M${round(head.from.x)},${round(head.from.y)}`);
4525
+ for (const s of sub.segments) {
4526
+ parts.push(
4527
+ `C${round(s.c1.x)},${round(s.c1.y)} ${round(s.c2.x)},${round(s.c2.y)} ${round(s.to.x)},${round(s.to.y)}`
4528
+ );
4529
+ }
4530
+ if (sub.closed) parts.push("Z");
3226
4531
  }
3227
4532
  return parts.join(" ");
3228
4533
  }
3229
- function round(value) {
3230
- return Math.round(value * 100) / 100;
4534
+ function centroidOf(subpath) {
4535
+ let x = 0;
4536
+ let y = 0;
4537
+ for (const s of subpath.segments) {
4538
+ x += s.from.x;
4539
+ y += s.from.y;
4540
+ }
4541
+ return { x: x / subpath.segments.length, y: y / subpath.segments.length };
4542
+ }
4543
+ function collapsedLike(partner) {
4544
+ const at = centroidOf(partner);
4545
+ return {
4546
+ segments: partner.segments.map(() => ({
4547
+ from: { ...at },
4548
+ c1: { ...at },
4549
+ c2: { ...at },
4550
+ to: { ...at }
4551
+ })),
4552
+ closed: partner.closed
4553
+ };
4554
+ }
4555
+ function normaliseSubpaths(a, b) {
4556
+ const count = Math.max(a.length, b.length);
4557
+ const from = [];
4558
+ const to = [];
4559
+ for (let i = 0; i < count; i++) {
4560
+ const left = a[i] ?? collapsedLike(b[i]);
4561
+ const right = b[i] ?? collapsedLike(a[i]);
4562
+ const target = Math.max(left.segments.length, right.segments.length);
4563
+ const closed = left.closed && right.closed;
4564
+ from.push({ segments: normaliseCount(left.segments, target), closed });
4565
+ to.push({ segments: normaliseCount(right.segments, target), closed });
4566
+ }
4567
+ return { from, to };
3231
4568
  }
3232
4569
  function createMorph(fromPath, toPath) {
3233
4570
  const a = parsePath(fromPath);
3234
4571
  const b = parsePath(toPath);
3235
4572
  if (a.reason) return { reason: `start path: ${a.reason}` };
3236
4573
  if (b.reason) return { reason: `end path: ${b.reason}` };
3237
- const count = Math.max(a.segments.length, b.segments.length);
3238
- const from = normaliseCount(a.segments, count);
3239
- const to = normaliseCount(b.segments, count);
4574
+ const { from, to } = normaliseSubpaths(a.subpaths, b.subpaths);
4575
+ const count = from.reduce((total, sub) => total + sub.segments.length, 0);
3240
4576
  return {
3241
4577
  morph: {
3242
4578
  segmentCount: count,
3243
4579
  at(t) {
3244
4580
  const clamped = Math.min(1, Math.max(0, t));
3245
- return toPathData(from.map((segment, i) => lerpCubic(segment, to[i], clamped)));
4581
+ return subpathsToPathData(
4582
+ from.map((sub, i) => ({
4583
+ segments: sub.segments.map(
4584
+ (segment, j) => lerpCubic(segment, to[i].segments[j], clamped)
4585
+ ),
4586
+ closed: sub.closed
4587
+ }))
4588
+ );
3246
4589
  }
3247
4590
  }
3248
4591
  };
@@ -3299,6 +4642,35 @@ function prepareMorph(el, params, ctx) {
3299
4642
  path.setAttribute("d", startPath);
3300
4643
  };
3301
4644
  }
4645
+ var PATH_DRAW_PRIMITIVE = cssPrimitive("path-draw", [CHANNEL.stroke], {
4646
+ parameters: {
4647
+ length: { type: "number", default: "100", cssProperty: "--kui-path-length", finite: true }
4648
+ }
4649
+ });
4650
+ var SHAPE_FILL_PRIMITIVE = cssPrimitive("shape-fill", [CHANNEL.clip]);
4651
+ var BAR_GROW_PRIMITIVE = cssPrimitive("bar-grow", [CHANNEL.scale], {
4652
+ parameters: { from: { type: "number", default: "0", cssProperty: "--kui-bar-from" } }
4653
+ });
4654
+ var LOGO_BUILD_PRIMITIVE = cssPrimitive("logo-assemble", [
4655
+ CHANNEL.opacity,
4656
+ CHANNEL.scale,
4657
+ CHANNEL.rotate
4658
+ ]);
4659
+ var ICON_TOGGLE_PRIMITIVE = {
4660
+ id: "icon-toggle",
4661
+ renderer: "javascript",
4662
+ channels: [CHANNEL.translate, CHANNEL.rotate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.clip],
4663
+ parameters: {
4664
+ duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
4665
+ ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
4666
+ },
4667
+ supportedTimelines: ["time"],
4668
+ supportedActivations: ["load"],
4669
+ defaultActivation: "load",
4670
+ perfClass: "compositor",
4671
+ reducedMotion: "disable",
4672
+ prepare: () => inertInstance()
4673
+ };
3302
4674
  var SVG_PRIMITIVES = [
3303
4675
  {
3304
4676
  id: "path-morph",
@@ -3318,19 +4690,109 @@ var SVG_PRIMITIVES = [
3318
4690
  perfClass: "paint",
3319
4691
  reducedMotion: "disable",
3320
4692
  prepare: deferPrepare(prepareMorph)
3321
- }
4693
+ },
4694
+ PATH_DRAW_PRIMITIVE,
4695
+ SHAPE_FILL_PRIMITIVE,
4696
+ BAR_GROW_PRIMITIVE,
4697
+ LOGO_BUILD_PRIMITIVE,
4698
+ ICON_TOGGLE_PRIMITIVE
3322
4699
  ];
3323
4700
  var SVG_PRESETS = [
3324
4701
  { name: "icon-morph", primitive: "path-morph" },
3325
- { name: "blob-morph", primitive: "path-morph", params: { duration: "800ms" } }
4702
+ { name: "blob-morph", primitive: "path-morph", params: { duration: "800ms" } },
4703
+ // Stroke draws. One keyframe block each rather than one shared block, matching the
4704
+ // progress-ring/gauge-sweep/donut-sweep/sparkline-draw group in numbers.ts: identical bodies
4705
+ // today, but each name is free to diverge and a consumer can restyle one without the others.
4706
+ { name: "draw-stroke", primitive: "path-draw", keyframes: "kui-draw-stroke", params: { duration: "800ms", ease: "ease-in-out" } },
4707
+ { name: "draw-signature", primitive: "path-draw", keyframes: "kui-draw-signature", params: { duration: "1600ms", ease: "ease-in-out" } },
4708
+ { name: "draw-underline", primitive: "path-draw", keyframes: "kui-draw-underline", params: { duration: "420ms" } },
4709
+ { name: "checkmark-draw", primitive: "path-draw", keyframes: "kui-checkmark-draw", params: { duration: "320ms" } },
4710
+ { name: "cross-draw", primitive: "path-draw", keyframes: "kui-cross-draw", params: { duration: "260ms" } },
4711
+ { name: "chart-line-draw", primitive: "path-draw", keyframes: "kui-chart-line-draw", params: { duration: "1200ms", ease: "ease-in-out" } },
4712
+ { name: "gradient-stroke", primitive: "path-draw", keyframes: "kui-gradient-stroke", params: { duration: "2400ms", ease: "ease-in-out" } },
4713
+ // Fills.
4714
+ { name: "heart-fill", primitive: "shape-fill", keyframes: "kui-heart-fill", params: { duration: "420ms" } },
4715
+ { name: "bookmark-fill", primitive: "shape-fill", keyframes: "kui-bookmark-fill", params: { duration: "360ms" } },
4716
+ { name: "chart-area-fill", primitive: "shape-fill", keyframes: "kui-chart-area-fill", params: { duration: "900ms" } },
4717
+ // `cloak: true`: `kui-chart-bar-grow`'s `from { scale: 1 0 }` (svg.css) is a zero-height box
4718
+ // while paused, not just an invisible one — so it occupies no space in layout for the whole
4719
+ // wait. Same defect `fold-panel` has, and the same fix shape: see svg.css's
4720
+ // `[data-kui-fx~='chart-bar-grow'][data-kui-state='ready']` rule.
4721
+ {
4722
+ name: "chart-bar-grow",
4723
+ primitive: "bar-grow",
4724
+ keyframes: "kui-chart-bar-grow",
4725
+ params: { duration: "700ms", ease: "back-out" },
4726
+ cloak: true
4727
+ },
4728
+ { name: "logo-build", primitive: "logo-assemble", keyframes: "kui-logo-build", params: { duration: "520ms", ease: "back-out" } },
4729
+ // Icon toggles — no `keyframes`, because their motion is a CSS transition in svg.css keyed off
4730
+ // aria state, not a compiled animation. Same shape as forms.ts's native-state presets.
4731
+ { name: "hamburger-to-x", primitive: "icon-toggle" },
4732
+ { name: "play-to-pause", primitive: "icon-toggle" },
4733
+ { name: "plus-to-minus", primitive: "icon-toggle" }
3326
4734
  ];
3327
4735
  function registerSvg(registry) {
3328
4736
  return registry.registerPrimitives(SVG_PRIMITIVES).registerPresets(SVG_PRESETS);
3329
4737
  }
3330
4738
 
3331
4739
  // src/effects/three-d/index.ts
4740
+ var FLIP_CONTROL_SELECTOR = ":scope > .kui-flip-control";
4741
+ function prepareCardToggle(el, params, ctx) {
4742
+ const trigger = params.text("trigger", "click");
4743
+ if (trigger === "click") return () => {
4744
+ };
4745
+ if (!supportsFineHover(ctx.win)) return () => {
4746
+ };
4747
+ const control = el.querySelector(FLIP_CONTROL_SELECTOR);
4748
+ if (!control) {
4749
+ ctx.warn(`flip-card trigger:${trigger} found no direct-child .kui-flip-control \u2014 the card will not flip`);
4750
+ return () => {
4751
+ };
4752
+ }
4753
+ const set = (flipped) => control.setAttribute("aria-pressed", String(flipped));
4754
+ const isFlipped = () => control.getAttribute("aria-pressed") === "true";
4755
+ const onEnter = () => {
4756
+ set(trigger === "hover-toggle" ? !isFlipped() : true);
4757
+ };
4758
+ const onLeave = () => set(false);
4759
+ el.addEventListener("pointerenter", onEnter, { passive: true });
4760
+ if (trigger === "hover") el.addEventListener("pointerleave", onLeave, { passive: true });
4761
+ return () => {
4762
+ el.removeEventListener("pointerenter", onEnter);
4763
+ el.removeEventListener("pointerleave", onLeave);
4764
+ };
4765
+ }
4766
+ var CARD_TOGGLE_PRIMITIVE = {
4767
+ id: "card-toggle",
4768
+ renderer: "javascript",
4769
+ channels: [CHANNEL.rotate],
4770
+ parameters: {
4771
+ duration: { type: "time", default: "700ms", cssProperty: "--kui-duration" },
4772
+ ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
4773
+ perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
4774
+ trigger: {
4775
+ type: "keyword",
4776
+ default: "click",
4777
+ cssProperty: "--kui-flip-trigger",
4778
+ values: ["click", "hover", "hover-latch", "hover-toggle"]
4779
+ }
4780
+ },
4781
+ supportedTimelines: ["time"],
4782
+ supportedActivations: ["load"],
4783
+ defaultActivation: "load",
4784
+ perfClass: "compositor",
4785
+ reducedMotion: "disable",
4786
+ prepare: deferPrepare(prepareCardToggle)
4787
+ };
3332
4788
  var THREE_D_PRIMITIVES = [
3333
- cssPrimitive("flip-face", [CHANNEL.rotate], {
4789
+ // `skew`, not `rotate`: the keyframes in three-d.css write `transform: perspective(...)
4790
+ // rotateX/Y(...)`, not the individual `rotate:` property — `perspective` only creates depth for
4791
+ // an element's *children*, so giving one of these effects its own depth means reaching for the
4792
+ // `perspective()` transform *function*, which only exists inside the `transform` shorthand.
4793
+ // `CHANNEL.skew` is this catalog's name for "claims the whole `transform` shorthand"; see the
4794
+ // comment on it in `core/types.ts`.
4795
+ cssPrimitive("flip-face", [CHANNEL.skew], {
3334
4796
  parameters: {
3335
4797
  angle: { type: "angle", default: "180deg", cssProperty: "--kui-from-angle" },
3336
4798
  perspective: { type: "length", default: "1200px", cssProperty: "--kui-perspective" }
@@ -3340,12 +4802,19 @@ var THREE_D_PRIMITIVES = [
3340
4802
  parameters: { distance: { type: "length", default: "40px", cssProperty: "--kui-distance" } }
3341
4803
  }),
3342
4804
  cssPrimitive("wipe", [CHANNEL.clip]),
3343
- cssPrimitive("bar", [CHANNEL.scale])
4805
+ // `from` gives `loading-bar` a real knob on its start scale — it had none before, unlike
4806
+ // `flip-face`'s `angle:` two rows up. `--kui-bar-from` is also what the fold-panel-style
4807
+ // `[data-kui-fx~='loading-bar'][data-kui-state='ready']` gate neutralizes in three-d.css; see
4808
+ // that rule's comment for the on:enter fix this parameter doubles as.
4809
+ cssPrimitive("bar", [CHANNEL.scale], {
4810
+ parameters: { from: { type: "number", default: "0", cssProperty: "--kui-bar-from" } }
4811
+ }),
4812
+ CARD_TOGGLE_PRIMITIVE
3344
4813
  ];
3345
4814
  var THREE_D_PRESETS = [
3346
4815
  // --- 3D & perspective ---
3347
- { name: "card-flip-y", primitive: "flip-face", keyframes: "kui-card-flip-y" },
3348
- { name: "card-flip-x", primitive: "flip-face", keyframes: "kui-card-flip-x" },
4816
+ { name: "card-flip-y", primitive: "flip-face", keyframes: "kui-card-flip-y", cloak: true },
4817
+ { name: "card-flip-x", primitive: "flip-face", keyframes: "kui-card-flip-x", cloak: true },
3349
4818
  { name: "cube-rotate", primitive: "flip-face", keyframes: "kui-cube-rotate", params: { angle: "90deg" } },
3350
4819
  {
3351
4820
  name: "book-page-turn",
@@ -3353,17 +4822,32 @@ var THREE_D_PRESETS = [
3353
4822
  keyframes: "kui-book-page-turn",
3354
4823
  params: { angle: "-160deg", duration: "900ms" }
3355
4824
  },
4825
+ // `cloak: true`, unlike its `flip-face` siblings above: those are `to`-only keyframes, so their
4826
+ // paused/waiting box is the ordinary, untransformed rest state. `fold-panel` is `from`-only —
4827
+ // its `rotateX(-90deg)` (three-d.css) *is* the paused box, edge-on and zero-height, so it holds
4828
+ // no space in layout for the whole wait; see the
4829
+ // `[data-kui-fx~='fold-panel'][data-kui-state='ready']` rule in three-d.css for the other half
4830
+ // of that fix. `cloak` only ever hid the pre-JS flash, not this, but adding it here keeps the
4831
+ // pre-JS and post-JS "ready" appearances the same (invisible) instead of trading one flash for
4832
+ // the other.
3356
4833
  {
3357
4834
  name: "fold-panel",
3358
4835
  primitive: "flip-face",
3359
4836
  keyframes: "kui-fold-panel",
3360
- params: { angle: "-90deg" }
4837
+ params: { angle: "-90deg" },
4838
+ cloak: true
3361
4839
  },
3362
4840
  // --- page transitions ---
3363
4841
  { name: "page-fade", primitive: "page-reveal", keyframes: "kui-page-fade" },
3364
4842
  { name: "page-slide", primitive: "page-reveal", keyframes: "kui-page-slide" },
3365
4843
  { name: "curtain-wipe", primitive: "wipe", keyframes: "kui-curtain-wipe", params: { duration: "800ms" } },
3366
- { name: "loading-bar", primitive: "bar", keyframes: "kui-loading-bar" }
4844
+ // `cloak: true` for the same reason as `fold-panel`: `kui-loading-bar`'s `from { scale: 0 1 }`
4845
+ // (three-d.css) is a zero-width box, not just an invisible one — see that file's
4846
+ // `[data-kui-fx~='loading-bar'][data-kui-state='ready']` rule.
4847
+ { name: "loading-bar", primitive: "bar", keyframes: "kui-loading-bar", cloak: true },
4848
+ // No `keyframes`: its motion is a CSS transition in three-d.css keyed off the control's
4849
+ // aria-pressed, not a compiled animation. Same shape as the icon toggles in svg.ts.
4850
+ { name: "flip-card", primitive: "card-toggle" }
3367
4851
  ];
3368
4852
  function registerThreeD(registry) {
3369
4853
  return registry.registerPrimitives(THREE_D_PRIMITIVES).registerPresets(THREE_D_PRESETS);