kuinetic 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,9 +15,13 @@ import {
15
15
  } from "./chunk-JT4PZL3A.mjs";
16
16
 
17
17
  // src/effects/shared.ts
18
+ var TRIGGER_DELAY_PARAM = {
19
+ delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" }
20
+ };
21
+ var TIMELINE_AGNOSTIC = ["time", "view", "scroll", "pin"];
18
22
  var COMMON = {
19
23
  duration: { type: "time", default: "600ms", cssProperty: "--kui-duration" },
20
- delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" },
24
+ ...TRIGGER_DELAY_PARAM,
21
25
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
22
26
  stagger: { type: "time", default: "0ms", cssProperty: "--kui-stagger" }
23
27
  };
@@ -45,7 +49,14 @@ function cssPrimitive(id, channels, options = {}) {
45
49
  // src/effects/catalog/ambient.ts
46
50
  var drift = {
47
51
  duration: { type: "time", default: "10s", cssProperty: "--kui-duration" },
48
- ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" }
52
+ ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
53
+ from: { type: "color", default: "", cssProperty: "--kui-ambient-c1" },
54
+ to: { type: "color", default: "", cssProperty: "--kui-ambient-c2" }
55
+ };
56
+ var tint = {
57
+ duration: { type: "time", default: "10s", cssProperty: "--kui-duration" },
58
+ ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
59
+ from: { type: "color", default: "", cssProperty: "--kui-ambient-c1" }
49
60
  };
50
61
  var float = {
51
62
  duration: { type: "time", default: "4s", cssProperty: "--kui-duration" },
@@ -71,6 +82,34 @@ var AMBIENT_PRIMITIVES = [
71
82
  reducedMotion: "disable",
72
83
  perfClass: "continuous"
73
84
  }),
85
+ /*
86
+ * `gradient-rotate-border` and `gradient-border` are not background fills, and a second
87
+ * primitive is how this catalog says so — the same reason `ambient-tint` exists beside
88
+ * `ambient-gradient` above. Channels are per-primitive, so a different channel set means a
89
+ * different primitive; folding these into `ambient-gradient` would make `gradient-mesh` and
90
+ * `aurora` claim a mask and a box they never touch, and `aurora, pin` would start reporting a
91
+ * conflict that isn't there.
92
+ *
93
+ * What the ring rules actually write (`ambient.css`) beyond the gradient: `mask` +
94
+ * `mask-composite`, which subtract the element's own content box to leave a ring — the same
95
+ * physical property `media-mask` claims under the `'mask'` channel — and `position: relative`
96
+ * plus a `padding` that *is* the ring's thickness, which is a claim on the host's box in the
97
+ * sense `pin` and `background-media` already use `'layout'` for. Declared only as
98
+ * `background`, a `gradient-border, pin-section` pair composed silently while both decided the
99
+ * host's `position`, and `gradient-border, mask-reveal` while both wrote `mask`.
100
+ */
101
+ cssPrimitive("ambient-gradient-ring", [CHANNEL.background, "mask", "layout"], {
102
+ parameters: drift,
103
+ defaultActivation: "load",
104
+ reducedMotion: "disable",
105
+ perfClass: "continuous"
106
+ }),
107
+ cssPrimitive("ambient-tint", [CHANNEL.background], {
108
+ parameters: tint,
109
+ defaultActivation: "load",
110
+ reducedMotion: "disable",
111
+ perfClass: "continuous"
112
+ }),
74
113
  cssPrimitive("ambient-float", [CHANNEL.translate], {
75
114
  parameters: float,
76
115
  defaultActivation: "load",
@@ -95,49 +134,58 @@ var AMBIENT_PRESETS = [
95
134
  { name: "aurora", primitive: "ambient-gradient", keyframes: "kui-aurora" },
96
135
  {
97
136
  name: "gradient-rotate-border",
98
- primitive: "ambient-gradient",
137
+ primitive: "ambient-gradient-ring",
99
138
  keyframes: "kui-gradient-rotate-border",
100
139
  params: { duration: "6s", ease: "linear" }
101
140
  },
141
+ {
142
+ // Not `gradient`: this rule masks its own content box away (`mask-composite: exclude`) to leave
143
+ // a ring, so putting the name on real content deletes the content. `-border` says that out loud,
144
+ // matching `gradient-rotate-border` and `beam-border`.
145
+ name: "gradient-border",
146
+ primitive: "ambient-gradient-ring",
147
+ keyframes: "kui-gradient-border",
148
+ params: { duration: "6s", ease: "linear" }
149
+ },
102
150
  {
103
151
  name: "noise-overlay",
104
- primitive: "ambient-gradient",
152
+ primitive: "ambient-tint",
105
153
  keyframes: "kui-noise-overlay",
106
154
  params: { duration: "650ms", ease: "steps(6)" }
107
155
  },
108
156
  {
109
157
  name: "scanline",
110
- primitive: "ambient-gradient",
158
+ primitive: "ambient-tint",
111
159
  keyframes: "kui-scanline",
112
160
  params: { duration: "3.5s", ease: "linear" }
113
161
  },
114
162
  {
115
163
  name: "dot-grid-drift",
116
- primitive: "ambient-gradient",
164
+ primitive: "ambient-tint",
117
165
  keyframes: "kui-dot-grid-drift",
118
166
  params: { duration: "16s", ease: "linear" }
119
167
  },
120
168
  {
121
169
  name: "line-grid-drift",
122
- primitive: "ambient-gradient",
170
+ primitive: "ambient-tint",
123
171
  keyframes: "kui-line-grid-drift",
124
172
  params: { duration: "16s", ease: "linear" }
125
173
  },
126
174
  {
127
175
  name: "starfield",
128
- primitive: "ambient-gradient",
176
+ primitive: "ambient-tint",
129
177
  keyframes: "kui-starfield",
130
178
  params: { duration: "40s", ease: "linear" }
131
179
  },
132
180
  {
133
181
  name: "spotlight-follow",
134
- primitive: "ambient-gradient",
182
+ primitive: "ambient-tint",
135
183
  keyframes: "kui-spotlight-follow",
136
184
  params: { duration: "9s" }
137
185
  },
138
186
  {
139
187
  name: "wave-blob",
140
- primitive: "ambient-gradient",
188
+ primitive: "ambient-tint",
141
189
  keyframes: "kui-wave-blob",
142
190
  params: { duration: "12s" }
143
191
  },
@@ -474,6 +522,10 @@ var liftParams = {
474
522
  var popParams = {
475
523
  scale: { type: "number", default: "1.06", cssProperty: "--kui-pop-scale", finite: true, minimum: 0 }
476
524
  };
525
+ var beamParams = {
526
+ color: { type: "color", default: "", cssProperty: "--kui-beam-border-c1" },
527
+ outset: { type: "length", default: "", cssProperty: "--kui-beam-border-outset" }
528
+ };
477
529
  function hoverPrimitive(id, channels, extraParams = {}) {
478
530
  return {
479
531
  id,
@@ -501,7 +553,7 @@ var HOVER_PRIMITIVES = [
501
553
  hoverPrimitive("split-flap", ["rotate"]),
502
554
  hoverPrimitive("border-draw", ["border"]),
503
555
  hoverPrimitive("border-glow", ["shadow"]),
504
- hoverPrimitive("beam-border", ["border"]),
556
+ hoverPrimitive("beam-border", ["border"], beamParams),
505
557
  hoverPrimitive("underline-slide", ["scale"]),
506
558
  hoverPrimitive("underline-center", ["scale"]),
507
559
  hoverPrimitive("icon-wiggle", ["rotate"]),
@@ -517,7 +569,7 @@ var CONTINUOUS_BORDER_PRIMITIVES = [
517
569
  id: "beam-border-auto",
518
570
  renderer: "javascript",
519
571
  channels: ["border"],
520
- parameters: hoverTiming,
572
+ parameters: { ...hoverTiming, ...beamParams },
521
573
  supportedTimelines: ["time"],
522
574
  supportedActivations: ["load"],
523
575
  defaultActivation: "load",
@@ -806,6 +858,177 @@ function registerInteraction(registry) {
806
858
  return registry.registerPrimitives(INTERACTION_PRIMITIVES).registerPresets(INTERACTION_PRESETS);
807
859
  }
808
860
 
861
+ // src/effects/catalog/background-media.ts
862
+ var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
863
+ var VIDEO_PAGE_HOSTS = /* @__PURE__ */ new Set([
864
+ "youtube.com",
865
+ "www.youtube.com",
866
+ "m.youtube.com",
867
+ "youtube-nocookie.com",
868
+ "www.youtube-nocookie.com",
869
+ "youtu.be",
870
+ "www.youtu.be"
871
+ ]);
872
+ function normalizeUrl(value) {
873
+ const stripped = value.replace(/[\t\n\r]/g, "");
874
+ let start = 0;
875
+ while (start < stripped.length && stripped.charCodeAt(start) <= 32) start += 1;
876
+ return stripped.slice(start);
877
+ }
878
+ function hostOf(value) {
879
+ const withoutScheme = normalizeUrl(value).replace(/^[a-z][a-z0-9+.-]*:/i, "").replace(/^\/\//, "");
880
+ const end = withoutScheme.search(/[/?#]/);
881
+ const authority = end === -1 ? withoutScheme : withoutScheme.slice(0, end);
882
+ return authority.slice(authority.lastIndexOf("@") + 1).toLowerCase();
883
+ }
884
+ function isVideoPageUrl(value) {
885
+ return VIDEO_PAGE_HOSTS.has(hostOf(value));
886
+ }
887
+ var FOCAL_POINTS = {
888
+ center: "50% 50%",
889
+ top: "50% 0%",
890
+ bottom: "50% 100%",
891
+ left: "0% 50%",
892
+ right: "100% 50%",
893
+ "top-left": "0% 0%",
894
+ "top-right": "100% 0%",
895
+ "bottom-left": "0% 100%",
896
+ "bottom-right": "100% 100%"
897
+ };
898
+ var FOCAL_POINT_NAMES = Object.keys(FOCAL_POINTS);
899
+ function focalPosition(focus) {
900
+ return FOCAL_POINTS[focus] ?? FOCAL_POINTS.center;
901
+ }
902
+ function paintsOverlay(options) {
903
+ return options.overlay !== "transparent" && options.overlayOpacity > 0;
904
+ }
905
+ function isVideoSource(src) {
906
+ const path = src.split("?")[0].split("#")[0];
907
+ const dot = path.lastIndexOf(".");
908
+ if (dot <= path.lastIndexOf("/")) return false;
909
+ return VIDEO_EXTENSIONS.has(path.slice(dot + 1).toLowerCase());
910
+ }
911
+ var ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["http", "https"]);
912
+ function schemeOf(value) {
913
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec(normalizeUrl(value));
914
+ return match ? match[1].toLowerCase() : "";
915
+ }
916
+ function mediaSource(authored, name, ctx) {
917
+ if (!authored) return authored;
918
+ if (isVideoPageUrl(authored)) {
919
+ ctx.warn(
920
+ `background-media "${name}": "${authored}" is a video *page*, not a media file \u2014 a YouTube URL cannot be played by <video> and needs an iframe embed instead. Point "${name}" at an .mp4/.webm file, or use a YouTube facade alongside this effect.`
921
+ );
922
+ return "";
923
+ }
924
+ const scheme = schemeOf(authored);
925
+ if (!scheme || ALLOWED_SCHEMES.has(scheme)) return authored;
926
+ ctx.warn(
927
+ `background-media "${name}": "${scheme}:" URLs are not allowed \u2014 use https:, http:, or a path such as "/media/hero.mp4".`
928
+ );
929
+ return "";
930
+ }
931
+ function styleLayer(node) {
932
+ const { style } = node;
933
+ style.setProperty("position", "absolute");
934
+ style.setProperty("top", "0");
935
+ style.setProperty("left", "0");
936
+ style.setProperty("width", "100%");
937
+ style.setProperty("height", "100%");
938
+ style.setProperty("border-radius", "inherit");
939
+ style.setProperty("pointer-events", "none");
940
+ style.setProperty("z-index", "-1");
941
+ }
942
+ function createOverlay(doc, options) {
943
+ const scrim = doc.createElement("div");
944
+ styleLayer(scrim);
945
+ scrim.style.setProperty("background", options.overlay);
946
+ scrim.style.setProperty("opacity", String(options.overlayOpacity));
947
+ scrim.setAttribute("aria-hidden", "true");
948
+ return scrim;
949
+ }
950
+ function createVideo(doc, options) {
951
+ const video = doc.createElement("video");
952
+ video.muted = true;
953
+ video.setAttribute("muted", "");
954
+ video.playsInline = true;
955
+ video.setAttribute("playsinline", "");
956
+ video.loop = options.loop;
957
+ video.defaultPlaybackRate = options.rate;
958
+ video.playbackRate = options.rate;
959
+ video.preload = "metadata";
960
+ if (options.poster) video.poster = options.poster;
961
+ video.src = options.src;
962
+ return video;
963
+ }
964
+ function play(video) {
965
+ const started = video.play();
966
+ if (started) void started.catch(() => {
967
+ });
968
+ }
969
+ function startPlayback(video, win, options) {
970
+ if (options.reducedMotion || options.autoplay === "never") return () => {
971
+ };
972
+ if (options.autoplay === "always") {
973
+ play(video);
974
+ return () => {
975
+ if (!video.paused) video.pause();
976
+ };
977
+ }
978
+ return autoplayInView(video, win);
979
+ }
980
+ function createImage(doc, options) {
981
+ const image = doc.createElement("img");
982
+ image.alt = "";
983
+ image.decoding = "async";
984
+ image.src = options.src;
985
+ return image;
986
+ }
987
+ function autoplayInView(video, win) {
988
+ const Observer = win.IntersectionObserver;
989
+ if (!Observer) return () => {
990
+ };
991
+ const observer = new Observer(
992
+ (entries) => {
993
+ for (const entry of entries) {
994
+ if (entry.isIntersecting) play(video);
995
+ else if (!video.paused) video.pause();
996
+ }
997
+ },
998
+ { threshold: 0 }
999
+ );
1000
+ observer.observe(video);
1001
+ return () => {
1002
+ observer.disconnect();
1003
+ if (!video.paused) video.pause();
1004
+ };
1005
+ }
1006
+ function installBackgroundMedia(el, ctx, options) {
1007
+ const doc = el.ownerDocument;
1008
+ const video = isVideoSource(options.src) ? createVideo(doc, options) : null;
1009
+ const node = video ?? createImage(doc, options);
1010
+ styleLayer(node);
1011
+ node.style.setProperty("object-fit", options.fit);
1012
+ node.style.setProperty("object-position", options.position);
1013
+ node.setAttribute("aria-hidden", "true");
1014
+ node.setAttribute("data-kui-background", "");
1015
+ el.append(node);
1016
+ const overlay = paintsOverlay(options) ? createOverlay(doc, options) : null;
1017
+ if (overlay) {
1018
+ overlay.setAttribute("data-kui-background-overlay", "");
1019
+ el.append(overlay);
1020
+ }
1021
+ const stopPlayback = video ? startPlayback(video, ctx.win, options) : () => {
1022
+ };
1023
+ return {
1024
+ remove: () => {
1025
+ stopPlayback();
1026
+ node.remove();
1027
+ overlay?.remove();
1028
+ }
1029
+ };
1030
+ }
1031
+
809
1032
  // src/effects/catalog/media-shared.ts
810
1033
  var AXIS_DEGREES = { vertical: 0, horizontal: 90 };
811
1034
  var UNIT_DEGREES = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 };
@@ -1099,7 +1322,7 @@ var slatParams = {
1099
1322
  },
1100
1323
  fold: { type: "keyword", default: "false", cssProperty: "--kui-fold", values: ["true", "false"] },
1101
1324
  duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" },
1102
- delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" },
1325
+ ...TRIGGER_DELAY_PARAM,
1103
1326
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
1104
1327
  stagger: { type: "time", default: "60ms", cssProperty: "--kui-stagger" }
1105
1328
  };
@@ -1145,6 +1368,133 @@ function prepareSlatAssemble(el, params, ctx) {
1145
1368
  }
1146
1369
  };
1147
1370
  }
1371
+ var backgroundMediaParams = {
1372
+ /*
1373
+ * `text`, and same-origin-checked at the point of use rather than by the type — identical
1374
+ * shape and identical reasoning to `media-scrub`'s own `src` (`scroll-mechanics/primitives.ts`).
1375
+ * A URL has no lexical shape to validate against, and `type: 'text'` is the one type that never
1376
+ * reaches a stylesheet, which is what makes accepting arbitrary path characters safe.
1377
+ */
1378
+ src: { type: "text", default: "", cssProperty: "--kui-src" },
1379
+ /*
1380
+ * The still a `<video>` shows before its first frame decodes. Not optional polish: without it a
1381
+ * background clip paints as an empty box for as long as the network takes, and that box is the
1382
+ * backdrop to the author's text — the one place on the page where a flash of nothing is most
1383
+ * visible. Every background video in this repo's own demo pages is authored with one.
1384
+ */
1385
+ poster: { type: "text", default: "", cssProperty: "--kui-poster" },
1386
+ /*
1387
+ * `fill`, `none` and `scale-down` are deliberately absent. `fill` is the only `object-fit` value
1388
+ * that distorts — it stretches the picture to the box rather than cropping it — and the standing
1389
+ * rule for imagery in this project is to crop, never stretch. The other two leave the media at
1390
+ * its intrinsic size inside a box sized to something else, which for a *backdrop* is a gap, not
1391
+ * a layout. Adding them would be offering three ways to get a broken background.
1392
+ */
1393
+ fit: {
1394
+ type: "keyword",
1395
+ default: "cover",
1396
+ cssProperty: "--kui-fit",
1397
+ values: ["cover", "contain"]
1398
+ },
1399
+ /*
1400
+ * Which part of the picture a `cover` crop keeps. Nine named points rather than a free
1401
+ * `object-position` string, because a free string would have to be `type: 'text'` — the one type
1402
+ * that is explicitly never written to a stylesheet (see `core/params.ts`) — and this value is
1403
+ * written to one. A keyword list is validated against its own `values`, so the author gets real
1404
+ * focal control and the CSS surface stays closed.
1405
+ */
1406
+ focus: {
1407
+ type: "keyword",
1408
+ default: "center",
1409
+ cssProperty: "--kui-focus",
1410
+ values: FOCAL_POINT_NAMES
1411
+ },
1412
+ /*
1413
+ * The scrim. This is the parameter that makes the whole effect usable, because the point of a
1414
+ * backdrop here is animated text on top of it, and text over unmodified footage is illegible
1415
+ * about half the time — a light frame arrives and the headline vanishes for those seconds.
1416
+ *
1417
+ * `type: 'color'` so it goes through the same validator every other colour does. `transparent`
1418
+ * as the default rather than an empty string for the same reason: `''` is not a colour, and a
1419
+ * default that its own type would reject is a lie the schema cannot catch. It is also the honest
1420
+ * spelling of "no scrim", and no scrim node is created for it.
1421
+ */
1422
+ overlay: { type: "color", default: "transparent", cssProperty: "--kui-overlay" },
1423
+ /*
1424
+ * Separate from the colour rather than folded into it. `overlay:rgb(0 0 0 / 45%)` does parse —
1425
+ * the tokenizer is paren-aware — but `overlay:black overlay-opacity:45%` is the spelling someone
1426
+ * reaches for while tuning legibility, and tuning is exactly what this value is for.
1427
+ */
1428
+ "overlay-opacity": { type: "percentage", default: "100%", cssProperty: "--kui-overlay-opacity" },
1429
+ /*
1430
+ * The opt-out for the play-while-visible behaviour. `in-view` pairs the clip with the viewport
1431
+ * and is right for a long section. `always` is for a short hero clip that must never be caught
1432
+ * mid-stall by a visibility heuristic. `never` installs the clip and leaves it on its poster,
1433
+ * which is also where any mode lands under a reduced-motion preference.
1434
+ */
1435
+ autoplay: {
1436
+ type: "keyword",
1437
+ default: "in-view",
1438
+ cssProperty: "--kui-autoplay",
1439
+ values: ["in-view", "always", "never"]
1440
+ },
1441
+ /*
1442
+ * Bounded at both ends: `0` is a clip that is loaded, decoding, and permanently frozen — worse
1443
+ * than `autoplay:never`, which at least says so — and browsers stop honouring rates past roughly
1444
+ * 4 anyway, so a larger number is a silent no-op rather than a faster clip.
1445
+ */
1446
+ rate: {
1447
+ type: "number",
1448
+ default: "1",
1449
+ cssProperty: "--kui-rate",
1450
+ finite: true,
1451
+ minimum: 0.25,
1452
+ maximum: 4
1453
+ },
1454
+ loop: { type: "keyword", default: "true", cssProperty: "--kui-loop", values: ["true", "false"] }
1455
+ /*
1456
+ * There is deliberately no `controls:`. The layer this primitive builds paints at `z-index: -1`
1457
+ * behind the author's own children, so a native control bar there is focusable by keyboard and
1458
+ * occluded by whatever the page happens to put over it — a player you can tab into and cannot
1459
+ * see. A clip meant to be controlled is a content `<video controls>` the author writes, not a
1460
+ * background one.
1461
+ */
1462
+ };
1463
+ function prepareBackgroundMedia(el, params, ctx) {
1464
+ const authored = params.text("src");
1465
+ if (!authored) {
1466
+ ctx.warn('background-media needs a "src:" \u2014 nothing installed');
1467
+ return () => {
1468
+ };
1469
+ }
1470
+ const src = mediaSource(authored, "src", ctx);
1471
+ if (!src) return () => {
1472
+ };
1473
+ const node = el;
1474
+ if (ctx.win.getComputedStyle(node).position === "static") ctx.style.set("position", "relative");
1475
+ ctx.style.set("isolation", "isolate");
1476
+ const layer = installBackgroundMedia(el, ctx, {
1477
+ src,
1478
+ poster: mediaSource(params.text("poster"), "poster", ctx),
1479
+ fit: params.is("fit", "contain") ? "contain" : "cover",
1480
+ position: focalPosition(params.text("focus", "center")),
1481
+ overlay: params.text("overlay", "transparent"),
1482
+ // `num` returns a percentage as a 0–1 ratio, which is exactly what `opacity` takes.
1483
+ overlayOpacity: Math.min(1, Math.max(0, params.num("overlay-opacity", 1))),
1484
+ autoplay: params.text("autoplay", "in-view"),
1485
+ rate: params.num("rate", 1),
1486
+ // `!is('loop', 'false')`, not `is('loop')`. Every other read here names its own fallback, and
1487
+ // this one has to as well: `is()` takes no fallback argument, so a bare `is('loop')` is only
1488
+ // true when something already filled the schema default in. That holds on the animator's path
1489
+ // (`readEffectParams` pre-fills every declared parameter) and not on `createParams`, so the
1490
+ // positive spelling silently defaulted a true-by-default parameter to false for any caller
1491
+ // handing over raw values. Reading it as "loop unless explicitly told not to" states the
1492
+ // default at the point of use, where it cannot drift.
1493
+ loop: !params.is("loop", "false"),
1494
+ reducedMotion: ctx.reducedMotion
1495
+ });
1496
+ return continuousSetup(layer.remove);
1497
+ }
1148
1498
  var MEDIA_JS_PRIMITIVES = [
1149
1499
  {
1150
1500
  id: "slat-assemble",
@@ -1165,10 +1515,68 @@ var MEDIA_JS_PRIMITIVES = [
1165
1515
  // in `core/types.ts` for why the catalog's default is the opposite of this.
1166
1516
  restoresOnFinish: true,
1167
1517
  prepare: deferPrepare(prepareSlatAssemble)
1518
+ },
1519
+ {
1520
+ id: "background-media",
1521
+ /*
1522
+ * `media` is the same word `media-scrub` uses for "this effect owns what the element shows",
1523
+ * and it is what makes `background-media, video-scrub` on one element a reported conflict
1524
+ * rather than two effects silently fighting over the same picture.
1525
+ *
1526
+ * `layout` is claimed for the same reason `pin` claims it: preparation writes `position` and
1527
+ * `isolation` on the *host*, which is a stacking-context claim on someone else's element. Left
1528
+ * undeclared, `background-media, pin-section` composed silently while both decided what
1529
+ * `position` the host has — the conflict detector cannot report a claim it was never told about.
1530
+ */
1531
+ channels: ["media", "layout"],
1532
+ renderer: "javascript",
1533
+ parameters: backgroundMediaParams,
1534
+ // Not a claim to support four timelines — an abstention. A backdrop is not driven by progress
1535
+ // of any kind and this primitive never reads `Timeline`; the list exists only so that
1536
+ // `data-kui="background-media src:/hero.mp4, parallax"` plus a `timeline:view` survives
1537
+ // `compile.ts`'s `intersect`. See `TIMELINE_AGNOSTIC` (`effects/shared.ts`), shared with the
1538
+ // scroll-mechanics drivers, which abstain for the same reason.
1539
+ supportedTimelines: TIMELINE_AGNOSTIC,
1540
+ supportedActivations: ["load", "enter", "manual"],
1541
+ /*
1542
+ * `'load'`, not the catalog's usual `'enter'`, and this is the difference between working and
1543
+ * not. A backdrop is the element's appearance, so gating it on an IntersectionObserver means a
1544
+ * section that is already on screen at page load, one in a background tab (no IO callbacks
1545
+ * fire at all until the tab is foregrounded), or one whose own box is still zero-area waits an
1546
+ * unbounded time to have any background — and unlike a missed reveal, that is a visibly broken
1547
+ * page. An author who *wants* a heavy clip deferred can still write `on:enter`.
1548
+ */
1549
+ defaultActivation: "load",
1550
+ perfClass: "paint",
1551
+ /*
1552
+ * `'shorten'`, unlike every other JS-rendered primitive in this file and in `text.ts`, and
1553
+ * deliberately so. `'disable'` means the animator never calls `activate()` under a reduced
1554
+ * motion preference, which for an animation is exactly right and for this is not: it would
1555
+ * leave the element with no backdrop at all rather than a calmer one. There is no CSS duration
1556
+ * here for `'shorten'` to shorten, so the policy is inert and the effect installs normally;
1557
+ * `ctx.reducedMotion` is then read inside, where it suppresses the one genuinely motion-y part
1558
+ * — a clip's autoplay — and leaves the poster frame standing. See `autoplayInView`.
1559
+ */
1560
+ reducedMotion: "shorten",
1561
+ prepare: deferPrepare(prepareBackgroundMedia)
1168
1562
  }
1169
1563
  ];
1170
1564
  var MEDIA_JS_PRESETS = [
1171
- { name: "slat-assemble", primitive: "slat-assemble", cloak: true }
1565
+ { name: "slat-assemble", primitive: "slat-assemble", cloak: true },
1566
+ /*
1567
+ * Two names, one primitive — the alias shape the catalog already uses everywhere (`pin-until`,
1568
+ * `pin-spacer` and `stacking-cards` are three names over the one `pin` primitive; six `wipe-*`
1569
+ * names share `media-wipe`). A preset row is the alias mechanism, so a second spelling costs a
1570
+ * table entry and nothing else: no duplicated implementation to keep in sync, and both names
1571
+ * resolve to the same `prepare`.
1572
+ *
1573
+ * No `cloak` on either: the pre-JS cloak rule hides an element until the runtime installs the
1574
+ * effect's from-state, and this element is the author's own content. Cloaking it would blank
1575
+ * their text for as long as the bundle takes to arrive, to hide a backdrop that has no
1576
+ * from-state at all.
1577
+ */
1578
+ { name: "bg", primitive: "background-media" },
1579
+ { name: "background", primitive: "background-media" }
1172
1580
  ];
1173
1581
  var MEDIA_PRIMITIVES = [...MEDIA_CSS_PRIMITIVES, ...MEDIA_JS_PRIMITIVES];
1174
1582
  var MEDIA_PRESETS = [...MEDIA_CSS_PRESETS, ...MEDIA_JS_PRESETS];
@@ -1311,6 +1719,7 @@ var COUNT_STEP_MS = 16;
1311
1719
  var REDUCED_MOTION_DURATION_MS = 1;
1312
1720
  var countParams = {
1313
1721
  duration: { type: "time", default: "1600ms", cssProperty: "--kui-duration" },
1722
+ ...TRIGGER_DELAY_PARAM,
1314
1723
  from: { type: "number", default: "0", cssProperty: "--kui-from" },
1315
1724
  to: { type: "number", default: "100", cssProperty: "--kui-to" },
1316
1725
  decimals: {
@@ -1331,6 +1740,7 @@ var countParams = {
1331
1740
  };
1332
1741
  var odometerParams = {
1333
1742
  duration: { type: "time", default: "1600ms", cssProperty: "--kui-duration" },
1743
+ ...TRIGGER_DELAY_PARAM,
1334
1744
  from: { type: "number", default: "0", cssProperty: "--kui-from" },
1335
1745
  to: { type: "number", default: "100", cssProperty: "--kui-to" }
1336
1746
  };
@@ -1357,7 +1767,9 @@ function tweenDurationMs(params, ctx) {
1357
1767
  function tweenTimingFor(params, ctx) {
1358
1768
  return {
1359
1769
  durationMs: tweenDurationMs(params, ctx),
1360
- delayMs: Math.max(0, params.timing.delayMs ?? 0),
1770
+ // Positional first, then the same-named parameter — the two-spellings rule `effectDurationMs`
1771
+ // applies to `duration` just above, now applied to `delay` as well.
1772
+ delayMs: Math.max(0, params.timing.delayMs ?? params.ms("delay", 0)),
1361
1773
  easing: resolveEasing(params.timing.easing, ctx.warn)
1362
1774
  };
1363
1775
  }
@@ -1617,15 +2029,22 @@ function createStepRunner(win, options) {
1617
2029
  function appendCharSpans(container, doc, text) {
1618
2030
  const spans = [];
1619
2031
  let index = 0;
2032
+ let wordWrapper = null;
1620
2033
  for (const grapheme of segmentGraphemes(text)) {
1621
2034
  if (grapheme.trim() === "") {
1622
2035
  container.append(doc.createTextNode(grapheme));
2036
+ wordWrapper = null;
1623
2037
  continue;
1624
2038
  }
2039
+ if (!wordWrapper) {
2040
+ wordWrapper = doc.createElement("span");
2041
+ wordWrapper.className = "kui-split-word";
2042
+ container.append(wordWrapper);
2043
+ }
1625
2044
  const span = doc.createElement("span");
1626
2045
  markItem(span, index);
1627
2046
  span.textContent = grapheme;
1628
- container.append(span);
2047
+ wordWrapper.append(span);
1629
2048
  spans.push(span);
1630
2049
  index++;
1631
2050
  }
@@ -1672,7 +2091,13 @@ function appendLineSpans(container, doc, text) {
1672
2091
  return buckets.map((nodes, index) => {
1673
2092
  const line = doc.createElement("span");
1674
2093
  markItem(line, index, "kui-split-line");
1675
- for (const node of nodes) line.append(node);
2094
+ for (const node of nodes) {
2095
+ if (node instanceof HTMLElement) {
2096
+ node.removeAttribute("class");
2097
+ node.style.removeProperty("--kui-i");
2098
+ }
2099
+ line.append(node);
2100
+ }
1676
2101
  container.append(line);
1677
2102
  return line;
1678
2103
  });
@@ -1806,7 +2231,7 @@ function jsTextPrimitive(id, channels, options) {
1806
2231
  }
1807
2232
  var splitTiming = {
1808
2233
  duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" },
1809
- delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" },
2234
+ ...TRIGGER_DELAY_PARAM,
1810
2235
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
1811
2236
  stagger: { type: "time", default: "30ms", cssProperty: "--kui-stagger" },
1812
2237
  unit: {
@@ -1824,6 +2249,11 @@ var splitTiming = {
1824
2249
  };
1825
2250
  var motionParams = {
1826
2251
  stagger: { type: "time", default: "40ms", cssProperty: "--kui-stagger" },
2252
+ // `duration`/`ease` are deliberately *not* declared beside the delay: text.css pins both for
2253
+ // wave and jitter on a higher-specificity `[data-kui-split-fx='wave'] .kui-split-item` rule, so
2254
+ // declaring them would advertise two knobs that the stylesheet then overrides. `animation-delay`
2255
+ // is the one the phase-start rule leaves alone, which is what lets `applyStaggerVars` honour it.
2256
+ ...TRIGGER_DELAY_PARAM,
1827
2257
  motion: {
1828
2258
  type: "keyword",
1829
2259
  default: "wave",
@@ -1833,10 +2263,15 @@ var motionParams = {
1833
2263
  };
1834
2264
  var typewriterParams = {
1835
2265
  step: { type: "time", default: "55ms", cssProperty: "--kui-step" },
1836
- loop: { type: "keyword", default: "false", cssProperty: "--kui-loop", values: ["true", "false"] }
2266
+ loop: { type: "keyword", default: "false", cssProperty: "--kui-loop", values: ["true", "false"] },
2267
+ ...TRIGGER_DELAY_PARAM
1837
2268
  };
1838
2269
  var scrambleParams = {
1839
2270
  step: { type: "time", default: "40ms", cssProperty: "--kui-step" },
2271
+ // `duration` gets no such shared declaration: `stepMsFor` reads its authored-or-not distinction
2272
+ // to decide between a whole-effect time and a per-tick `step:`, and a schema default would erase
2273
+ // that distinction. A `0ms` delay default has no equivalent problem.
2274
+ ...TRIGGER_DELAY_PARAM,
1840
2275
  revealEvery: {
1841
2276
  type: "number",
1842
2277
  default: "2",
@@ -1853,7 +2288,11 @@ var scrambleParams = {
1853
2288
  };
1854
2289
  var wordCyclerParams = {
1855
2290
  words: { type: "text", default: "", cssProperty: "--kui-words" },
1856
- interval: { type: "time", default: "2200ms", cssProperty: "--kui-interval" }
2291
+ interval: { type: "time", default: "2200ms", cssProperty: "--kui-interval" },
2292
+ // Load-bearing here, not just for symmetry: a cycler has no authored `duration` — `interval:`
2293
+ // paces it — so the positional "duration then delay" slot only reached a delay when the author
2294
+ // wrote a throwaway first value, `word-cycler 0ms 300ms`.
2295
+ ...TRIGGER_DELAY_PARAM
1857
2296
  };
1858
2297
  function prepareSplitText(el, params, ctx) {
1859
2298
  const doc = el.ownerDocument;
@@ -1899,7 +2338,7 @@ function prepareTypewriter(el, params, ctx) {
1899
2338
  layers.decorative.textContent = graphemes.slice(0, count).join("");
1900
2339
  };
1901
2340
  const run = createStepRunner(ctx.win, {
1902
- delayMs: params.timing.delayMs ?? 0,
2341
+ delayMs: params.timing.delayMs ?? params.ms("delay", 0),
1903
2342
  stepMs: stepMsFor(params, graphemes.length, 55),
1904
2343
  tick: () => {
1905
2344
  const step = nextTypeState(state, graphemes.length, loop2);
@@ -1923,6 +2362,16 @@ function prepareTypewriter(el, params, ctx) {
1923
2362
  function prepareScramble(el, params, ctx) {
1924
2363
  const charset = SCRAMBLE_CHARSETS[params.text("charset", "upper")];
1925
2364
  const revealEvery = Math.max(1, Math.round(params.num("revealEvery", 2)));
2365
+ const node = el;
2366
+ const authoredMinWidth = node.style.getPropertyValue("min-width");
2367
+ const authoredMinHeight = node.style.getPropertyValue("min-height");
2368
+ const restRect = el.getBoundingClientRect();
2369
+ ctx.style.set("min-width", `${restRect.width}px`);
2370
+ ctx.style.set("min-height", `${restRect.height}px`);
2371
+ const releaseSizeLock = () => {
2372
+ ctx.style.set("min-width", authoredMinWidth);
2373
+ ctx.style.set("min-height", authoredMinHeight);
2374
+ };
1926
2375
  const layers = installSplitLayers(el, el.ownerDocument);
1927
2376
  layers.decorative.classList.add("kui-scramble");
1928
2377
  const graphemes = segmentGraphemes(layers.originalText);
@@ -1934,25 +2383,29 @@ function prepareScramble(el, params, ctx) {
1934
2383
  render();
1935
2384
  const totalTicks = Math.max(1, graphemes.length * revealEvery);
1936
2385
  const run = createStepRunner(ctx.win, {
1937
- delayMs: params.timing.delayMs ?? 0,
2386
+ delayMs: params.timing.delayMs ?? params.ms("delay", 0),
1938
2387
  stepMs: stepMsFor(params, totalTicks, Math.max(1, 700 / totalTicks)),
1939
2388
  tick: () => {
1940
2389
  ticks++;
1941
2390
  if (ticks % revealEvery === 0) resolved++;
1942
2391
  render();
1943
- return resolved >= graphemes.length;
2392
+ const done = resolved >= graphemes.length;
2393
+ if (done) releaseSizeLock();
2394
+ return done;
1944
2395
  }
1945
2396
  });
1946
2397
  return {
1947
2398
  cleanup: () => {
1948
2399
  run.stop();
1949
2400
  layers.restore();
2401
+ releaseSizeLock();
1950
2402
  },
1951
2403
  finished: run.finished,
1952
2404
  finish: () => {
1953
2405
  run.stop();
1954
2406
  resolved = graphemes.length;
1955
2407
  render();
2408
+ releaseSizeLock();
1956
2409
  }
1957
2410
  };
1958
2411
  }
@@ -1965,7 +2418,7 @@ function prepareWordCycler(el, params, ctx) {
1965
2418
  let index = 0;
1966
2419
  el.textContent = words[0];
1967
2420
  const run = createStepRunner(ctx.win, {
1968
- delayMs: params.timing.delayMs ?? 0,
2421
+ delayMs: params.timing.delayMs ?? params.ms("delay", 0),
1969
2422
  stepMs: params.ms("interval", 2200),
1970
2423
  tick: () => {
1971
2424
  el.classList.add("kui-word-cycler-swap");
@@ -2013,13 +2466,16 @@ var TEXT_JS_PRIMITIVES = [
2013
2466
  perfClass: "continuous"
2014
2467
  })
2015
2468
  ];
2469
+ var CHARS_STAGGER = "30ms";
2470
+ var WORDS_STAGGER = "90ms";
2471
+ var LINES_STAGGER = "160ms";
2016
2472
  var TEXT_JS_PRESETS = [
2017
- { name: "split-chars", primitive: "split-text", params: { unit: "chars", direction: "fade" } },
2018
- { name: "split-words", primitive: "split-text", params: { unit: "words", direction: "fade" } },
2019
- { name: "split-lines", primitive: "split-text", params: { unit: "lines", direction: "fade" } },
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 },
2473
+ { name: "split-chars", primitive: "split-text", params: { unit: "chars", direction: "fade", stagger: CHARS_STAGGER } },
2474
+ { name: "split-words", primitive: "split-text", params: { unit: "words", direction: "fade", stagger: WORDS_STAGGER } },
2475
+ { name: "split-lines", primitive: "split-text", params: { unit: "lines", direction: "fade", stagger: LINES_STAGGER } },
2476
+ { name: "text-reveal-up", primitive: "split-text", params: { unit: "words", direction: "up", stagger: WORDS_STAGGER }, cloak: true },
2477
+ { name: "text-reveal-down", primitive: "split-text", params: { unit: "words", direction: "down", stagger: WORDS_STAGGER }, cloak: true },
2478
+ { name: "text-reveal-mask", primitive: "split-text", params: { unit: "lines", direction: "mask", stagger: LINES_STAGGER }, cloak: true },
2023
2479
  { name: "text-wave", primitive: "split-text-motion", params: { motion: "wave" } },
2024
2480
  { name: "text-jitter", primitive: "split-text-motion", params: { motion: "jitter" } },
2025
2481
  { name: "typewriter", primitive: "typewriter", params: { loop: "false" } },
@@ -2353,7 +2809,7 @@ function stepStateFor(position, index) {
2353
2809
  // src/effects/forms/primitives.ts
2354
2810
  var timing = {
2355
2811
  duration: { type: "time", default: "400ms", cssProperty: "--kui-duration" },
2356
- delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" },
2812
+ ...TRIGGER_DELAY_PARAM,
2357
2813
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
2358
2814
  };
2359
2815
  var NATIVE_STATE_PRIMITIVE = {
@@ -3547,12 +4003,13 @@ function scrollPrimitive(spec) {
3547
4003
  renderer: "javascript",
3548
4004
  channels,
3549
4005
  parameters,
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"],
4006
+ // Accepted, never read: these primitives read scroll position themselves and are never driven
4007
+ // by an `animation-timeline`. The list exists so that composing the driver with the effects it
4008
+ // drives — `data-kui="pin-section distance:200vh, parallax-rotate ... "` plus `timeline:pin` —
4009
+ // survives `compile.ts`'s `intersect`. Without it the intersection empties, `style-plan.ts`
4010
+ // refuses the timeline, and the scrub silently degrades to a one-shot. See `TIMELINE_AGNOSTIC`
4011
+ // (`effects/shared.ts`) for why the name says abstention rather than support.
4012
+ supportedTimelines: TIMELINE_AGNOSTIC,
3556
4013
  supportedActivations: ["manual", "load", "enter"],
3557
4014
  defaultActivation: "load",
3558
4015
  perfClass,
@@ -3829,7 +4286,21 @@ var SCROLL_PRIMITIVES = [
3829
4286
  }),
3830
4287
  scrollPrimitive({
3831
4288
  id: "smooth-scroll",
3832
- channels: ["layout"],
4289
+ /*
4290
+ * Its own channel, not the `'layout'` it used to share with `pin`, `stacking-cards` and
4291
+ * `scroll-snap`. The channel model exists to stop two effects fighting over the same CSS
4292
+ * property, and this one writes exactly `scroll-behavior` — a property that describes how a
4293
+ * *user-or-script-initiated* scroll is performed, and that no other primitive touches.
4294
+ *
4295
+ * On `'layout'` it made a legitimate pairing impossible. Both `smooth-scroll` and
4296
+ * `scroll-snap` have to sit on the document element to have any effect at all — neither
4297
+ * `scroll-behavior` nor `scroll-snap-type` is propagated to the viewport from `<body>` — so
4298
+ * "apply them to nested elements", the advice the conflict message gives, has no valid
4299
+ * nesting to offer here. `data-kui="smooth-scroll-to, scroll-snap-y"` on `<html>` is the
4300
+ * ordinary way to ask for smooth anchor jumps on a page that also snaps, and it was refused
4301
+ * for a collision that cannot happen: the two write disjoint properties.
4302
+ */
4303
+ channels: ["scroll-behavior"],
3833
4304
  parameters: {
3834
4305
  behavior: { type: "keyword", default: "smooth", cssProperty: "--kui-scroll-behavior", values: ["smooth", "auto"] }
3835
4306
  },
@@ -4266,6 +4737,32 @@ function registerSvg(registry) {
4266
4737
  }
4267
4738
 
4268
4739
  // src/effects/three-d/index.ts
4740
+ var FLIP_CONTROL_SELECTOR = ":scope > .kui-flip-control";
4741
+ function prepareCardToggle(el, params, ctx) {
4742
+ const trigger = params.text("trigger", "click");
4743
+ if (trigger === "click") return () => {
4744
+ };
4745
+ if (!supportsFineHover(ctx.win)) return () => {
4746
+ };
4747
+ const control = el.querySelector(FLIP_CONTROL_SELECTOR);
4748
+ if (!control) {
4749
+ ctx.warn(`flip-card trigger:${trigger} found no direct-child .kui-flip-control \u2014 the card will not flip`);
4750
+ return () => {
4751
+ };
4752
+ }
4753
+ const set = (flipped) => control.setAttribute("aria-pressed", String(flipped));
4754
+ const isFlipped = () => control.getAttribute("aria-pressed") === "true";
4755
+ const onEnter = () => {
4756
+ set(trigger === "hover-toggle" ? !isFlipped() : true);
4757
+ };
4758
+ const onLeave = () => set(false);
4759
+ el.addEventListener("pointerenter", onEnter, { passive: true });
4760
+ if (trigger === "hover") el.addEventListener("pointerleave", onLeave, { passive: true });
4761
+ return () => {
4762
+ el.removeEventListener("pointerenter", onEnter);
4763
+ el.removeEventListener("pointerleave", onLeave);
4764
+ };
4765
+ }
4269
4766
  var CARD_TOGGLE_PRIMITIVE = {
4270
4767
  id: "card-toggle",
4271
4768
  renderer: "javascript",
@@ -4273,14 +4770,20 @@ var CARD_TOGGLE_PRIMITIVE = {
4273
4770
  parameters: {
4274
4771
  duration: { type: "time", default: "700ms", cssProperty: "--kui-duration" },
4275
4772
  ease: { type: "easing", default: "ease-in-out", cssProperty: "--kui-ease" },
4276
- perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" }
4773
+ perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
4774
+ trigger: {
4775
+ type: "keyword",
4776
+ default: "click",
4777
+ cssProperty: "--kui-flip-trigger",
4778
+ values: ["click", "hover", "hover-latch", "hover-toggle"]
4779
+ }
4277
4780
  },
4278
4781
  supportedTimelines: ["time"],
4279
4782
  supportedActivations: ["load"],
4280
4783
  defaultActivation: "load",
4281
4784
  perfClass: "compositor",
4282
4785
  reducedMotion: "disable",
4283
- prepare: () => inertInstance()
4786
+ prepare: deferPrepare(prepareCardToggle)
4284
4787
  };
4285
4788
  var THREE_D_PRIMITIVES = [
4286
4789
  // `skew`, not `rotate`: the keyframes in three-d.css write `transform: perspective(...)