kuinetic 0.1.2 → 0.1.3

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.
@@ -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,7 +12,7 @@ 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
17
18
  var COMMON = {
@@ -51,6 +52,13 @@ var float = {
51
52
  ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
52
53
  distance: { type: "length", default: "14px", cssProperty: "--kui-distance" }
53
54
  };
55
+ var orbit = {
56
+ duration: { type: "time", default: "3.5s", cssProperty: "--kui-duration" },
57
+ // `linear` by default, unlike every other ambient primitive: a continuous rotation that eases
58
+ // visibly stutters once per revolution, because the ease restarts at each iteration boundary.
59
+ ease: { type: "easing", default: "linear", cssProperty: "--kui-ease" },
60
+ angle: { type: "angle", default: "360deg", cssProperty: "--kui-to-angle" }
61
+ };
54
62
  var pulse = {
55
63
  duration: { type: "time", default: "2.2s", cssProperty: "--kui-duration" },
56
64
  ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
@@ -69,6 +77,12 @@ var AMBIENT_PRIMITIVES = [
69
77
  reducedMotion: "disable",
70
78
  perfClass: "continuous"
71
79
  }),
80
+ cssPrimitive("ambient-orbit", [CHANNEL.rotate], {
81
+ parameters: orbit,
82
+ defaultActivation: "load",
83
+ reducedMotion: "disable",
84
+ perfClass: "continuous"
85
+ }),
72
86
  cssPrimitive("ambient-pulse", [CHANNEL.scale, CHANNEL.opacity], {
73
87
  parameters: pulse,
74
88
  defaultActivation: "load",
@@ -140,6 +154,7 @@ var AMBIENT_PRESETS = [
140
154
  keyframes: "kui-floating-shapes",
141
155
  params: { duration: "6s", distance: "10px" }
142
156
  },
157
+ { name: "orbit", primitive: "ambient-orbit", keyframes: "kui-orbit" },
143
158
  { name: "glow-pulse", primitive: "ambient-pulse", keyframes: "kui-glow-pulse" }
144
159
  ];
145
160
  function registerAmbient(registry) {
@@ -456,6 +471,9 @@ var hoverTiming = {
456
471
  var liftParams = {
457
472
  distance: { type: "length", default: "6px", cssProperty: "--kui-lift-distance" }
458
473
  };
474
+ var popParams = {
475
+ scale: { type: "number", default: "1.06", cssProperty: "--kui-pop-scale", finite: true, minimum: 0 }
476
+ };
459
477
  function hoverPrimitive(id, channels, extraParams = {}) {
460
478
  return {
461
479
  id,
@@ -477,6 +495,7 @@ function hoverPrimitive(id, channels, extraParams = {}) {
477
495
  }
478
496
  var HOVER_PRIMITIVES = [
479
497
  hoverPrimitive("lift", ["translate"], liftParams),
498
+ hoverPrimitive("pop", ["scale"], popParams),
480
499
  hoverPrimitive("lift-shadow", ["translate", "shadow"], liftParams),
481
500
  hoverPrimitive("shine-sweep", ["sweep"]),
482
501
  hoverPrimitive("split-flap", ["rotate"]),
@@ -787,12 +806,173 @@ function registerInteraction(registry) {
787
806
  return registry.registerPrimitives(INTERACTION_PRIMITIVES).registerPresets(INTERACTION_PRESETS);
788
807
  }
789
808
 
809
+ // src/effects/catalog/media-shared.ts
810
+ var AXIS_DEGREES = { vertical: 0, horizontal: 90 };
811
+ var UNIT_DEGREES = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 };
812
+ var BAND_OVERLAP_PX = 1;
813
+ var STAGE_CLASS = "kui-slat-stage";
814
+ var SLAT_CLASS = "kui-slat-item";
815
+ var GOLDEN_RATIO_CONJUGATE = 0.6180339887498949;
816
+ function slatOrder(index, count, from) {
817
+ if (count <= 1) return 0;
818
+ switch (from) {
819
+ case "start":
820
+ return index;
821
+ case "end":
822
+ return count - 1 - index;
823
+ case "edges":
824
+ return Math.min(index, count - 1 - index);
825
+ case "random-ish":
826
+ return Math.floor(index * GOLDEN_RATIO_CONJUGATE % 1 * count);
827
+ case "alternate":
828
+ default:
829
+ return zigzagRank(index, count);
830
+ }
831
+ }
832
+ function zigzagRank(index, count) {
833
+ const fromStart = index;
834
+ const fromEnd = count - 1 - index;
835
+ const pair = Math.min(fromStart, fromEnd);
836
+ return fromStart <= fromEnd ? pair * 2 : pair * 2 + 1;
837
+ }
838
+ function slatAngleDegrees(authored, axis) {
839
+ const trimmed = authored.trim();
840
+ if (!trimmed) return AXIS_DEGREES[axis];
841
+ const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(deg|rad|grad|turn)?$/.exec(trimmed);
842
+ if (!match) return AXIS_DEGREES[axis];
843
+ const value = Number(match[1]);
844
+ if (!Number.isFinite(value)) return AXIS_DEGREES[axis];
845
+ const unit = match[2] ?? "deg";
846
+ const degrees = value * UNIT_DEGREES[unit];
847
+ return (degrees % 180 + 180) % 180;
848
+ }
849
+ function slatTravelVector(angleDegrees) {
850
+ const theta = angleDegrees * Math.PI / 180;
851
+ return { x: -Math.sin(theta), y: Math.cos(theta) };
852
+ }
853
+ function slatBandClip(index, count, angleDegrees, box) {
854
+ const { width, height } = box;
855
+ const theta = angleDegrees * Math.PI / 180;
856
+ const nx = Math.cos(theta);
857
+ const ny = Math.sin(theta);
858
+ const { x: dx, y: dy } = slatTravelVector(angleDegrees);
859
+ const span = Math.abs(nx) * width + Math.abs(ny) * height;
860
+ const centreX = width / 2;
861
+ const centreY = height / 2;
862
+ const step = span / count;
863
+ const near = index * step - span / 2 - BAND_OVERLAP_PX;
864
+ const far = (index + 1) * step - span / 2 + BAND_OVERLAP_PX;
865
+ const reach = width + height;
866
+ const corner = (along, across) => `${(centreX + along * nx + across * dx).toFixed(2)}px ${(centreY + along * ny + across * dy).toFixed(2)}px`;
867
+ return `polygon(${corner(near, reach)}, ${corner(near, -reach)}, ${corner(far, -reach)}, ${corner(far, reach)})`;
868
+ }
869
+ function axisLabel(angleDegrees) {
870
+ if (angleDegrees === 0) return "vertical";
871
+ if (angleDegrees === 90) return "horizontal";
872
+ return "diagonal";
873
+ }
874
+ function syncStageToImage(stage, img, bands) {
875
+ const { slats, angleDegrees } = bands;
876
+ const width = img.offsetWidth;
877
+ const height = img.offsetHeight;
878
+ stage.style.top = `${img.offsetTop}px`;
879
+ stage.style.left = `${img.offsetLeft}px`;
880
+ stage.style.width = `${width}px`;
881
+ stage.style.height = `${height}px`;
882
+ slats.forEach((slat, index) => {
883
+ slat.style.clipPath = slatBandClip(index, slats.length, angleDegrees, { width, height });
884
+ });
885
+ }
886
+ function watchImageBox(stage, img, win, bands) {
887
+ const handler = () => syncStageToImage(stage, img, bands);
888
+ win.addEventListener("resize", handler, { passive: true });
889
+ const stopWindow = () => win.removeEventListener("resize", handler);
890
+ const ResizeObserverCtor = win.ResizeObserver;
891
+ if (!ResizeObserverCtor) return stopWindow;
892
+ const observer = new ResizeObserverCtor(handler);
893
+ observer.observe(img);
894
+ return () => {
895
+ stopWindow();
896
+ observer.disconnect();
897
+ };
898
+ }
899
+ function imagePaintStyle(img, win) {
900
+ const computed = win.getComputedStyle(img);
901
+ const position = computed.objectPosition || "50% 50%";
902
+ const fit = computed.objectFit;
903
+ if (fit === "cover") return { size: "cover", position };
904
+ if (fit === "contain" || fit === "scale-down") return { size: "contain", position };
905
+ if (fit === "none") return { size: "auto", position };
906
+ return { size: "100% 100%", position };
907
+ }
908
+ function installSlatStage(el, doc, win, options) {
909
+ const img = el.querySelector("img");
910
+ if (!img) return null;
911
+ const url = img.currentSrc || img.getAttribute("src") || "";
912
+ if (!url) return null;
913
+ const { count, angleDegrees, from, fold } = options;
914
+ const travel = slatTravelVector(angleDegrees);
915
+ const stage = doc.createElement("div");
916
+ stage.className = STAGE_CLASS;
917
+ stage.setAttribute("aria-hidden", "true");
918
+ stage.dataset.kuiSlatAxis = axisLabel(angleDegrees);
919
+ stage.dataset.kuiSlatAngle = String(angleDegrees);
920
+ stage.dataset.kuiSlatFold = String(fold);
921
+ stage.style.setProperty("--kui-slat-count", String(count));
922
+ stage.style.setProperty("--kui-slat-dx", travel.x.toFixed(4));
923
+ stage.style.setProperty("--kui-slat-dy", travel.y.toFixed(4));
924
+ const paint = imagePaintStyle(img, win);
925
+ const slats = [];
926
+ for (let index = 0; index < count; index++) {
927
+ const slat = doc.createElement("div");
928
+ slat.className = SLAT_CLASS;
929
+ slat.style.setProperty("--kui-slat-index", String(index));
930
+ slat.style.setProperty("--kui-i", String(slatOrder(index, count, from)));
931
+ slat.style.backgroundImage = `url("${url}")`;
932
+ slat.style.backgroundSize = paint.size;
933
+ slat.style.backgroundPosition = paint.position;
934
+ stage.append(slat);
935
+ slats.push(slat);
936
+ }
937
+ el.append(stage);
938
+ const bands = { slats, angleDegrees };
939
+ syncStageToImage(stage, img, bands);
940
+ const stopWatching = watchImageBox(stage, img, win, bands);
941
+ const imageStyles = createStyleLedger(img);
942
+ imageStyles.set("visibility", "hidden");
943
+ return {
944
+ stage,
945
+ slats,
946
+ restore: () => {
947
+ stopWatching();
948
+ imageStyles.restore();
949
+ stage.remove();
950
+ }
951
+ };
952
+ }
953
+ function applySlatTimingVars(stage, params) {
954
+ const { durationMs, delayMs, easing } = params.timing;
955
+ const duration = durationMs === void 0 ? params.text("duration", "500ms") : `${durationMs}ms`;
956
+ const delay = delayMs === void 0 ? params.text("delay", "0ms") : `${delayMs}ms`;
957
+ stage.style.setProperty("--kui-duration", duration);
958
+ stage.style.setProperty("--kui-delay", delay);
959
+ stage.style.setProperty("--kui-ease", easing ?? params.text("ease", "ease-out"));
960
+ stage.style.setProperty("--kui-stagger", params.text("stagger", "60ms"));
961
+ }
962
+ function slatAssembleFinishMs(params, count) {
963
+ if (count === 0) return 0;
964
+ const durationMs = params.timing.durationMs ?? params.ms("duration", 500);
965
+ const delayMs = params.timing.delayMs ?? params.ms("delay", 0);
966
+ const staggerMs = params.ms("stagger", 60);
967
+ return delayMs + (count - 1) * staggerMs + durationMs;
968
+ }
969
+
790
970
  // src/effects/catalog/media.ts
791
971
  var geometry = {
792
972
  distance: { type: "length", default: "24px", cssProperty: "--kui-distance" },
793
973
  scale: { type: "number", default: "1.12", cssProperty: "--kui-to-scale" }
794
974
  };
795
- var MEDIA_PRIMITIVES = [
975
+ var MEDIA_CSS_PRIMITIVES = [
796
976
  cssPrimitive("media-wipe", [CHANNEL.clip]),
797
977
  cssPrimitive("media-mask", ["mask"], { perfClass: "paint" }),
798
978
  // Not `reducedMotion: 'disable'` — that policy means "no finite duration would make sense,
@@ -813,33 +993,66 @@ var MEDIA_PRIMITIVES = [
813
993
  defaultActivation: "hover",
814
994
  perfClass: "paint"
815
995
  }),
996
+ // `geometry.distance` only, not `...geometry`: `kui-blur-up` (media.css) reads `--kui-distance`
997
+ // and `--kui-blur` but never `--kui-to-scale` — this primitive doesn't even declare
998
+ // `CHANNEL.scale`. Spreading the whole shared object used to expose `scale:` as an
999
+ // apparently-valid, silently-inert parameter, the same shape `flip-3d`'s dead `perspective`
1000
+ // parameter was (`entrance.css`'s comment on `kui-flip-in-x`).
816
1001
  cssPrimitive("media-blur-up", [CHANNEL.translate, CHANNEL.filter], {
817
- parameters: { ...geometry, blur: { type: "length", default: "16px", cssProperty: "--kui-blur" } },
1002
+ parameters: {
1003
+ distance: geometry.distance,
1004
+ blur: { type: "length", default: "16px", cssProperty: "--kui-blur" }
1005
+ },
818
1006
  perfClass: "paint"
819
1007
  }),
1008
+ // No `defaultActivation` — same convention `core.ts`'s `parallax`/`parallax-scale`/
1009
+ // `parallax-rotate`/`scroll-fade`/`desaturate`/`skew`/`progress`/`progress-stroke` already use
1010
+ // for every other `timelines: ['view', 'scroll', ...]` primitive. `resolveActivation`
1011
+ // (`animator.ts`) only consults `defaultActivation` when the author named no activation, and
1012
+ // falls through to `element-config.ts`'s hardcoded `'enter'` when a primitive declares none.
1013
+ // Setting it to `'manual'` here (matching `activations: ['manual']`) looked like the obviously
1014
+ // correct pairing, but it is what actually broke the effect: `effectiveActivation`
1015
+ // (`style-plan.ts`) only converts a stuck `'manual'` into `'enter'` when `config.timeline !==
1016
+ // 'time'` — i.e. only once the author has actually written `timeline:view`/`timeline:scroll`.
1017
+ // Authored bare (no `timeline:`, the sweep's own probe and the likely first thing anyone
1018
+ // tries), `config.timeline` stays the default `'time'`, that conversion never fires, and the
1019
+ // element sits at `data-kui-state="ready"` forever — which is also the state
1020
+ // `entrance.css`'s `[data-kui-state='ready'] { --kui-distance: 0px !important; }` targets, so
1021
+ // every sample read the same permanently-zeroed `--kui-distance`: not a paused animation, a
1022
+ // zeroed one. `timeline:view`/`timeline:scroll` usage is unaffected either way, since a native
1023
+ // timeline resolves to the `'native-timeline'` gate before activation is even consulted.
1024
+ // `geometry.distance` only: `kui-image-parallax-frame` never reads `--kui-to-scale` and this
1025
+ // primitive doesn't declare `CHANNEL.scale` — same dead-parameter shape as `media-blur-up` above.
820
1026
  cssPrimitive("media-parallax-frame", [CHANNEL.translate], {
821
- parameters: geometry,
1027
+ parameters: { distance: geometry.distance },
822
1028
  timelines: ["view", "scroll"],
823
1029
  activations: ["manual"],
824
- defaultActivation: "manual",
825
1030
  reducedMotion: "disable"
826
1031
  }),
1032
+ // Its own `scale` parameter, not `geometry`: `kui-lightbox-open` opens from a fixed 0.92, never
1033
+ // reading `--kui-to-scale` (or any `distance`, since this primitive doesn't animate position at
1034
+ // all) — the whole shared object was dead weight here. Unlike `media-blur-up`/
1035
+ // `media-parallax-frame`, this primitive *does* declare `CHANNEL.scale`, so the fix is to wire
1036
+ // the keyframe up to a real parameter rather than remove the promise: `--kui-from-scale`, the
1037
+ // same name and "starts at this scale, animates to 1" meaning `scale`/`scale-move`
1038
+ // (`catalog/core.ts`) already use, so `lightbox-open scale:0.8` now does what it looks like it
1039
+ // should. See `kui-lightbox-open` in `media.css`.
827
1040
  cssPrimitive("media-lightbox", [CHANNEL.opacity, CHANNEL.scale], {
828
- parameters: geometry
1041
+ parameters: { scale: { type: "number", default: "0.92", cssProperty: "--kui-from-scale" } }
829
1042
  })
830
1043
  ];
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" },
1044
+ var MEDIA_CSS_PRESETS = [
1045
+ { name: "wipe-up", primitive: "media-wipe", keyframes: "kui-wipe-up", cloak: true },
1046
+ { name: "wipe-down", primitive: "media-wipe", keyframes: "kui-wipe-down", cloak: true },
1047
+ { name: "wipe-left", primitive: "media-wipe", keyframes: "kui-wipe-left", cloak: true },
1048
+ { name: "wipe-right", primitive: "media-wipe", keyframes: "kui-wipe-right", cloak: true },
1049
+ { name: "wipe-circle", primitive: "media-wipe", keyframes: "kui-wipe-circle", cloak: true },
1050
+ { name: "wipe-diagonal", primitive: "media-wipe", keyframes: "kui-wipe-diagonal", cloak: true },
1051
+ { name: "mask-reveal", primitive: "media-mask", keyframes: "kui-mask-reveal", cloak: true },
1052
+ { name: "curtain-reveal", primitive: "media-wipe", keyframes: "kui-curtain-reveal", cloak: true },
840
1053
  { name: "ken-burns", primitive: "media-ken-burns", keyframes: "kui-ken-burns" },
841
1054
  { 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" },
1055
+ { name: "blur-up", primitive: "media-blur-up", keyframes: "kui-blur-up", cloak: true },
843
1056
  { name: "duotone-hover", primitive: "media-filter", keyframes: "kui-duotone-hover" },
844
1057
  { name: "grayscale-hover", primitive: "media-filter", keyframes: "kui-grayscale-hover" },
845
1058
  { name: "saturate-hover", primitive: "media-filter", keyframes: "kui-saturate-hover" },
@@ -851,14 +1064,131 @@ var MEDIA_PRESETS = [
851
1064
  { name: "before-after-wipe", primitive: "media-wipe", keyframes: "kui-before-after-wipe" },
852
1065
  { name: "lightbox-open", primitive: "media-lightbox", keyframes: "kui-lightbox-open" }
853
1066
  ];
1067
+ var slatParams = {
1068
+ slats: {
1069
+ type: "number",
1070
+ default: "8",
1071
+ cssProperty: "--kui-slats",
1072
+ minimum: 2,
1073
+ maximum: 24,
1074
+ integer: true
1075
+ },
1076
+ axis: {
1077
+ type: "keyword",
1078
+ default: "vertical",
1079
+ cssProperty: "--kui-axis",
1080
+ values: ["vertical", "horizontal"]
1081
+ },
1082
+ /*
1083
+ * The general form of `axis:`, in degrees: `0deg` is `axis:vertical`, `90deg` is
1084
+ * `axis:horizontal`, and anything between cuts the picture into diagonal bands. An authored
1085
+ * angle wins; leaving it off reads `axis:`, so every existing attribute keeps its meaning.
1086
+ *
1087
+ * `text`, not `angle`, for one reason: `readParams` pre-fills every declared parameter with its
1088
+ * schema default, so a typed default is indistinguishable from an authored value and there would
1089
+ * be no way to tell `angle:0deg` from "no angle, use the axis". An empty default is only possible
1090
+ * on a type that is never written to a stylesheet, which is exactly what `text` is for — and this
1091
+ * value never reaches CSS anyway. `slatAngleDegrees` does the parsing and the range clamp.
1092
+ */
1093
+ angle: { type: "text", default: "", cssProperty: "--kui-slat-angle" },
1094
+ from: {
1095
+ type: "keyword",
1096
+ default: "alternate",
1097
+ cssProperty: "--kui-from",
1098
+ values: ["alternate", "start", "end", "edges", "random-ish"]
1099
+ },
1100
+ fold: { type: "keyword", default: "false", cssProperty: "--kui-fold", values: ["true", "false"] },
1101
+ duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" },
1102
+ delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" },
1103
+ ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
1104
+ stagger: { type: "time", default: "60ms", cssProperty: "--kui-stagger" }
1105
+ };
1106
+ function prepareSlatAssemble(el, params, ctx) {
1107
+ const doc = el.ownerDocument;
1108
+ const count = Math.min(24, Math.max(2, Math.round(params.num("slats", 8))));
1109
+ const axis = params.text("axis", "vertical");
1110
+ const angleDegrees = slatAngleDegrees(params.text("angle", ""), axis);
1111
+ const from = params.text("from", "alternate");
1112
+ const fold = params.is("fold");
1113
+ const node = el;
1114
+ if (ctx.win.getComputedStyle(node).position === "static") ctx.style.set("position", "relative");
1115
+ const built = installSlatStage(el, doc, ctx.win, { count, angleDegrees, from, fold });
1116
+ if (!built) return () => {
1117
+ };
1118
+ const { stage } = built;
1119
+ applySlatTimingVars(stage, params);
1120
+ stage.classList.add("kui-slat-animating");
1121
+ let settle2;
1122
+ const finished = new Promise((resolve) => {
1123
+ settle2 = resolve;
1124
+ });
1125
+ let landed = false;
1126
+ const land = () => {
1127
+ if (landed) return;
1128
+ landed = true;
1129
+ stage.classList.remove("kui-slat-animating");
1130
+ built.restore();
1131
+ settle2();
1132
+ };
1133
+ const timer = ctx.win.setTimeout(land, slatAssembleFinishMs(params, count));
1134
+ return {
1135
+ cleanup: () => {
1136
+ ctx.win.clearTimeout(timer);
1137
+ if (landed) return;
1138
+ landed = true;
1139
+ built.restore();
1140
+ },
1141
+ finished,
1142
+ finish: () => {
1143
+ ctx.win.clearTimeout(timer);
1144
+ land();
1145
+ }
1146
+ };
1147
+ }
1148
+ var MEDIA_JS_PRIMITIVES = [
1149
+ {
1150
+ id: "slat-assemble",
1151
+ renderer: "javascript",
1152
+ channels: [CHANNEL.opacity, CHANNEL.translate, CHANNEL.rotate],
1153
+ parameters: slatParams,
1154
+ supportedTimelines: ["time"],
1155
+ supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
1156
+ defaultActivation: "enter",
1157
+ perfClass: "dom-transform",
1158
+ // Same reasoning as every JS-rendered primitive in `text.ts`: nothing here declares a CSS
1159
+ // `animation-duration` the reduced-motion policy layer could shorten, and `disable` is what
1160
+ // stops `installSlatStage`'s DOM surgery from ever running at all under reduced motion — the
1161
+ // animator never calls `activate()`, so the wrapped `<img>` is simply left exactly as authored.
1162
+ reducedMotion: "disable",
1163
+ // Land()ing hands the picture back to the real `<img>` and tears down every slat — see
1164
+ // `prepareSlatAssemble`'s `land()`. Declared, not assumed: see `restoresOnFinish`'s own comment
1165
+ // in `core/types.ts` for why the catalog's default is the opposite of this.
1166
+ restoresOnFinish: true,
1167
+ prepare: deferPrepare(prepareSlatAssemble)
1168
+ }
1169
+ ];
1170
+ var MEDIA_JS_PRESETS = [
1171
+ { name: "slat-assemble", primitive: "slat-assemble", cloak: true }
1172
+ ];
1173
+ var MEDIA_PRIMITIVES = [...MEDIA_CSS_PRIMITIVES, ...MEDIA_JS_PRIMITIVES];
1174
+ var MEDIA_PRESETS = [...MEDIA_CSS_PRESETS, ...MEDIA_JS_PRESETS];
854
1175
  function registerMedia(registry) {
855
1176
  return registry.registerPrimitives(MEDIA_PRIMITIVES).registerPresets(MEDIA_PRESETS);
856
1177
  }
857
1178
 
1179
+ // src/effects/catalog/subtree-capture.ts
1180
+ function captureChildren(el) {
1181
+ const authored = Array.from(el.childNodes);
1182
+ return () => {
1183
+ el.replaceChildren(...authored);
1184
+ };
1185
+ }
1186
+
858
1187
  // src/effects/catalog/numbers-shared.ts
859
1188
  var SR_ONLY_CLASS = "kui-sr-only";
860
1189
  var DECORATIVE_CLASS = "kui-count-decorative";
861
1190
  function installCountLayers(el, doc) {
1191
+ const restoreChildren = captureChildren(el);
862
1192
  const decorative = doc.createElement("span");
863
1193
  decorative.setAttribute("aria-hidden", "true");
864
1194
  decorative.className = DECORATIVE_CLASS;
@@ -870,9 +1200,7 @@ function installCountLayers(el, doc) {
870
1200
  return {
871
1201
  decorative,
872
1202
  srOnly,
873
- restore: () => {
874
- el.textContent = srOnly.textContent;
875
- }
1203
+ restore: restoreChildren
876
1204
  };
877
1205
  }
878
1206
  function easeOutCubic(t) {
@@ -1182,7 +1510,12 @@ var COUNT_PRESETS = [
1182
1510
  ];
1183
1511
  var METER_PRIMITIVES = [
1184
1512
  cssPrimitive("stroke-sweep", [CHANNEL.stroke]),
1185
- cssPrimitive("meter-bar", [CHANNEL.scale]),
1513
+ // `from` gives `progress-bar` a real knob on its start scale — it had none before. `--kui-bar-from`
1514
+ // is also what the `[data-kui-fx~='progress-bar'][data-kui-state='ready']` gate in numbers.css
1515
+ // neutralizes; see that rule's comment for the on:enter fix this parameter doubles as.
1516
+ cssPrimitive("meter-bar", [CHANNEL.scale], {
1517
+ parameters: { from: { type: "number", default: "0", cssProperty: "--kui-bar-from" } }
1518
+ }),
1186
1519
  cssPrimitive("meter-segments", [CHANNEL.opacity]),
1187
1520
  cssPrimitive("meter-stars", [CHANNEL.clip])
1188
1521
  ];
@@ -1191,7 +1524,13 @@ var METER_PRESETS = [
1191
1524
  { name: "gauge-sweep", primitive: "stroke-sweep", keyframes: "kui-gauge-sweep" },
1192
1525
  { name: "donut-sweep", primitive: "stroke-sweep", keyframes: "kui-donut-sweep" },
1193
1526
  { name: "sparkline-draw", primitive: "stroke-sweep", keyframes: "kui-sparkline-draw" },
1194
- { name: "progress-bar", primitive: "meter-bar", keyframes: "kui-progress-bar" },
1527
+ // `cloak: true`: `kui-progress-bar`'s `from { scale: 0 1 }` (numbers.css) is a zero-width box
1528
+ // while paused, not just an invisible one — so while it waits it occupies no space in layout at
1529
+ // all. Not, despite the tidier story, because an observer refuses to fire on it: Chromium was
1530
+ // measured resolving a zero-area target's `intersectionRatio` to `1`. See numbers.css's
1531
+ // `[data-kui-fx~='progress-bar'][data-kui-state='ready']` rule for the geometry half of the fix;
1532
+ // `cloak` keeps the pre-JS and post-JS "waiting" look the same (invisible) either side of it.
1533
+ { name: "progress-bar", primitive: "meter-bar", keyframes: "kui-progress-bar", cloak: true },
1195
1534
  { name: "progress-segments", primitive: "meter-segments", keyframes: "kui-progress-segments" },
1196
1535
  { name: "star-rating-fill", primitive: "meter-stars", keyframes: "kui-star-rating-fill" }
1197
1536
  ];
@@ -1206,6 +1545,7 @@ var SR_ONLY_CLASS2 = "kui-sr-only";
1206
1545
  var DECORATIVE_CLASS2 = "kui-split-decorative";
1207
1546
  function installSplitLayers(el, doc) {
1208
1547
  const originalText = el.textContent.trim();
1548
+ const restoreChildren = captureChildren(el);
1209
1549
  const decorative = doc.createElement("span");
1210
1550
  decorative.setAttribute("aria-hidden", "true");
1211
1551
  decorative.className = DECORATIVE_CLASS2;
@@ -1217,9 +1557,7 @@ function installSplitLayers(el, doc) {
1217
1557
  return {
1218
1558
  decorative,
1219
1559
  originalText,
1220
- restore: () => {
1221
- el.textContent = originalText;
1222
- }
1560
+ restore: restoreChildren
1223
1561
  };
1224
1562
  }
1225
1563
  function segmentGraphemes(text) {
@@ -1387,14 +1725,43 @@ var extrudeParams = {
1387
1725
  distance: { type: "length", default: "32px", cssProperty: "--kui-distance" }
1388
1726
  };
1389
1727
  var TEXT_CSS_PRIMITIVES = [
1390
- cssPrimitive("text-shimmer", [CHANNEL.background], { reducedMotion: "disable" }),
1728
+ // `color`, alongside `background`: both presets' unconditional rule sets `-webkit-text-fill-color:
1729
+ // transparent` so the `background-image` gradient shows through the glyphs (the standard
1730
+ // gradient-text technique). That is a real claim on the glyph fill, the same physical property
1731
+ // `text-outline-fill` animates on its own `color` channel below — without this, the two looked
1732
+ // disjoint to the compiler (`background` vs `stroke`+`color`) and composing them would let
1733
+ // whichever applied last silently win the glyph fill instead of being flagged as a conflict.
1734
+ cssPrimitive("text-shimmer", [CHANNEL.background, CHANNEL.color], { reducedMotion: "disable" }),
1735
+ // `gradient-sweep` is the only one of the three `text-sweep`-family presets whose keyframe
1736
+ // touches `-webkit-text-fill-color` (see text.css) — `highlight-sweep` and `underline-draw` only
1737
+ // paint a `background-image`. Claiming `color` for all three made the compiler reject compositions
1738
+ // like `underline-draw, text-outline-fill` as a glyph-fill conflict even though they touch disjoint
1739
+ // properties. Split so only the preset that actually claims the fill declares the channel.
1740
+ cssPrimitive("text-gradient-sweep", [CHANNEL.background, CHANNEL.color], { parameters: textSweepParams }),
1391
1741
  cssPrimitive("text-sweep", [CHANNEL.background], { parameters: textSweepParams }),
1392
1742
  cssPrimitive("text-outline-fill", [CHANNEL.stroke, CHANNEL.color]),
1393
1743
  cssPrimitive("var-weight", ["font"], { parameters: fontWeightParams }),
1394
1744
  cssPrimitive("var-width", ["font"], { parameters: fontWidthParams }),
1395
1745
  cssPrimitive("var-slant", ["font"], { parameters: fontSlantParams }),
1746
+ /*
1747
+ * `defaultActivation: 'load'` for the same reason every ambient primitive declares it, and it
1748
+ * was missing here: a marquee is continuous motion, so `reducedMotion: 'disable'` is only half
1749
+ * the rule `ambient.ts` spells out — the other half is starting on `load` rather than waiting on
1750
+ * a scroll-triggered `enter`.
1751
+ *
1752
+ * Without it, a bare `data-kui="marquee 42s"` resolved to `enter`, which `style-plan.ts`'s
1753
+ * `resolveGate` sends down the `deferred` path and stamps `animation-play-state: paused`. It
1754
+ * compiled correctly, reported `data-kui-state="ready"`, and never ran — measured on a marquee
1755
+ * fully in view, so this was not an observer that had simply not fired yet. Every page carrying
1756
+ * one had to know to write `on:load`, which is exactly the kind of thing an author cannot be
1757
+ * expected to guess.
1758
+ *
1759
+ * `marquee-scroll-linked` is unaffected: its position comes from `animation-timeline: scroll()`,
1760
+ * not from an activation.
1761
+ */
1396
1762
  cssPrimitive("text-marquee", [CHANNEL.translate], {
1397
1763
  timelines: ["time", "scroll"],
1764
+ defaultActivation: "load",
1398
1765
  reducedMotion: "disable"
1399
1766
  }),
1400
1767
  cssPrimitive("redaction-reveal", [CHANNEL.clip]),
@@ -1402,7 +1769,7 @@ var TEXT_CSS_PRIMITIVES = [
1402
1769
  ];
1403
1770
  var TEXT_CSS_PRESETS = [
1404
1771
  { name: "gradient-shimmer", primitive: "text-shimmer", keyframes: "kui-gradient-shimmer" },
1405
- { name: "gradient-sweep", primitive: "text-sweep", keyframes: "kui-gradient-sweep" },
1772
+ { name: "gradient-sweep", primitive: "text-gradient-sweep", keyframes: "kui-gradient-sweep" },
1406
1773
  {
1407
1774
  name: "highlight-sweep",
1408
1775
  primitive: "text-sweep",
@@ -1565,9 +1932,10 @@ function prepareScramble(el, params, ctx) {
1565
1932
  layers.decorative.textContent = scrambledFrame(graphemes, resolved, charset, Math.random);
1566
1933
  };
1567
1934
  render();
1935
+ const totalTicks = Math.max(1, graphemes.length * revealEvery);
1568
1936
  const run = createStepRunner(ctx.win, {
1569
1937
  delayMs: params.timing.delayMs ?? 0,
1570
- stepMs: stepMsFor(params, graphemes.length * revealEvery, 40),
1938
+ stepMs: stepMsFor(params, totalTicks, Math.max(1, 700 / totalTicks)),
1571
1939
  tick: () => {
1572
1940
  ticks++;
1573
1941
  if (ticks % revealEvery === 0) resolved++;
@@ -1592,7 +1960,7 @@ function prepareWordCycler(el, params, ctx) {
1592
1960
  const words = params.text("words", "").split("|").map((word) => word.trim()).filter(Boolean);
1593
1961
  if (words.length === 0) return () => {
1594
1962
  };
1595
- const original = el.textContent;
1963
+ const restoreChildren = captureChildren(el);
1596
1964
  const swapMs = 150;
1597
1965
  let index = 0;
1598
1966
  el.textContent = words[0];
@@ -1613,7 +1981,7 @@ function prepareWordCycler(el, params, ctx) {
1613
1981
  cleanup: () => {
1614
1982
  run.stop();
1615
1983
  el.classList.remove("kui-word-cycler-swap");
1616
- el.textContent = original;
1984
+ restoreChildren();
1617
1985
  },
1618
1986
  finished: run.finished,
1619
1987
  finish: () => run.stop()
@@ -1649,9 +2017,9 @@ var TEXT_JS_PRESETS = [
1649
2017
  { name: "split-chars", primitive: "split-text", params: { unit: "chars", direction: "fade" } },
1650
2018
  { name: "split-words", primitive: "split-text", params: { unit: "words", direction: "fade" } },
1651
2019
  { 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" } },
2020
+ { name: "text-reveal-up", primitive: "split-text", params: { unit: "words", direction: "up" }, cloak: true },
2021
+ { name: "text-reveal-down", primitive: "split-text", params: { unit: "words", direction: "down" }, cloak: true },
2022
+ { name: "text-reveal-mask", primitive: "split-text", params: { unit: "lines", direction: "mask" }, cloak: true },
1655
2023
  { name: "text-wave", primitive: "split-text-motion", params: { motion: "wave" } },
1656
2024
  { name: "text-jitter", primitive: "split-text-motion", params: { motion: "jitter" } },
1657
2025
  { name: "typewriter", primitive: "typewriter", params: { loop: "false" } },
@@ -1683,66 +2051,130 @@ var distance = {
1683
2051
  distance: { type: "length", default: "24px", cssProperty: "--kui-distance" },
1684
2052
  opacity: { type: "number", default: "0", cssProperty: "--kui-from-opacity" }
1685
2053
  };
2054
+ var ENTRANCE_TIMELINES = ["time", "view", "scroll", "pin"];
1686
2055
  var PRIMITIVES = [
1687
2056
  // --- entrance / exit -------------------------------------------------------------------
1688
- cssPrimitive("reveal", [CHANNEL.opacity, CHANNEL.translate], { parameters: distance }),
2057
+ cssPrimitive("reveal", [CHANNEL.opacity, CHANNEL.translate], { timelines: ENTRANCE_TIMELINES, parameters: distance }),
1689
2058
  cssPrimitive("scale", [CHANNEL.scale], {
2059
+ timelines: ENTRANCE_TIMELINES,
1690
2060
  parameters: { scale: { type: "number", default: "0.92", cssProperty: "--kui-from-scale" } }
1691
2061
  }),
1692
2062
  // Separate from `scale` because it claims translate as well and so composes differently.
2063
+ // `distance.distance` only, not `...distance`: `kui-zoom-in-up`/`-down` (entrance.css) read
2064
+ // `--kui-distance` and `--kui-from-scale` but never `--kui-from-opacity` — this primitive
2065
+ // doesn't declare `CHANNEL.opacity`. Spreading the whole shared `distance` object used to expose
2066
+ // `opacity:` as an apparently-valid, silently-inert parameter (same dead-parameter shape as
2067
+ // `flip-3d`'s old `perspective`, fixed above).
1693
2068
  cssPrimitive("scale-move", [CHANNEL.scale, CHANNEL.translate], {
2069
+ timelines: ENTRANCE_TIMELINES,
1694
2070
  parameters: {
1695
- ...distance,
2071
+ distance: distance.distance,
1696
2072
  scale: { type: "number", default: "0.92", cssProperty: "--kui-from-scale" }
1697
2073
  }
1698
2074
  }),
1699
2075
  cssPrimitive("rotate", [CHANNEL.rotate], {
2076
+ timelines: ENTRANCE_TIMELINES,
1700
2077
  parameters: { angle: { type: "angle", default: "-8deg", cssProperty: "--kui-from-angle" } }
1701
2078
  }),
2079
+ // `distance.distance` only, not `...distance`: `kui-roll-in`/`-out` (entrance.css) write
2080
+ // `rotate`/`translate`, never `opacity` — this primitive doesn't declare `CHANNEL.opacity`. Same
2081
+ // dead-parameter shape as `scale-move` above.
1702
2082
  cssPrimitive("roll", [CHANNEL.rotate, CHANNEL.translate], {
2083
+ timelines: ENTRANCE_TIMELINES,
1703
2084
  parameters: {
1704
- ...distance,
2085
+ distance: distance.distance,
1705
2086
  angle: { type: "angle", default: "-120deg", cssProperty: "--kui-from-angle" }
1706
2087
  }
1707
2088
  }),
1708
- cssPrimitive("flip-3d", [CHANNEL.rotate], {
2089
+ // `skew`, not `rotate`: entrance.css's keyframes write `transform: perspective(...)
2090
+ // rotateX/Y(...)`, not the individual `rotate:` property — the `perspective` parameter below
2091
+ // used to compile cleanly and do nothing, because nothing read it. See entrance.css's own
2092
+ // comment on `kui-flip-in-x` for the fix; `CHANNEL.skew` is this catalog's name for "claims the
2093
+ // whole `transform` shorthand" (`core/types.ts`), shared with `scroll-skew` and `flip-face`.
2094
+ cssPrimitive("flip-3d", [CHANNEL.skew], {
2095
+ timelines: ENTRANCE_TIMELINES,
1709
2096
  parameters: {
1710
2097
  angle: { type: "angle", default: "90deg", cssProperty: "--kui-from-angle" },
1711
2098
  perspective: { type: "length", default: "1200px", cssProperty: "--kui-perspective" }
1712
2099
  }
1713
2100
  }),
1714
2101
  cssPrimitive("blur", [CHANNEL.filter], {
2102
+ timelines: ENTRANCE_TIMELINES,
1715
2103
  parameters: { blur: { type: "length", default: "12px", cssProperty: "--kui-blur" } }
1716
2104
  }),
1717
2105
  // Purpose-built combination: one keyframe, so opacity is written once instead of twice.
1718
2106
  cssPrimitive("reveal-blur", [CHANNEL.opacity, CHANNEL.translate, CHANNEL.filter], {
2107
+ timelines: ENTRANCE_TIMELINES,
1719
2108
  parameters: { ...distance, blur: { type: "length", default: "12px", cssProperty: "--kui-blur" } }
1720
2109
  }),
1721
2110
  // --- scroll-linked ---------------------------------------------------------------------
1722
2111
  // These are progress-linked, not time-triggered: they reverse as the user scrolls back.
1723
2112
  // That is by design and is why `timeline:` is a different axis from `on:`.
2113
+ // `distance.distance` only, not the whole `distance` object: `kui-parallax-y`/`-x` (scroll.css)
2114
+ // write only `translate` — this primitive doesn't declare `CHANNEL.opacity`. Same dead-parameter
2115
+ // shape as `scale-move`/`roll` above; `parallax-y`/`parallax-x`/`depth-layer` never read
2116
+ // `--kui-from-opacity`.
1724
2117
  cssPrimitive("parallax", [CHANNEL.translate], {
1725
- parameters: distance,
1726
- timelines: ["view", "scroll"],
2118
+ parameters: { distance: distance.distance },
2119
+ timelines: ["view", "scroll", "pin"],
1727
2120
  activations: ["manual"],
1728
2121
  reducedMotion: "disable",
1729
2122
  perfClass: "compositor"
1730
2123
  }),
2124
+ // `from` exists because the resting end was hardcoded (scale 1 / rotate 0deg), which fixed
2125
+ // these to "grow slightly" and "tilt slightly" — a scroll-driven element that should sweep in
2126
+ // from a quarter-size or from half a turn away had no way to say so. Same `--kui-from-*`
2127
+ // properties the entrance primitives already use, so the two stay spellable the same way.
1731
2128
  cssPrimitive("parallax-scale", [CHANNEL.scale], {
1732
- parameters: { scale: { type: "number", default: "1.2", cssProperty: "--kui-to-scale" } },
1733
- timelines: ["view", "scroll"],
2129
+ parameters: {
2130
+ scale: { type: "number", default: "1.2", cssProperty: "--kui-to-scale" },
2131
+ from: { type: "number", default: "1", cssProperty: "--kui-from-scale" }
2132
+ },
2133
+ timelines: ["view", "scroll", "pin"],
1734
2134
  activations: ["manual"],
1735
2135
  reducedMotion: "disable"
1736
2136
  }),
1737
2137
  cssPrimitive("parallax-rotate", [CHANNEL.rotate], {
1738
- parameters: { angle: { type: "angle", default: "12deg", cssProperty: "--kui-to-angle" } },
1739
- timelines: ["view", "scroll"],
2138
+ parameters: {
2139
+ angle: { type: "angle", default: "12deg", cssProperty: "--kui-to-angle" },
2140
+ from: { type: "angle", default: "0deg", cssProperty: "--kui-from-angle" }
2141
+ },
2142
+ timelines: ["view", "scroll", "pin"],
1740
2143
  activations: ["manual"],
1741
2144
  reducedMotion: "disable"
1742
2145
  }),
1743
2146
  cssPrimitive("scroll-fade", [CHANNEL.opacity], {
1744
2147
  parameters: { opacity: { type: "number", default: "0", cssProperty: "--kui-from-opacity" } },
1745
- timelines: ["view", "scroll"],
2148
+ timelines: ["view", "scroll", "pin"],
2149
+ activations: ["manual"],
2150
+ reducedMotion: "disable"
2151
+ }),
2152
+ // `filter`, not `opacity` — it collides with `blur`, and declaring the real channel is what
2153
+ // makes `channels.ts` say so instead of letting the two silently overwrite each other's
2154
+ // `filter` declaration.
2155
+ cssPrimitive("desaturate", [CHANNEL.filter], {
2156
+ parameters: {
2157
+ from: { type: "percentage", default: "100%", cssProperty: "--kui-from-grayscale" },
2158
+ to: { type: "percentage", default: "0%", cssProperty: "--kui-to-grayscale" }
2159
+ },
2160
+ timelines: ["view", "scroll", "pin"],
2161
+ activations: ["manual"],
2162
+ reducedMotion: "disable",
2163
+ perfClass: "paint"
2164
+ }),
2165
+ /*
2166
+ * `transform`, not one of the independent transform properties, because CSS never shipped a
2167
+ * standalone `skew:`. That is also why `skew` is its own channel rather than folded in with
2168
+ * `rotate`: writing `transform` replaces the entire shorthand, so a skew composed with anything
2169
+ * else that wrote `transform` would silently win. Nothing else in the catalog does — every other
2170
+ * transform in the library goes through `translate`/`rotate`/`scale` — so the shorthand is free.
2171
+ */
2172
+ cssPrimitive("skew", [CHANNEL.skew], {
2173
+ parameters: {
2174
+ from: { type: "angle", default: "8deg", cssProperty: "--kui-from-skew" },
2175
+ to: { type: "angle", default: "0deg", cssProperty: "--kui-to-skew" }
2176
+ },
2177
+ timelines: ["view", "scroll", "pin"],
1746
2178
  activations: ["manual"],
1747
2179
  reducedMotion: "disable"
1748
2180
  }),
@@ -1762,13 +2194,14 @@ var PRIMITIVES = [
1762
2194
  })
1763
2195
  ];
1764
2196
  var p = (name, primitive, keyframes, params) => ({ name, primitive, keyframes, ...params ? { params } : {} });
2197
+ var pIn = (name, primitive, keyframes, params) => ({ ...p(name, primitive, keyframes, params), cloak: true });
1765
2198
  var FADE = [
1766
- p("fade-in", "reveal", "kui-in"),
2199
+ pIn("fade-in", "reveal", "kui-in"),
1767
2200
  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"),
2201
+ pIn("fade-up", "reveal", "kui-in-up"),
2202
+ pIn("fade-down", "reveal", "kui-in-down"),
2203
+ pIn("fade-left", "reveal", "kui-in-left"),
2204
+ pIn("fade-right", "reveal", "kui-in-right"),
1772
2205
  p("fade-out-up", "reveal", "kui-out-up"),
1773
2206
  p("fade-out-down", "reveal", "kui-out-down"),
1774
2207
  p("fade-out-left", "reveal", "kui-out-left"),
@@ -1776,56 +2209,56 @@ var FADE = [
1776
2209
  ];
1777
2210
  var SLIDE_PARAMS = { distance: "100px", opacity: "1" };
1778
2211
  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),
2212
+ pIn("slide-up", "reveal", "kui-in-up", SLIDE_PARAMS),
2213
+ pIn("slide-down", "reveal", "kui-in-down", SLIDE_PARAMS),
2214
+ pIn("slide-left", "reveal", "kui-in-left", SLIDE_PARAMS),
2215
+ pIn("slide-right", "reveal", "kui-in-right", SLIDE_PARAMS),
1783
2216
  p("slide-out-up", "reveal", "kui-out-up", SLIDE_PARAMS),
1784
2217
  p("slide-out-down", "reveal", "kui-out-down", SLIDE_PARAMS),
1785
2218
  p("slide-out-left", "reveal", "kui-out-left", SLIDE_PARAMS),
1786
2219
  p("slide-out-right", "reveal", "kui-out-right", SLIDE_PARAMS)
1787
2220
  ];
1788
2221
  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)
2222
+ pIn("slide-inline-start", "reveal", "kui-in-inline-start", SLIDE_PARAMS),
2223
+ pIn("slide-inline-end", "reveal", "kui-in-inline-end", SLIDE_PARAMS),
2224
+ pIn("slide-block-start", "reveal", "kui-in-up", SLIDE_PARAMS),
2225
+ pIn("slide-block-end", "reveal", "kui-in-down", SLIDE_PARAMS)
1793
2226
  ];
1794
2227
  var ZOOM = [
1795
- p("zoom-in", "scale", "kui-zoom-in"),
2228
+ pIn("zoom-in", "scale", "kui-zoom-in"),
1796
2229
  p("zoom-out", "scale", "kui-zoom-out"),
1797
- p("pop-in", "scale", "kui-zoom-in", { scale: "0.6", ease: "back-out" }),
2230
+ pIn("pop-in", "scale", "kui-zoom-in", { scale: "0.6", ease: "back-out" }),
1798
2231
  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")
2232
+ pIn("zoom-in-up", "scale-move", "kui-zoom-in-up"),
2233
+ pIn("zoom-in-down", "scale-move", "kui-zoom-in-down")
1801
2234
  ];
1802
2235
  var FLIP = [
1803
- p("flip-in-x", "flip-3d", "kui-flip-in-x"),
1804
- p("flip-in-y", "flip-3d", "kui-flip-in-y"),
2236
+ pIn("flip-in-x", "flip-3d", "kui-flip-in-x"),
2237
+ pIn("flip-in-y", "flip-3d", "kui-flip-in-y"),
1805
2238
  p("flip-out-x", "flip-3d", "kui-flip-out-x"),
1806
2239
  p("flip-out-y", "flip-3d", "kui-flip-out-y")
1807
2240
  ];
1808
2241
  var ROTATE = [
1809
- p("rotate-in", "rotate", "kui-rotate-in"),
2242
+ pIn("rotate-in", "rotate", "kui-rotate-in"),
1810
2243
  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"),
2244
+ pIn("rotate-in-left", "rotate", "kui-rotate-in", { angle: "-45deg" }),
2245
+ pIn("rotate-in-right", "rotate", "kui-rotate-in", { angle: "45deg" }),
2246
+ pIn("roll-in", "roll", "kui-roll-in"),
1814
2247
  p("roll-out", "roll", "kui-roll-out"),
1815
- p("swing-in", "rotate", "kui-swing-in", { angle: "-15deg", ease: "back-out" })
2248
+ pIn("swing-in", "rotate", "kui-swing-in", { angle: "-15deg", ease: "back-out" })
1816
2249
  ];
1817
2250
  var BLUR = [
1818
- p("blur-in", "blur", "kui-blur-in"),
2251
+ pIn("blur-in", "blur", "kui-blur-in"),
1819
2252
  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")
2253
+ pIn("fade-blur-up", "reveal-blur", "kui-fade-blur-up"),
2254
+ pIn("fade-blur-in", "reveal-blur", "kui-fade-blur-in")
1822
2255
  ];
1823
2256
  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" })
2257
+ pIn("bounce-in", "scale", "kui-zoom-in", { scale: "0.3", ease: "bounce" }),
2258
+ pIn("bounce-in-up", "reveal", "kui-in-up", { distance: "60px", ease: "back-out" }),
2259
+ pIn("bounce-in-down", "reveal", "kui-in-down", { distance: "60px", ease: "back-out" }),
2260
+ pIn("back-in-up", "reveal", "kui-in-up", { distance: "120px", ease: "expo-out" }),
2261
+ pIn("back-in-down", "reveal", "kui-in-down", { distance: "120px", ease: "expo-out" })
1829
2262
  ];
1830
2263
  var SCROLL = [
1831
2264
  p("parallax-y", "parallax", "kui-parallax-y"),
@@ -1834,11 +2267,14 @@ var SCROLL = [
1834
2267
  p("parallax-rotate", "parallax-rotate", "kui-parallax-rotate"),
1835
2268
  p("depth-layer", "parallax", "kui-parallax-y", { distance: "200px" }),
1836
2269
  p("scroll-fade", "scroll-fade", "kui-scroll-fade"),
2270
+ p("scroll-desaturate", "desaturate", "kui-desaturate"),
2271
+ p("scroll-skew", "skew", "kui-scroll-skew"),
1837
2272
  p("scroll-progress-bar", "progress", "kui-progress-x"),
2273
+ p("scroll-progress-bar-y", "progress", "kui-progress-y"),
1838
2274
  p("scroll-progress-ring", "progress-stroke", "kui-progress-ring"),
1839
2275
  // `reveal-repeat` was removed: it was byte-identical to `reveal-once`, and the activation
1840
2276
  // binder unobserves after first entry, so a repeating reveal is not implementable yet.
1841
- p("reveal-once", "reveal", "kui-in-up")
2277
+ pIn("reveal-once", "reveal", "kui-in-up")
1842
2278
  ];
1843
2279
  var PRESETS = [
1844
2280
  ...FADE,
@@ -1861,6 +2297,59 @@ function registerCore(registry) {
1861
2297
  return registry;
1862
2298
  }
1863
2299
 
2300
+ // src/effects/step-marking.ts
2301
+ var STEP_STATE_ATTR = "data-kui-step-state";
2302
+ function selectorBreadth(selector, doc) {
2303
+ try {
2304
+ if (doc.documentElement.matches(selector)) return "document-wide";
2305
+ if (doc.body?.matches(selector)) return "document-wide";
2306
+ return "ok";
2307
+ } catch {
2308
+ return "invalid";
2309
+ }
2310
+ }
2311
+ function resolveTarget(selector, ctx, effect) {
2312
+ if (!selector) return selector;
2313
+ const breadth = selectorBreadth(selector, ctx.doc);
2314
+ if (breadth === "invalid") {
2315
+ ctx.warn(`${effect} target "${selector}" is not a valid selector and will be ignored`);
2316
+ return "";
2317
+ }
2318
+ if (breadth === "document-wide") {
2319
+ ctx.warn(`${effect} target "${selector}" matches the whole document and will be ignored`);
2320
+ return "";
2321
+ }
2322
+ return selector;
2323
+ }
2324
+ function createStepMarker(resolve) {
2325
+ const ledgers = /* @__PURE__ */ new Map();
2326
+ return {
2327
+ mark(index) {
2328
+ const seen = /* @__PURE__ */ new Map();
2329
+ for (const node of resolve()) {
2330
+ const parent = node.parentElement;
2331
+ const position = seen.get(parent) ?? 0;
2332
+ seen.set(parent, position + 1);
2333
+ let ledger = ledgers.get(node);
2334
+ if (!ledger) {
2335
+ ledger = createAttributeLedger(node);
2336
+ ledgers.set(node, ledger);
2337
+ }
2338
+ ledger.set(STEP_STATE_ATTR, stepStateFor(position, index));
2339
+ }
2340
+ },
2341
+ restore() {
2342
+ for (const ledger of ledgers.values()) ledger.restore();
2343
+ ledgers.clear();
2344
+ }
2345
+ };
2346
+ }
2347
+ function stepStateFor(position, index) {
2348
+ if (position < index) return "before";
2349
+ if (position === index) return "active";
2350
+ return "after";
2351
+ }
2352
+
1864
2353
  // src/effects/forms/primitives.ts
1865
2354
  var timing = {
1866
2355
  duration: { type: "time", default: "400ms", cssProperty: "--kui-duration" },
@@ -1979,10 +2468,18 @@ var RANGE_FILL_PRIMITIVE = jsInputPrimitive(
1979
2468
  function nextStep(step, total) {
1980
2469
  return total > 0 ? (step + 1) % total : 0;
1981
2470
  }
1982
- function prepareStepProgress(el, params) {
2471
+ function prepareStepProgress(el, params, ctx) {
1983
2472
  const total = Math.max(1, Math.round(params.num("steps", 4)));
2473
+ const selector = resolveTarget(params.text("target"), ctx, "step-progress");
2474
+ const marker = createStepMarker(
2475
+ () => selector ? ctx.doc.querySelectorAll(selector) : el.children
2476
+ );
2477
+ const self = createAttributeLedger(el);
1984
2478
  let step = 0;
1985
- const render = () => el.setAttribute("data-kui-step", String(step));
2479
+ const render = () => {
2480
+ self.set("data-kui-step", String(step));
2481
+ marker.mark(step);
2482
+ };
1986
2483
  const advance = () => {
1987
2484
  step = nextStep(step, total);
1988
2485
  render();
@@ -1991,13 +2488,17 @@ function prepareStepProgress(el, params) {
1991
2488
  render();
1992
2489
  return () => {
1993
2490
  el.removeEventListener("click", advance);
1994
- el.removeAttribute("data-kui-step");
2491
+ self.restore();
2492
+ marker.restore();
1995
2493
  };
1996
2494
  }
1997
2495
  var STEP_PROGRESS_PRIMITIVE = jsInputPrimitive(
1998
2496
  "step-progress",
1999
2497
  ["state"],
2000
- { steps: { type: "number", default: "4", cssProperty: "--kui-steps", minimum: 1, maximum: 20, integer: true } },
2498
+ {
2499
+ steps: { type: "number", default: "4", cssProperty: "--kui-steps", minimum: 1, maximum: 20, integer: true },
2500
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
2501
+ },
2001
2502
  deferPrepare(prepareStepProgress)
2002
2503
  );
2003
2504
  function nextSubmitStage(stage) {
@@ -2254,6 +2755,7 @@ function prepareDraggable(el, params, ctx) {
2254
2755
  const resistance = params.num("resistance", 0.55);
2255
2756
  const momentum = params.num("momentum", 0.2);
2256
2757
  const offset = { x: 0, y: 0 };
2758
+ const pickup = { x: 0, y: 0 };
2257
2759
  const deps = springDeps(ctx);
2258
2760
  const runners = {
2259
2761
  x: createSpringRunner(config, (value) => write({ ...offset, x: value }), deps),
@@ -2270,10 +2772,15 @@ function prepareDraggable(el, params, ctx) {
2270
2772
  onStart() {
2271
2773
  runners.x.stop();
2272
2774
  runners.y.stop();
2775
+ pickup.x = offset.x;
2776
+ pickup.y = offset.y;
2273
2777
  el.setAttribute("data-kui-dragging", "true");
2274
2778
  },
2275
2779
  onMove(vector) {
2276
- write({ x: resist(vector.dx, bounds, resistance), y: resist(vector.dy, bounds, resistance) });
2780
+ write({
2781
+ x: resist(pickup.x + vector.dx, bounds, resistance),
2782
+ y: resist(pickup.y + vector.dy, bounds, resistance)
2783
+ });
2277
2784
  },
2278
2785
  onEnd(vector) {
2279
2786
  el.setAttribute("data-kui-dragging", "false");
@@ -2301,8 +2808,9 @@ function settle(runners, offset, vector, mode) {
2301
2808
  runners.y.to(0);
2302
2809
  return;
2303
2810
  }
2304
- runners.x.to(offset.x + vector.vx * mode.momentum);
2305
- runners.y.to(offset.y + vector.vy * mode.momentum);
2811
+ const carry = mode.inertia ? mode.momentum : 0;
2812
+ runners.x.to(offset.x + vector.vx * carry);
2813
+ runners.y.to(offset.y + vector.vy * carry);
2306
2814
  }
2307
2815
  function prepareSwipeable(el, params) {
2308
2816
  const stop = recognise(
@@ -2586,9 +3094,12 @@ function prepareAutoHeight(el, params, ctx) {
2586
3094
  ctx.style.claim("height");
2587
3095
  const duration = effectDurationMs(params, 400);
2588
3096
  let animation = null;
3097
+ let previous = node.getBoundingClientRect().height;
2589
3098
  const observer = watchAttribute(node, params.text("attribute"), () => {
2590
3099
  animation?.cancel();
2591
- animation = animateHeight(node, duration, params.text("ease"));
3100
+ const endpoints = heightEndpoints(node, previous);
3101
+ previous = endpoints.to;
3102
+ animation = animateHeight(node, endpoints, duration, params.text("ease"));
2592
3103
  });
2593
3104
  ctx.invalidate();
2594
3105
  return () => {
@@ -2596,13 +3107,19 @@ function prepareAutoHeight(el, params, ctx) {
2596
3107
  animation?.cancel();
2597
3108
  };
2598
3109
  }
2599
- function animateHeight(node, duration, easing) {
2600
- const from = `${node.getBoundingClientRect().height}px`;
3110
+ function heightEndpoints(node, previous) {
2601
3111
  node.style.removeProperty("height");
2602
- const to = `${node.scrollHeight}px`;
3112
+ const to = node.getBoundingClientRect().height;
3113
+ return { from: previous, to };
3114
+ }
3115
+ function animateHeight(node, endpoints, duration, easing) {
2603
3116
  const animate = node.animate;
2604
3117
  if (typeof animate !== "function") return null;
2605
- return animate.call(node, [{ height: from }, { height: to }], { duration, easing, fill: "none" });
3118
+ return animate.call(
3119
+ node,
3120
+ [{ height: `${endpoints.from}px` }, { height: `${endpoints.to}px` }],
3121
+ { duration, easing, fill: "none" }
3122
+ );
2606
3123
  }
2607
3124
  function prepareIndicator(el, params, ctx) {
2608
3125
  const node = el;
@@ -2732,27 +3249,42 @@ function subscribeScrollTop(el, ctx, onScrollTop) {
2732
3249
  }
2733
3250
  function prepareHeaderShrink(el, params, ctx) {
2734
3251
  const offset = params.num("offset", 120);
2735
- return subscribeScrollTop(el, ctx, (top) => {
3252
+ const state = createAttributeLedger(el);
3253
+ const unsubscribe = subscribeScrollTop(el, ctx, (top) => {
2736
3254
  const progress = offset > 0 ? Math.min(1, Math.max(0, top / offset)) : 1;
2737
3255
  ctx.style.set("--kui-shrink", progress.toFixed(4));
2738
- el.setAttribute("data-kui-shrunk", String(progress >= 1));
3256
+ state.set("data-kui-shrunk", String(progress >= 1));
2739
3257
  });
3258
+ return () => {
3259
+ unsubscribe();
3260
+ state.restore();
3261
+ };
2740
3262
  }
2741
3263
  function prepareHeaderHide(el, params, ctx) {
2742
3264
  const minDelta = params.num("offset", 8);
2743
3265
  let last = 0;
2744
- return subscribeScrollTop(el, ctx, (top) => {
3266
+ const state = createAttributeLedger(el);
3267
+ const unsubscribe = subscribeScrollTop(el, ctx, (top) => {
2745
3268
  const delta = top - last;
2746
3269
  if (Math.abs(delta) < minDelta) return;
2747
- el.setAttribute("data-kui-hidden", String(delta > 0 && top > minDelta));
3270
+ state.set("data-kui-hidden", String(delta > 0 && top > minDelta));
2748
3271
  last = top;
2749
3272
  });
3273
+ return () => {
3274
+ unsubscribe();
3275
+ state.restore();
3276
+ };
2750
3277
  }
2751
3278
  function prepareBackToTop(el, params, ctx) {
2752
3279
  const offset = params.num("offset", 400);
2753
- return subscribeScrollTop(el, ctx, (top) => {
2754
- el.setAttribute("data-kui-visible", String(top > offset));
3280
+ const state = createAttributeLedger(el);
3281
+ const unsubscribe = subscribeScrollTop(el, ctx, (top) => {
3282
+ state.set("data-kui-visible", String(top > offset));
2755
3283
  });
3284
+ return () => {
3285
+ unsubscribe();
3286
+ state.restore();
3287
+ };
2756
3288
  }
2757
3289
  var NAV_JS_PRIMITIVES = [
2758
3290
  navPrimitive(
@@ -2790,13 +3322,38 @@ var domGeometry = (el) => {
2790
3322
  const rect = el.getBoundingClientRect();
2791
3323
  return { top: rect.top, height: rect.height };
2792
3324
  };
3325
+ var domPosition = (el) => {
3326
+ const view = el.ownerDocument?.defaultView;
3327
+ return view ? view.getComputedStyle(el).position : "static";
3328
+ };
3329
+ var domOffsetTop = (el) => {
3330
+ const view = el.ownerDocument?.defaultView;
3331
+ if (!view) return 0;
3332
+ const parsed = Number.parseFloat(view.getComputedStyle(el).top);
3333
+ return Number.isFinite(parsed) ? parsed : 0;
3334
+ };
3335
+ function geometrySource(el, positionOf) {
3336
+ let outermostSticky = null;
3337
+ for (let node = el; node; node = node.parentElement) {
3338
+ if (positionOf(node) === "sticky") outermostSticky = node;
3339
+ }
3340
+ return outermostSticky?.parentElement ?? el;
3341
+ }
3342
+ function sourceTop(el, box, measure, positionOf) {
3343
+ const source = geometrySource(el, positionOf);
3344
+ return source === el ? box.top : measure(source).top;
3345
+ }
2793
3346
  function trackProgress(el, ctx, options, onProgress) {
2794
3347
  const measure = options.measure ?? domGeometry;
3348
+ const positionOf = options.positionOf ?? domPosition;
3349
+ const offsetOf = options.offsetOf ?? domOffsetTop;
2795
3350
  let scrollTop = 0;
2796
3351
  let scrollportTop = 0;
2797
3352
  const geometry2 = createMeasureCache(() => {
2798
3353
  const box = measure(el);
2799
- return { contentTop: box.top - scrollportTop + scrollTop, height: box.height };
3354
+ const top = options.contentAnchor ? measure(options.contentAnchor).top - box.height : sourceTop(el, box, measure, positionOf);
3355
+ const stickyOffset = options.stickyEl ? offsetOf(options.stickyEl) : 0;
3356
+ return { contentTop: top - stickyOffset - scrollportTop + scrollTop, height: box.height };
2800
3357
  });
2801
3358
  return ctx.scheduler.subscribe(ctx.rootFor(el), (frame) => {
2802
3359
  scrollTop = frame.metrics.scrollTop;
@@ -2822,11 +3379,167 @@ function progressFrom(top, span) {
2822
3379
  return clamp01(-top / span);
2823
3380
  }
2824
3381
 
3382
+ // src/effects/scroll-mechanics/scroll-spy.ts
3383
+ function prepareScrollSpy(el, params, ctx) {
3384
+ const sectionsAuthored = params.text("sections");
3385
+ if (sectionsAuthored) {
3386
+ const sectionsSelector = resolveTarget(sectionsAuthored, ctx, "scroll-spy sections");
3387
+ return prepareScrollSpyContainer(el, params, ctx, sectionsSelector);
3388
+ }
3389
+ return prepareScrollSpySingle(el, params, ctx);
3390
+ }
3391
+ function prepareScrollSpySingle(el, params, ctx) {
3392
+ if (params.text("offset-top", "0px") !== "0px") {
3393
+ ctx.warn('scroll-spy "offset-top" has no effect without sections: and is ignored here');
3394
+ }
3395
+ const selector = resolveTarget(params.text("target"), ctx, "scroll-spy");
3396
+ const links = /* @__PURE__ */ new Map();
3397
+ const self = createAttributeLedger(el);
3398
+ let last;
3399
+ const untrack = trackProgress(el, ctx, { distance: params.text("distance") }, (progress) => {
3400
+ const active = progress > 0 && progress < 1;
3401
+ if (active === last) return;
3402
+ last = active;
3403
+ self.set("data-kui-active", String(active));
3404
+ if (selector) markLinks(ctx.doc, selector, active, links);
3405
+ });
3406
+ return continuousSetup(() => {
3407
+ untrack();
3408
+ self.restore();
3409
+ for (const ledger of links.values()) ledger.restore();
3410
+ });
3411
+ }
3412
+ function markLinks(doc, selector, active, links) {
3413
+ for (const link of doc.querySelectorAll(selector)) {
3414
+ let ledger = links.get(link);
3415
+ if (!ledger) {
3416
+ ledger = createAttributeLedger(link);
3417
+ links.set(link, ledger);
3418
+ }
3419
+ ledger.set("data-kui-active", String(active));
3420
+ }
3421
+ }
3422
+ function hrefHash(link) {
3423
+ const href = link.getAttribute("href") ?? "";
3424
+ const at = href.indexOf("#");
3425
+ return at === -1 ? "" : href.slice(at);
3426
+ }
3427
+ function pairSectionsWithLinks(sections, links, ctx, sectionsSelector) {
3428
+ const linksByHash = /* @__PURE__ */ new Map();
3429
+ for (const link of links) {
3430
+ const hash = hrefHash(link);
3431
+ if (hash) linksByHash.set(hash, link);
3432
+ }
3433
+ const claimed = /* @__PURE__ */ new Set();
3434
+ const pairs = sections.map((section) => {
3435
+ if (!section.id) {
3436
+ ctx.warn(
3437
+ `scroll-spy: a section matched by sections:"${sectionsSelector}" has no id and cannot be paired with a link`
3438
+ );
3439
+ return { section, link: null };
3440
+ }
3441
+ const hash = `#${section.id}`;
3442
+ const link = linksByHash.get(hash) ?? null;
3443
+ if (link) claimed.add(hash);
3444
+ return { section, link };
3445
+ });
3446
+ for (const [hash, link] of linksByHash) {
3447
+ if (!claimed.has(hash)) {
3448
+ ctx.warn(`scroll-spy: link "${link.getAttribute("href")}" matches no section id in sections:"${sectionsSelector}"`);
3449
+ }
3450
+ }
3451
+ return pairs;
3452
+ }
3453
+ function offsetTopPixels(authored, frame) {
3454
+ return toPixels(
3455
+ authored,
3456
+ {
3457
+ viewportWidth: frame.metrics.viewportWidth,
3458
+ viewportHeight: frame.metrics.viewportHeight,
3459
+ percentBasis: 0,
3460
+ fontSize: 16,
3461
+ rootFontSize: 16
3462
+ },
3463
+ 0
3464
+ );
3465
+ }
3466
+ function highestReachedIndex(tops, scrollTop, line) {
3467
+ let index = -1;
3468
+ for (let i = 0; i < tops.length; i++) {
3469
+ if (tops[i] - scrollTop - line <= 0) index = i;
3470
+ }
3471
+ return index;
3472
+ }
3473
+ function prepareScrollSpyContainer(el, params, ctx, sectionsSelector) {
3474
+ if (params.text("distance", "100vh") !== "100vh") {
3475
+ ctx.warn('scroll-spy "distance" has no effect with sections: \u2014 each section measures its own height');
3476
+ }
3477
+ const linksSelector = resolveTarget(params.text("target"), ctx, "scroll-spy target");
3478
+ const sections = sectionsSelector ? [...el.querySelectorAll(sectionsSelector)] : [];
3479
+ if (sectionsSelector && sections.length === 0) {
3480
+ ctx.warn(`scroll-spy sections:"${sectionsSelector}" matched nothing inside this element`);
3481
+ }
3482
+ const links = linksSelector ? [...el.querySelectorAll(linksSelector)] : [];
3483
+ const pairs = pairSectionsWithLinks(sections, links, ctx, sectionsSelector);
3484
+ const sectionLedgers = pairs.map((pair) => createAttributeLedger(pair.section));
3485
+ const linkLedgers = /* @__PURE__ */ new Map();
3486
+ for (const { link } of pairs) {
3487
+ if (link && !linkLedgers.has(link)) linkLedgers.set(link, createAttributeLedger(link));
3488
+ }
3489
+ const offsetAuthored = params.text("offset-top", "0px");
3490
+ let scrollTop = 0;
3491
+ let scrollportTop = 0;
3492
+ const contentTops = createMeasureCache(
3493
+ () => pairs.map(({ section }) => domGeometry(section).top - scrollportTop + scrollTop)
3494
+ );
3495
+ let active = -1;
3496
+ function setActive(index, value) {
3497
+ const pair = pairs[index];
3498
+ sectionLedgers[index].set("data-kui-active", String(value));
3499
+ if (pair.link) linkLedgers.get(pair.link).set("data-kui-active", String(value));
3500
+ }
3501
+ const untrack = ctx.scheduler.subscribe(ctx.rootFor(el), (frame) => {
3502
+ scrollTop = frame.metrics.scrollTop;
3503
+ scrollportTop = frame.metrics.viewportTop;
3504
+ const tops = contentTops.read(frame.epoch);
3505
+ const line = offsetTopPixels(offsetAuthored, frame);
3506
+ const next = highestReachedIndex(tops, scrollTop, line);
3507
+ if (next === active) return;
3508
+ if (active !== -1) setActive(active, false);
3509
+ if (next !== -1) setActive(next, true);
3510
+ active = next;
3511
+ });
3512
+ return continuousSetup(() => {
3513
+ untrack();
3514
+ for (const ledger of sectionLedgers) ledger.restore();
3515
+ for (const ledger of linkLedgers.values()) ledger.restore();
3516
+ });
3517
+ }
3518
+
2825
3519
  // src/effects/scroll-mechanics/primitives.ts
2826
3520
  var PROGRESS_VAR = "--kui-progress";
2827
3521
  var distanceParam = {
2828
3522
  distance: { type: "length", default: "100vh", cssProperty: "--kui-distance" }
2829
3523
  };
3524
+ var stickyParams = {
3525
+ "offset-top": {
3526
+ type: "length",
3527
+ default: "var(--kui-pin-offset, 0px)",
3528
+ cssProperty: "--kui-offset-top"
3529
+ },
3530
+ spacer: {
3531
+ type: "keyword",
3532
+ default: "false",
3533
+ cssProperty: "--kui-spacer",
3534
+ values: ["true", "false"]
3535
+ }
3536
+ };
3537
+ function installSticky(node, params, ctx) {
3538
+ ctx.style.set("position", "sticky");
3539
+ ctx.style.set("top", params.text("offset-top", "var(--kui-pin-offset, 0px)"));
3540
+ const inserted = params.is("spacer") ? insertSpacer(node, params.text("distance"), ctx) : null;
3541
+ return { spacer: inserted?.spacer ?? null, dispose: () => inserted?.remove() };
3542
+ }
2830
3543
  function scrollPrimitive(spec) {
2831
3544
  const { id, channels, parameters, prepare, perfClass = "compositor" } = spec;
2832
3545
  return {
@@ -2834,7 +3547,12 @@ function scrollPrimitive(spec) {
2834
3547
  renderer: "javascript",
2835
3548
  channels,
2836
3549
  parameters,
2837
- supportedTimelines: ["time", "view", "scroll"],
3550
+ // `'pin'` is accepted but ignored: these primitives read scroll position themselves and are
3551
+ // never driven by an `animation-timeline`. It is listed so that composing the driver with the
3552
+ // effects it drives — `data-kui="pin-section distance:200vh, parallax-rotate ... "` plus
3553
+ // `timeline:pin` — survives `compile.ts`'s `intersect`. Without it the intersection empties,
3554
+ // `style-plan.ts` refuses the timeline, and the scrub silently degrades to a one-shot.
3555
+ supportedTimelines: ["time", "view", "scroll", "pin"],
2838
3556
  supportedActivations: ["manual", "load", "enter"],
2839
3557
  defaultActivation: "load",
2840
3558
  perfClass,
@@ -2847,19 +3565,17 @@ function writeProgress(ctx, progress) {
2847
3565
  }
2848
3566
  function preparePin(el, params, ctx) {
2849
3567
  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;
3568
+ const { dispose: unstick } = installSticky(node, params, ctx);
2853
3569
  const tracked = node.parentElement ?? el;
2854
- const untrack = trackProgress(tracked, ctx, { distance: params.text("distance") }, (progress) => {
3570
+ const untrack = trackProgress(tracked, ctx, { distance: params.text("distance"), stickyEl: node }, (progress) => {
2855
3571
  writeProgress(ctx, progress);
2856
3572
  el.setAttribute("data-kui-pinned", progress > 0 && progress < 1 ? "true" : "false");
2857
3573
  });
2858
- return () => {
3574
+ return continuousSetup(() => {
2859
3575
  untrack();
2860
- removeSpacer?.();
3576
+ unstick();
2861
3577
  el.removeAttribute("data-kui-pinned");
2862
- };
3578
+ });
2863
3579
  }
2864
3580
  function insertSpacer(node, distance2, ctx) {
2865
3581
  const spacer = ctx.doc.createElement("div");
@@ -2869,34 +3585,81 @@ function insertSpacer(node, distance2, ctx) {
2869
3585
  spacer.style.pointerEvents = "none";
2870
3586
  node.after(spacer);
2871
3587
  ctx.invalidate();
2872
- return () => {
2873
- spacer.remove();
2874
- ctx.invalidate();
3588
+ return {
3589
+ spacer,
3590
+ remove: () => {
3591
+ spacer.remove();
3592
+ ctx.invalidate();
3593
+ }
2875
3594
  };
2876
3595
  }
2877
3596
  function prepareProgress(el, params, ctx) {
2878
3597
  const steps = Math.max(0, Math.round(params.num("steps", 0)));
3598
+ const selector = resolveTarget(params.text("target"), ctx, "scrollytelling-step");
3599
+ const marker = createStepMarker(() => ctx.doc.querySelectorAll(selector));
3600
+ const self = createAttributeLedger(el);
3601
+ let lastIndex;
2879
3602
  const untrack = trackProgress(el, ctx, { distance: params.text("distance") }, (progress) => {
2880
3603
  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
- }
3604
+ if (steps === 0) return;
3605
+ const index = Math.min(steps - 1, Math.floor(progress * steps));
3606
+ if (index === lastIndex) return;
3607
+ lastIndex = index;
3608
+ self.set("data-kui-step", String(index));
3609
+ ctx.style.set("--kui-step", String(index));
3610
+ if (selector) marker.mark(index);
2885
3611
  });
2886
- return () => {
3612
+ return continuousSetup(() => {
2887
3613
  untrack();
2888
- el.removeAttribute("data-kui-step");
2889
- };
3614
+ self.restore();
3615
+ marker.restore();
3616
+ });
2890
3617
  }
2891
3618
  function prepareHorizontal(el, params, ctx) {
2892
- const node = el;
3619
+ const selector = resolveTarget(params.text("target"), ctx, "horizontal-scroll");
3620
+ if (!selector) return prepareBareTrack(el, params, ctx);
3621
+ const track = el.querySelector(selector);
3622
+ if (!track) {
3623
+ ctx.warn(`horizontal-scroll target "${selector}" matched nothing inside this element`);
3624
+ return () => {
3625
+ };
3626
+ }
3627
+ return prepareManagedTrack(el, track, params, ctx);
3628
+ }
3629
+ function prepareBareTrack(node, params, ctx) {
2893
3630
  const authored = params.text("travel", "auto");
2894
3631
  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`);
3632
+ return continuousSetup(
3633
+ trackProgress(node, ctx, { distance: params.text("distance") }, (progress, frame) => {
3634
+ ctx.style.set("translate", `${-progress * travel.read(frame.epoch)}px 0`);
3635
+ writeProgress(ctx, progress);
3636
+ })
3637
+ );
3638
+ }
3639
+ function prepareManagedTrack(host, track, params, ctx) {
3640
+ const offsetTop = params.text("offset-top", "var(--kui-pin-offset, 0px)");
3641
+ ctx.style.set("position", "sticky");
3642
+ ctx.style.set("top", offsetTop);
3643
+ ctx.style.set("height", `calc(100vh - ${offsetTop})`);
3644
+ ctx.style.set("overflow", "hidden");
3645
+ ctx.style.set("display", "grid");
3646
+ ctx.style.set("align-content", "center");
3647
+ const { remove: removeSpacer } = insertSpacer(host, params.text("distance"), ctx);
3648
+ const rail = createStyleLedger(track);
3649
+ rail.set("display", "flex");
3650
+ rail.set("width", "max-content");
3651
+ const authored = params.text("travel", "auto");
3652
+ const travel = createMeasureCache(() => trackTravel(track, authored, track.ownerDocument));
3653
+ const tracked = host.parentElement ?? host;
3654
+ const untrack = trackProgress(tracked, ctx, { distance: params.text("distance"), stickyEl: host }, (progress, frame) => {
3655
+ rail.set("translate", `${-progress * travel.read(frame.epoch)}px 0`);
2897
3656
  writeProgress(ctx, progress);
2898
3657
  });
2899
- return untrack;
3658
+ return continuousSetup(() => {
3659
+ untrack();
3660
+ rail.restore();
3661
+ removeSpacer();
3662
+ });
2900
3663
  }
2901
3664
  function trackTravel(node, authored, doc) {
2902
3665
  if (authored && authored !== "auto") return toPixels(authored, ABSOLUTE_BASIS, 0);
@@ -2906,11 +3669,41 @@ function trackTravel(node, authored, doc) {
2906
3669
  return Math.max(0, node.scrollWidth - viewportWidth);
2907
3670
  }
2908
3671
  function prepareMediaScrub(el, params, ctx) {
3672
+ const managed = params.is("spacer") ? installSticky(el, params, ctx) : null;
3673
+ const contentAnchor = managed?.spacer ?? void 0;
3674
+ const stickyEl = managed ? el : void 0;
3675
+ const selector = resolveTarget(params.text("target"), ctx, "media-scrub");
3676
+ const scrub = selector ? prepareTargetScrub(el, params, ctx, { selector, contentAnchor, stickyEl }) : prepareSrcScrub(el, params, ctx, { contentAnchor, stickyEl });
3677
+ return continuousSetup(() => {
3678
+ scrub();
3679
+ managed?.dispose();
3680
+ });
3681
+ }
3682
+ function prepareTargetScrub(el, params, ctx, authored) {
3683
+ const { selector, contentAnchor, stickyEl } = authored;
3684
+ const marker = createStepMarker(() => ctx.doc.querySelectorAll(selector));
3685
+ const frames = Math.max(1, ctx.doc.querySelectorAll(selector).length);
3686
+ let lastIndex;
3687
+ const untrack = trackProgress(el, ctx, { distance: params.text("distance"), contentAnchor, stickyEl }, (progress) => {
3688
+ writeProgress(ctx, progress);
3689
+ const index = Math.min(frames - 1, Math.floor(progress * frames));
3690
+ if (index === lastIndex) return;
3691
+ lastIndex = index;
3692
+ marker.mark(index);
3693
+ });
3694
+ marker.mark(0);
3695
+ return () => {
3696
+ untrack();
3697
+ marker.restore();
3698
+ };
3699
+ }
3700
+ function prepareSrcScrub(el, params, ctx, anchors) {
3701
+ const { contentAnchor, stickyEl } = anchors;
2909
3702
  const frames = Math.max(1, Math.round(params.num("frames", 1)));
2910
3703
  const pattern = mediaSrcPattern(params.text("src"), ctx);
2911
3704
  const media = el;
2912
3705
  let lastIndex = -1;
2913
- const untrack = trackProgress(el, ctx, { distance: params.text("distance") }, (progress) => {
3706
+ const untrack = trackProgress(el, ctx, { distance: params.text("distance"), contentAnchor, stickyEl }, (progress) => {
2914
3707
  writeProgress(ctx, progress);
2915
3708
  const index = Math.min(frames - 1, Math.floor(progress * frames));
2916
3709
  if (index === lastIndex) return;
@@ -2934,80 +3727,37 @@ function applyFrame(media, write) {
2934
3727
  if (media.tagName !== "IMG") return;
2935
3728
  if (pattern) media.src = pattern.replace("{i}", String(index).padStart(String(frames).length, "0"));
2936
3729
  }
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
- });
3730
+ function prepareSmoothScroll(el, params, ctx) {
3731
+ ctx.style.set("scroll-behavior", params.text("behavior", "smooth"));
2949
3732
  return () => {
2950
- untrack();
2951
- self.restore();
2952
- for (const ledger of links.values()) ledger.restore();
2953
3733
  };
2954
3734
  }
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
3735
  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));
3736
+ const axis = params.is("axis", "x") ? "x" : "y";
3737
+ ctx.style.set("scroll-snap-type", `${axis} ${params.text("strictness", "mandatory")}`);
3738
+ const selector = resolveTarget(params.text("target"), ctx, "scroll-snap");
3739
+ const items = selector ? [...el.querySelectorAll(selector)] : [...el.children];
3740
+ if (selector && items.length === 0) {
3741
+ ctx.warn(`scroll-snap target "${selector}" matched nothing inside this element`);
3742
+ }
3743
+ if (selector) installSnapContainer(axis, ctx);
3744
+ const childLedgers = items.map((child) => createStyleLedger(child));
2993
3745
  for (const ledger of childLedgers) ledger.set("scroll-snap-align", params.text("align", "start"));
2994
3746
  return () => {
2995
3747
  for (const ledger of childLedgers) ledger.restore();
2996
3748
  };
2997
3749
  }
3750
+ function installSnapContainer(axis, ctx) {
3751
+ ctx.style.set(axis === "x" ? "overflow-x" : "overflow-y", "auto");
3752
+ if (axis === "x") ctx.style.set("display", "flex");
3753
+ }
2998
3754
  var SCROLL_PRIMITIVES = [
2999
3755
  scrollPrimitive({
3000
3756
  id: "pin",
3001
3757
  channels: ["layout", "progress"],
3002
3758
  parameters: {
3003
3759
  ...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
- }
3760
+ ...stickyParams
3011
3761
  },
3012
3762
  prepare: deferPrepare(preparePin),
3013
3763
  perfClass: "layout"
@@ -3017,7 +3767,10 @@ var SCROLL_PRIMITIVES = [
3017
3767
  channels: ["progress"],
3018
3768
  parameters: {
3019
3769
  ...distanceParam,
3020
- steps: { type: "number", default: "0", cssProperty: "--kui-steps" }
3770
+ steps: { type: "number", default: "0", cssProperty: "--kui-steps" },
3771
+ // Same name, same shape and the same validation as scroll-spy's: one `target:` convention
3772
+ // across the library rather than a second word for "the elements this effect marks".
3773
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
3021
3774
  },
3022
3775
  prepare: deferPrepare(prepareProgress)
3023
3776
  }),
@@ -3026,7 +3779,16 @@ var SCROLL_PRIMITIVES = [
3026
3779
  channels: ["translate", "progress"],
3027
3780
  parameters: {
3028
3781
  ...distanceParam,
3029
- travel: { type: "text", default: "auto", cssProperty: "--kui-travel" }
3782
+ travel: { type: "text", default: "auto", cssProperty: "--kui-travel" },
3783
+ // Same name, same shape and the same validation as scroll-spy's and media-scrub's: one
3784
+ // `target:` convention across the library. Naming the row that moves is also what opts this
3785
+ // primitive into owning the stage, the sticky window and the row's layout itself.
3786
+ target: { type: "text", default: "", cssProperty: "--kui-target" },
3787
+ "offset-top": {
3788
+ type: "length",
3789
+ default: "var(--kui-pin-offset, 0px)",
3790
+ cssProperty: "--kui-offset-top"
3791
+ }
3030
3792
  },
3031
3793
  prepare: deferPrepare(prepareHorizontal)
3032
3794
  }),
@@ -3035,8 +3797,13 @@ var SCROLL_PRIMITIVES = [
3035
3797
  channels: ["media", "progress"],
3036
3798
  parameters: {
3037
3799
  ...distanceParam,
3800
+ // A scrub is a hold, so it needs the same two knobs a pin does. Declaring them here is what
3801
+ // lets `sequence-scrub` become one attribute with no wrapper at all.
3802
+ ...stickyParams,
3038
3803
  frames: { type: "number", default: "1", cssProperty: "--kui-frames" },
3039
- src: { type: "text", default: "", cssProperty: "--kui-src" }
3804
+ src: { type: "text", default: "", cssProperty: "--kui-src" },
3805
+ // The preferred form. `frames:`/`src:` remain for sequences too long to author as tags.
3806
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
3040
3807
  },
3041
3808
  prepare: deferPrepare(prepareMediaScrub),
3042
3809
  perfClass: "paint"
@@ -3045,11 +3812,30 @@ var SCROLL_PRIMITIVES = [
3045
3812
  id: "scroll-spy",
3046
3813
  channels: ["state"],
3047
3814
  parameters: {
3815
+ // `distance`: the per-section form only. `offset-top`: the container form only. Each is a
3816
+ // no-op — warned, not silent — in the other; see `prepareScrollSpySingle` and
3817
+ // `prepareScrollSpyContainer`.
3048
3818
  ...distanceParam,
3049
- target: { type: "text", default: "", cssProperty: "--kui-target" }
3819
+ // Same name and meaning in both forms: the link(s) this instance marks. Per-section, the
3820
+ // one link this section names; with `sections:`, every link `target:` matches, each paired
3821
+ // to its own section by `href`. See `prepareScrollSpyContainer`.
3822
+ target: { type: "text", default: "", cssProperty: "--kui-target" },
3823
+ // Presence, not value, selects the container form: authoring this at all switches
3824
+ // `prepareScrollSpy` from one-section-per-instance to one-instance-on-the-shared-ancestor.
3825
+ sections: { type: "text", default: "", cssProperty: "--kui-sections" },
3826
+ "offset-top": { type: "length", default: "0px", cssProperty: "--kui-offset-top" }
3050
3827
  },
3051
3828
  prepare: deferPrepare(prepareScrollSpy)
3052
3829
  }),
3830
+ scrollPrimitive({
3831
+ id: "smooth-scroll",
3832
+ channels: ["layout"],
3833
+ parameters: {
3834
+ behavior: { type: "keyword", default: "smooth", cssProperty: "--kui-scroll-behavior", values: ["smooth", "auto"] }
3835
+ },
3836
+ prepare: deferPrepare(prepareSmoothScroll),
3837
+ perfClass: "layout"
3838
+ }),
3053
3839
  scrollPrimitive({
3054
3840
  id: "scroll-snap",
3055
3841
  channels: ["layout"],
@@ -3066,7 +3852,11 @@ var SCROLL_PRIMITIVES = [
3066
3852
  default: "start",
3067
3853
  cssProperty: "--kui-snap-align",
3068
3854
  values: ["start", "center", "end"]
3069
- }
3855
+ },
3856
+ // Names the snap items. Also what opts this primitive into owning the scroll container
3857
+ // itself — see `installSnapContainer`. Without it, the direct children are the items and
3858
+ // the page keeps its own `overflow`, exactly as before.
3859
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
3070
3860
  },
3071
3861
  prepare: deferPrepare(prepareSnap),
3072
3862
  perfClass: "layout"
@@ -3089,11 +3879,28 @@ var SCROLL_PRESETS = [
3089
3879
  // --- travel ------------------------------------------------------------------------------
3090
3880
  { name: "horizontal-scroll", primitive: "horizontal-track" },
3091
3881
  // --- media -------------------------------------------------------------------------------
3092
- { name: "sequence-scrub", primitive: "media-scrub" },
3882
+ /*
3883
+ * `spacer:true` is what deletes the `.scrub-stage` wrapper a page used to hand-write.
3884
+ *
3885
+ * It could not be switched on until the tracker stopped measuring against the parent. A scrub
3886
+ * makes itself sticky, and `geometrySource` escapes a sticky subtree by taking its parent — so
3887
+ * with the wrapper gone the scrub was measured against whatever section contained it. Measured
3888
+ * on `demo/scroll.html`: the parent started 926px above the scrub against a 1817px distance, so
3889
+ * progress reached 51% before the element had even stuck and half the sequence played off
3890
+ * screen. The wrapper was not ceremony; it was the tight box that made the parent honest.
3891
+ *
3892
+ * `trackProgress`'s `contentAnchor` is the fix. Progress is read from the spacer, which the
3893
+ * library inserts, is exactly `distance` tall, is never sticky, and moves with the content — so
3894
+ * there is no wrapper to write and nothing to disagree with.
3895
+ *
3896
+ * `video-scrub` stays off: a video positioned by the page is not asking the library for a box.
3897
+ */
3898
+ { name: "sequence-scrub", primitive: "media-scrub", params: { spacer: "true" } },
3093
3899
  { name: "video-scrub", primitive: "media-scrub" },
3094
3900
  // --- navigation --------------------------------------------------------------------------
3095
3901
  { name: "scroll-spy", primitive: "scroll-spy" },
3096
3902
  // --- native CSS passthroughs ---------------------------------------------------------------
3903
+ { name: "smooth-scroll-to", primitive: "smooth-scroll" },
3097
3904
  { name: "scroll-snap-x", primitive: "scroll-snap", params: { axis: "x" } },
3098
3905
  { name: "scroll-snap-y", primitive: "scroll-snap", params: { axis: "y" } }
3099
3906
  ];
@@ -3108,11 +3915,16 @@ var COMMAND = /([mlhvcz])|(-?(?:\d+(?:\.\d+)?|\.\d+))/gi;
3108
3915
  var UNSUPPORTED = /[AaSsQqTt]/;
3109
3916
  function parsePath(d) {
3110
3917
  if (UNSUPPORTED.test(d)) {
3111
- return { segments: [], reason: "arc and shorthand commands (A S Q T) are not supported" };
3918
+ return {
3919
+ segments: [],
3920
+ subpaths: [],
3921
+ reason: "arc and shorthand commands (A S Q T) are not supported"
3922
+ };
3112
3923
  }
3113
3924
  const tokens = [...d.matchAll(COMMAND)].map((m) => m[1] ?? m[2]);
3114
3925
  const state = {
3115
- segments: [],
3926
+ subpaths: [],
3927
+ open: void 0,
3116
3928
  current: { x: 0, y: 0 },
3117
3929
  start: { x: 0, y: 0 },
3118
3930
  command: ""
@@ -3126,18 +3938,33 @@ function parsePath(d) {
3126
3938
  index++;
3127
3939
  continue;
3128
3940
  }
3129
- if (!state.command) return { segments: [], reason: "path must start with a command letter" };
3941
+ if (!state.command) {
3942
+ return { segments: [], subpaths: [], reason: "path must start with a command letter" };
3943
+ }
3130
3944
  index = consume(tokens, index, state);
3131
3945
  }
3132
- if (state.segments.length === 0) return { segments: [], reason: "no drawable segments" };
3133
- return { segments: state.segments };
3946
+ if (state.subpaths.length === 0) {
3947
+ return { segments: [], subpaths: [], reason: "no drawable segments" };
3948
+ }
3949
+ return { segments: state.subpaths.flatMap((sub) => sub.segments), subpaths: state.subpaths };
3950
+ }
3951
+ function pushSegment(state, segment) {
3952
+ if (!state.open) {
3953
+ state.open = { segments: [], closed: false };
3954
+ state.subpaths.push(state.open);
3955
+ }
3956
+ state.open.segments.push(segment);
3134
3957
  }
3135
3958
  var ARITY = { m: 2, l: 2, h: 1, v: 1, c: 6, z: 0 };
3136
3959
  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));
3960
+ const { current, start, open } = state;
3961
+ if (!open) return;
3962
+ if (Math.abs(current.x - start.x) >= 1e-6 || Math.abs(current.y - start.y) >= 1e-6) {
3963
+ open.segments.push(lineToCubic(current, start));
3964
+ }
3965
+ open.closed = true;
3140
3966
  state.current = { ...start };
3967
+ state.open = void 0;
3141
3968
  }
3142
3969
  function consume(tokens, index, state) {
3143
3970
  const key = state.command.toLowerCase();
@@ -3149,10 +3976,11 @@ function consume(tokens, index, state) {
3149
3976
  if (key === "m") {
3150
3977
  state.current = next;
3151
3978
  state.start = next;
3979
+ state.open = void 0;
3152
3980
  state.command = relative ? "l" : "L";
3153
3981
  return index + arity;
3154
3982
  }
3155
- state.segments.push(straightOrCubic({ key, args, from: state.current, relative }, next));
3983
+ pushSegment(state, straightOrCubic({ key, args, from: state.current, relative }, next));
3156
3984
  state.current = next;
3157
3985
  return index + arity;
3158
3986
  }
@@ -3215,34 +4043,78 @@ function normaliseCount(segments, target) {
3215
4043
  function chordLength(segment) {
3216
4044
  return Math.hypot(segment.to.x - segment.from.x, segment.to.y - segment.from.y);
3217
4045
  }
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
- );
4046
+ function round(value) {
4047
+ return Math.round(value * 100) / 100;
4048
+ }
4049
+ function subpathsToPathData(subpaths) {
4050
+ const parts = [];
4051
+ for (const sub of subpaths) {
4052
+ const head = sub.segments[0];
4053
+ parts.push(`M${round(head.from.x)},${round(head.from.y)}`);
4054
+ for (const s of sub.segments) {
4055
+ parts.push(
4056
+ `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)}`
4057
+ );
4058
+ }
4059
+ if (sub.closed) parts.push("Z");
3226
4060
  }
3227
4061
  return parts.join(" ");
3228
4062
  }
3229
- function round(value) {
3230
- return Math.round(value * 100) / 100;
4063
+ function centroidOf(subpath) {
4064
+ let x = 0;
4065
+ let y = 0;
4066
+ for (const s of subpath.segments) {
4067
+ x += s.from.x;
4068
+ y += s.from.y;
4069
+ }
4070
+ return { x: x / subpath.segments.length, y: y / subpath.segments.length };
4071
+ }
4072
+ function collapsedLike(partner) {
4073
+ const at = centroidOf(partner);
4074
+ return {
4075
+ segments: partner.segments.map(() => ({
4076
+ from: { ...at },
4077
+ c1: { ...at },
4078
+ c2: { ...at },
4079
+ to: { ...at }
4080
+ })),
4081
+ closed: partner.closed
4082
+ };
4083
+ }
4084
+ function normaliseSubpaths(a, b) {
4085
+ const count = Math.max(a.length, b.length);
4086
+ const from = [];
4087
+ const to = [];
4088
+ for (let i = 0; i < count; i++) {
4089
+ const left = a[i] ?? collapsedLike(b[i]);
4090
+ const right = b[i] ?? collapsedLike(a[i]);
4091
+ const target = Math.max(left.segments.length, right.segments.length);
4092
+ const closed = left.closed && right.closed;
4093
+ from.push({ segments: normaliseCount(left.segments, target), closed });
4094
+ to.push({ segments: normaliseCount(right.segments, target), closed });
4095
+ }
4096
+ return { from, to };
3231
4097
  }
3232
4098
  function createMorph(fromPath, toPath) {
3233
4099
  const a = parsePath(fromPath);
3234
4100
  const b = parsePath(toPath);
3235
4101
  if (a.reason) return { reason: `start path: ${a.reason}` };
3236
4102
  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);
4103
+ const { from, to } = normaliseSubpaths(a.subpaths, b.subpaths);
4104
+ const count = from.reduce((total, sub) => total + sub.segments.length, 0);
3240
4105
  return {
3241
4106
  morph: {
3242
4107
  segmentCount: count,
3243
4108
  at(t) {
3244
4109
  const clamped = Math.min(1, Math.max(0, t));
3245
- return toPathData(from.map((segment, i) => lerpCubic(segment, to[i], clamped)));
4110
+ return subpathsToPathData(
4111
+ from.map((sub, i) => ({
4112
+ segments: sub.segments.map(
4113
+ (segment, j) => lerpCubic(segment, to[i].segments[j], clamped)
4114
+ ),
4115
+ closed: sub.closed
4116
+ }))
4117
+ );
3246
4118
  }
3247
4119
  }
3248
4120
  };
@@ -3299,6 +4171,35 @@ function prepareMorph(el, params, ctx) {
3299
4171
  path.setAttribute("d", startPath);
3300
4172
  };
3301
4173
  }
4174
+ var PATH_DRAW_PRIMITIVE = cssPrimitive("path-draw", [CHANNEL.stroke], {
4175
+ parameters: {
4176
+ length: { type: "number", default: "100", cssProperty: "--kui-path-length", finite: true }
4177
+ }
4178
+ });
4179
+ var SHAPE_FILL_PRIMITIVE = cssPrimitive("shape-fill", [CHANNEL.clip]);
4180
+ var BAR_GROW_PRIMITIVE = cssPrimitive("bar-grow", [CHANNEL.scale], {
4181
+ parameters: { from: { type: "number", default: "0", cssProperty: "--kui-bar-from" } }
4182
+ });
4183
+ var LOGO_BUILD_PRIMITIVE = cssPrimitive("logo-assemble", [
4184
+ CHANNEL.opacity,
4185
+ CHANNEL.scale,
4186
+ CHANNEL.rotate
4187
+ ]);
4188
+ var ICON_TOGGLE_PRIMITIVE = {
4189
+ id: "icon-toggle",
4190
+ renderer: "javascript",
4191
+ channels: [CHANNEL.translate, CHANNEL.rotate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.clip],
4192
+ parameters: {
4193
+ duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
4194
+ ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
4195
+ },
4196
+ supportedTimelines: ["time"],
4197
+ supportedActivations: ["load"],
4198
+ defaultActivation: "load",
4199
+ perfClass: "compositor",
4200
+ reducedMotion: "disable",
4201
+ prepare: () => inertInstance()
4202
+ };
3302
4203
  var SVG_PRIMITIVES = [
3303
4204
  {
3304
4205
  id: "path-morph",
@@ -3318,19 +4219,77 @@ var SVG_PRIMITIVES = [
3318
4219
  perfClass: "paint",
3319
4220
  reducedMotion: "disable",
3320
4221
  prepare: deferPrepare(prepareMorph)
3321
- }
4222
+ },
4223
+ PATH_DRAW_PRIMITIVE,
4224
+ SHAPE_FILL_PRIMITIVE,
4225
+ BAR_GROW_PRIMITIVE,
4226
+ LOGO_BUILD_PRIMITIVE,
4227
+ ICON_TOGGLE_PRIMITIVE
3322
4228
  ];
3323
4229
  var SVG_PRESETS = [
3324
4230
  { name: "icon-morph", primitive: "path-morph" },
3325
- { name: "blob-morph", primitive: "path-morph", params: { duration: "800ms" } }
4231
+ { name: "blob-morph", primitive: "path-morph", params: { duration: "800ms" } },
4232
+ // Stroke draws. One keyframe block each rather than one shared block, matching the
4233
+ // progress-ring/gauge-sweep/donut-sweep/sparkline-draw group in numbers.ts: identical bodies
4234
+ // today, but each name is free to diverge and a consumer can restyle one without the others.
4235
+ { name: "draw-stroke", primitive: "path-draw", keyframes: "kui-draw-stroke", params: { duration: "800ms", ease: "ease-in-out" } },
4236
+ { name: "draw-signature", primitive: "path-draw", keyframes: "kui-draw-signature", params: { duration: "1600ms", ease: "ease-in-out" } },
4237
+ { name: "draw-underline", primitive: "path-draw", keyframes: "kui-draw-underline", params: { duration: "420ms" } },
4238
+ { name: "checkmark-draw", primitive: "path-draw", keyframes: "kui-checkmark-draw", params: { duration: "320ms" } },
4239
+ { name: "cross-draw", primitive: "path-draw", keyframes: "kui-cross-draw", params: { duration: "260ms" } },
4240
+ { name: "chart-line-draw", primitive: "path-draw", keyframes: "kui-chart-line-draw", params: { duration: "1200ms", ease: "ease-in-out" } },
4241
+ { name: "gradient-stroke", primitive: "path-draw", keyframes: "kui-gradient-stroke", params: { duration: "2400ms", ease: "ease-in-out" } },
4242
+ // Fills.
4243
+ { name: "heart-fill", primitive: "shape-fill", keyframes: "kui-heart-fill", params: { duration: "420ms" } },
4244
+ { name: "bookmark-fill", primitive: "shape-fill", keyframes: "kui-bookmark-fill", params: { duration: "360ms" } },
4245
+ { name: "chart-area-fill", primitive: "shape-fill", keyframes: "kui-chart-area-fill", params: { duration: "900ms" } },
4246
+ // `cloak: true`: `kui-chart-bar-grow`'s `from { scale: 1 0 }` (svg.css) is a zero-height box
4247
+ // while paused, not just an invisible one — so it occupies no space in layout for the whole
4248
+ // wait. Same defect `fold-panel` has, and the same fix shape: see svg.css's
4249
+ // `[data-kui-fx~='chart-bar-grow'][data-kui-state='ready']` rule.
4250
+ {
4251
+ name: "chart-bar-grow",
4252
+ primitive: "bar-grow",
4253
+ keyframes: "kui-chart-bar-grow",
4254
+ params: { duration: "700ms", ease: "back-out" },
4255
+ cloak: true
4256
+ },
4257
+ { name: "logo-build", primitive: "logo-assemble", keyframes: "kui-logo-build", params: { duration: "520ms", ease: "back-out" } },
4258
+ // Icon toggles — no `keyframes`, because their motion is a CSS transition in svg.css keyed off
4259
+ // aria state, not a compiled animation. Same shape as forms.ts's native-state presets.
4260
+ { name: "hamburger-to-x", primitive: "icon-toggle" },
4261
+ { name: "play-to-pause", primitive: "icon-toggle" },
4262
+ { name: "plus-to-minus", primitive: "icon-toggle" }
3326
4263
  ];
3327
4264
  function registerSvg(registry) {
3328
4265
  return registry.registerPrimitives(SVG_PRIMITIVES).registerPresets(SVG_PRESETS);
3329
4266
  }
3330
4267
 
3331
4268
  // src/effects/three-d/index.ts
4269
+ var CARD_TOGGLE_PRIMITIVE = {
4270
+ id: "card-toggle",
4271
+ renderer: "javascript",
4272
+ channels: [CHANNEL.rotate],
4273
+ parameters: {
4274
+ duration: { type: "time", default: "700ms", cssProperty: "--kui-duration" },
4275
+ ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
4276
+ perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" }
4277
+ },
4278
+ supportedTimelines: ["time"],
4279
+ supportedActivations: ["load"],
4280
+ defaultActivation: "load",
4281
+ perfClass: "compositor",
4282
+ reducedMotion: "disable",
4283
+ prepare: () => inertInstance()
4284
+ };
3332
4285
  var THREE_D_PRIMITIVES = [
3333
- cssPrimitive("flip-face", [CHANNEL.rotate], {
4286
+ // `skew`, not `rotate`: the keyframes in three-d.css write `transform: perspective(...)
4287
+ // rotateX/Y(...)`, not the individual `rotate:` property — `perspective` only creates depth for
4288
+ // an element's *children*, so giving one of these effects its own depth means reaching for the
4289
+ // `perspective()` transform *function*, which only exists inside the `transform` shorthand.
4290
+ // `CHANNEL.skew` is this catalog's name for "claims the whole `transform` shorthand"; see the
4291
+ // comment on it in `core/types.ts`.
4292
+ cssPrimitive("flip-face", [CHANNEL.skew], {
3334
4293
  parameters: {
3335
4294
  angle: { type: "angle", default: "180deg", cssProperty: "--kui-from-angle" },
3336
4295
  perspective: { type: "length", default: "1200px", cssProperty: "--kui-perspective" }
@@ -3340,12 +4299,19 @@ var THREE_D_PRIMITIVES = [
3340
4299
  parameters: { distance: { type: "length", default: "40px", cssProperty: "--kui-distance" } }
3341
4300
  }),
3342
4301
  cssPrimitive("wipe", [CHANNEL.clip]),
3343
- cssPrimitive("bar", [CHANNEL.scale])
4302
+ // `from` gives `loading-bar` a real knob on its start scale — it had none before, unlike
4303
+ // `flip-face`'s `angle:` two rows up. `--kui-bar-from` is also what the fold-panel-style
4304
+ // `[data-kui-fx~='loading-bar'][data-kui-state='ready']` gate neutralizes in three-d.css; see
4305
+ // that rule's comment for the on:enter fix this parameter doubles as.
4306
+ cssPrimitive("bar", [CHANNEL.scale], {
4307
+ parameters: { from: { type: "number", default: "0", cssProperty: "--kui-bar-from" } }
4308
+ }),
4309
+ CARD_TOGGLE_PRIMITIVE
3344
4310
  ];
3345
4311
  var THREE_D_PRESETS = [
3346
4312
  // --- 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" },
4313
+ { name: "card-flip-y", primitive: "flip-face", keyframes: "kui-card-flip-y", cloak: true },
4314
+ { name: "card-flip-x", primitive: "flip-face", keyframes: "kui-card-flip-x", cloak: true },
3349
4315
  { name: "cube-rotate", primitive: "flip-face", keyframes: "kui-cube-rotate", params: { angle: "90deg" } },
3350
4316
  {
3351
4317
  name: "book-page-turn",
@@ -3353,17 +4319,32 @@ var THREE_D_PRESETS = [
3353
4319
  keyframes: "kui-book-page-turn",
3354
4320
  params: { angle: "-160deg", duration: "900ms" }
3355
4321
  },
4322
+ // `cloak: true`, unlike its `flip-face` siblings above: those are `to`-only keyframes, so their
4323
+ // paused/waiting box is the ordinary, untransformed rest state. `fold-panel` is `from`-only —
4324
+ // its `rotateX(-90deg)` (three-d.css) *is* the paused box, edge-on and zero-height, so it holds
4325
+ // no space in layout for the whole wait; see the
4326
+ // `[data-kui-fx~='fold-panel'][data-kui-state='ready']` rule in three-d.css for the other half
4327
+ // of that fix. `cloak` only ever hid the pre-JS flash, not this, but adding it here keeps the
4328
+ // pre-JS and post-JS "ready" appearances the same (invisible) instead of trading one flash for
4329
+ // the other.
3356
4330
  {
3357
4331
  name: "fold-panel",
3358
4332
  primitive: "flip-face",
3359
4333
  keyframes: "kui-fold-panel",
3360
- params: { angle: "-90deg" }
4334
+ params: { angle: "-90deg" },
4335
+ cloak: true
3361
4336
  },
3362
4337
  // --- page transitions ---
3363
4338
  { name: "page-fade", primitive: "page-reveal", keyframes: "kui-page-fade" },
3364
4339
  { name: "page-slide", primitive: "page-reveal", keyframes: "kui-page-slide" },
3365
4340
  { name: "curtain-wipe", primitive: "wipe", keyframes: "kui-curtain-wipe", params: { duration: "800ms" } },
3366
- { name: "loading-bar", primitive: "bar", keyframes: "kui-loading-bar" }
4341
+ // `cloak: true` for the same reason as `fold-panel`: `kui-loading-bar`'s `from { scale: 0 1 }`
4342
+ // (three-d.css) is a zero-width box, not just an invisible one — see that file's
4343
+ // `[data-kui-fx~='loading-bar'][data-kui-state='ready']` rule.
4344
+ { name: "loading-bar", primitive: "bar", keyframes: "kui-loading-bar", cloak: true },
4345
+ // No `keyframes`: its motion is a CSS transition in three-d.css keyed off the control's
4346
+ // aria-pressed, not a compiled animation. Same shape as the icon toggles in svg.ts.
4347
+ { name: "flip-card", primitive: "card-toggle" }
3367
4348
  ];
3368
4349
  function registerThreeD(registry) {
3369
4350
  return registry.registerPrimitives(THREE_D_PRIMITIVES).registerPresets(THREE_D_PRESETS);