kuinetic 0.2.1 → 0.2.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.
Files changed (64) hide show
  1. package/README.md +15 -8
  2. package/dist/esm/advanced/index.mjs +5 -5
  3. package/dist/esm/{chunk-U43BX5O2.mjs → chunk-FASAEQDR.mjs} +54 -7
  4. package/dist/esm/{chunk-BN2Y4UKB.mjs → chunk-FYMZW5N2.mjs} +2373 -541
  5. package/dist/esm/{chunk-RVTYN6NZ.mjs → chunk-MZQG2VMG.mjs} +208 -2
  6. package/dist/esm/core/index.mjs +21 -3
  7. package/dist/esm/effects/index.mjs +2 -2
  8. package/dist/esm/index.mjs +35 -4
  9. package/dist/esm/notes/index.mjs +1075 -0
  10. package/dist/kuinetic.advanced.js +63 -25
  11. package/dist/kuinetic.advanced.min.js +3 -3
  12. package/dist/kuinetic.all.js +2658 -538
  13. package/dist/kuinetic.all.min.js +2 -2
  14. package/dist/kuinetic.css +238 -65
  15. package/dist/kuinetic.js +2657 -537
  16. package/dist/kuinetic.min.css +1 -1
  17. package/dist/kuinetic.min.js +2 -2
  18. package/dist/kuinetic.notes.js +1 -0
  19. package/dist/types/3d/flattening.d.ts +8 -7
  20. package/dist/types/advanced/camera-3d.d.ts +0 -4
  21. package/dist/types/advanced/fluid-cursor.d.ts +0 -4
  22. package/dist/types/advanced/particles.d.ts +0 -4
  23. package/dist/types/advanced/scenes.d.ts +0 -4
  24. package/dist/types/browser/boot.d.ts +9 -4
  25. package/dist/types/core/activation.d.ts +11 -0
  26. package/dist/types/core/compile.d.ts +7 -0
  27. package/dist/types/core/deck-viewer.d.ts +87 -0
  28. package/dist/types/core/derived/types.d.ts +0 -1
  29. package/dist/types/core/describe.d.ts +174 -0
  30. package/dist/types/core/gesture.d.ts +16 -1
  31. package/dist/types/core/index.d.ts +6 -2
  32. package/dist/types/core/stagger.d.ts +1 -1
  33. package/dist/types/core/target.d.ts +28 -0
  34. package/dist/types/core/timing-contract.d.ts +61 -0
  35. package/dist/types/core/types.d.ts +59 -0
  36. package/dist/types/effects/auto-motion.d.ts +169 -0
  37. package/dist/types/effects/carousel/deck.d.ts +170 -0
  38. package/dist/types/effects/carousel/drag.d.ts +6 -0
  39. package/dist/types/effects/carousel/fit.d.ts +130 -0
  40. package/dist/types/effects/carousel/index.d.ts +6 -23
  41. package/dist/types/effects/carousel/stack.d.ts +77 -0
  42. package/dist/types/effects/catalog/interaction-reveal.d.ts +69 -9
  43. package/dist/types/effects/forms/primitives.d.ts +1 -2
  44. package/dist/types/effects/gestures/index.d.ts +3 -4
  45. package/dist/types/effects/shared.d.ts +3 -30
  46. package/dist/types/effects/step-index.d.ts +48 -10
  47. package/dist/types/effects/swipe-event.d.ts +75 -0
  48. package/dist/types/index.d.ts +26 -0
  49. package/dist/types/notes/catalog-core.d.ts +3 -0
  50. package/dist/types/notes/catalog.d.ts +3 -0
  51. package/dist/types/notes/index.d.ts +13 -0
  52. package/dist/types/notes/interaction-reveal.d.ts +3 -0
  53. package/dist/types/notes/interaction.d.ts +6 -0
  54. package/dist/types/notes/keys.d.ts +7 -0
  55. package/dist/types/notes/mechanics.d.ts +7 -0
  56. package/dist/types/notes/scroll-nav-forms.d.ts +6 -0
  57. package/dist/types/notes/shared.d.ts +6 -0
  58. package/dist/types/notes/showcase.d.ts +7 -0
  59. package/dist/types/notes/text.d.ts +3 -0
  60. package/dist/types/showcase/compare.d.ts +9 -0
  61. package/dist/types/showcase/index.d.ts +3 -0
  62. package/dist/types/showcase/lightbox.d.ts +23 -1
  63. package/dist/types/showcase/modal-shell.d.ts +11 -0
  64. package/package.json +10 -4
@@ -1,23 +1,29 @@
1
1
  import {
2
2
  ABSOLUTE_BASIS,
3
+ ALL_TIMING_TOKENS,
3
4
  ATTR,
4
5
  CHANNEL,
5
6
  DEFAULT_SPRING,
6
7
  Registry,
7
8
  SCOPE_PARAM,
9
+ SUBTREE_CHANNEL,
8
10
  TIME_SCALE_ATTR,
11
+ attributeChannel,
9
12
  clamp01,
10
13
  continuousSetup,
11
14
  createMeasureCache,
12
15
  createSpringRunner,
13
16
  cssEasingValue,
14
17
  decimalNumber,
18
+ declareTimingContract,
15
19
  defaultSpringDeps,
16
20
  deferPrepare,
17
21
  deferredInstance,
18
22
  effectDurationMs,
19
23
  inertInstance,
24
+ inheritTimingContract,
20
25
  isSameOriginPath,
26
+ queryControls,
21
27
  queryScoped,
22
28
  resolveTarget,
23
29
  scopeParam,
@@ -26,7 +32,7 @@ import {
26
32
  toPixels,
27
33
  validate,
28
34
  waapiEasingValue
29
- } from "./chunk-U43BX5O2.mjs";
35
+ } from "./chunk-FASAEQDR.mjs";
30
36
  import {
31
37
  createAttributeLedger,
32
38
  createStyleLedger
@@ -36,7 +42,6 @@ import {
36
42
  var TRIGGER_DELAY_PARAM = {
37
43
  delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" }
38
44
  };
39
- var ALL_TIMING_TOKENS = ["duration", "delay", "ease"];
40
45
  function authoredTiming(params, token) {
41
46
  if (token === "ease") return params.timing.easing;
42
47
  const ms = token === "duration" ? params.timing.durationMs : params.timing.delayMs;
@@ -64,20 +69,20 @@ function mirrorTimingToCss(id, honours, params, ctx) {
64
69
  }
65
70
  }
66
71
  function withTimingContract(id, contract, prepare) {
67
- return (el, params, ctx) => {
72
+ return declareTimingContract((el, params, ctx) => {
68
73
  warnUnhonouredTiming(id, contract, params, ctx.warn);
69
74
  return prepare(el, params, ctx);
70
- };
75
+ }, contract);
71
76
  }
72
77
  function stylesheetTimingPrepare(id, contract) {
73
- return (el, params, ctx) => {
78
+ return declareTimingContract((el, params, ctx) => {
74
79
  warnUnhonouredTiming(id, contract, params, ctx.warn);
75
80
  return deferredInstance(() => {
76
81
  mirrorTimingToCss(id, contract.honours ?? [], params, ctx);
77
82
  return () => {
78
83
  };
79
84
  });
80
- };
85
+ }, contract);
81
86
  }
82
87
  var TIMELINE_AGNOSTIC = ["time", "view", "scroll", "pin"];
83
88
  var COMMON = {
@@ -154,12 +159,12 @@ var AMBIENT_PRIMITIVES = [
154
159
  * What the ring rules actually write (`ambient.css`) beyond the gradient: `mask` +
155
160
  * `mask-composite`, which subtract the element's own content box to leave a ring — the same
156
161
  * physical property `media-mask` claims under the `'mask'` channel — and `position: relative`
157
- * plus a `padding` that *is* the ring's thickness, which is a claim on the host's box in the
158
- * sense `pin` and `background-media` already use `'layout'` for. Declared only as
159
- * `background`, a `gradient-border, pin-section` pair composed silently while both decided the
160
- * host's `position`, and `gradient-border, mask-reveal` while both wrote `mask`.
162
+ * plus a `padding` that *is* the ring's thickness — `position` as `pin` and `background-media`
163
+ * claim it, `padding` as `header-shrink` does. Declared only as `background`, a
164
+ * `gradient-border, pin-section` pair composed silently while both decided the host's
165
+ * `position`, and `gradient-border, mask-reveal` while both wrote `mask`.
161
166
  */
162
- cssPrimitive("ambient-gradient-ring", [CHANNEL.background, "mask", "layout"], {
167
+ cssPrimitive("ambient-gradient-ring", [CHANNEL.background, "mask", "position", "padding"], {
163
168
  parameters: drift,
164
169
  defaultActivation: "load",
165
170
  reducedMotion: "disable",
@@ -837,22 +842,287 @@ var GROUP_PRESETS = [
837
842
  var STATE_PRIMITIVES = [...PRESS_PRIMITIVES, ...GROUP_PRIMITIVES];
838
843
  var STATE_PRESETS = [...PRESS_PRESETS, ...GROUP_PRESETS];
839
844
 
845
+ // src/core/element-size.ts
846
+ function watchElementSize(win, onChange) {
847
+ const ResizeObserverCtor = win.ResizeObserver;
848
+ const observer = ResizeObserverCtor ? new ResizeObserverCtor(onChange) : void 0;
849
+ const elements = /* @__PURE__ */ new Set();
850
+ win.addEventListener("resize", onChange, { passive: true });
851
+ return {
852
+ observe(element) {
853
+ if (elements.has(element)) return;
854
+ elements.add(element);
855
+ observer?.observe(element);
856
+ },
857
+ unobserve(element) {
858
+ elements.delete(element);
859
+ observer?.unobserve(element);
860
+ },
861
+ disconnect() {
862
+ win.removeEventListener("resize", onChange);
863
+ observer?.disconnect();
864
+ elements.clear();
865
+ }
866
+ };
867
+ }
868
+ function frameScheduler(win, callback) {
869
+ let frame;
870
+ let timeout;
871
+ const run = () => {
872
+ frame = void 0;
873
+ timeout = void 0;
874
+ callback();
875
+ };
876
+ return {
877
+ request() {
878
+ if (frame !== void 0 || timeout !== void 0) return;
879
+ if (win.requestAnimationFrame) frame = win.requestAnimationFrame(run);
880
+ else timeout = win.setTimeout(run, 16);
881
+ },
882
+ cancel() {
883
+ if (frame !== void 0) win.cancelAnimationFrame(frame);
884
+ if (timeout !== void 0) win.clearTimeout(timeout);
885
+ frame = void 0;
886
+ timeout = void 0;
887
+ }
888
+ };
889
+ }
890
+
840
891
  // src/effects/catalog/interaction-reveal.ts
841
- function revealPrimitive(id, channels, parameters, perfClass = "compositor") {
892
+ function revealPrimitive(id, channels, parameters, { perfClass = "compositor", placed } = {}) {
893
+ const contract = {
894
+ honours: ALL_TIMING_TOKENS,
895
+ because: "interaction.css pins that value on this effect"
896
+ };
842
897
  return {
843
898
  id,
844
899
  renderer: "javascript",
845
- channels,
900
+ // A placed family publishes the resolved side, and a running tease, on its host.
901
+ channels: placed ? [...channels, attributeChannel(placed.attribute), attributeChannel(placed.tease)] : channels,
846
902
  parameters,
847
903
  supportedTimelines: ["time"],
848
904
  supportedActivations: ["load"],
849
905
  defaultActivation: "load",
850
906
  perfClass,
851
907
  reducedMotion: "shorten",
852
- prepare: stylesheetTimingPrepare(id, {
853
- honours: ALL_TIMING_TOKENS,
854
- because: "interaction.css pins that value on this effect"
855
- })
908
+ prepare: placed ? withTimingContract(
909
+ id,
910
+ contract,
911
+ (el, params, ctx) => deferredInstance(() => {
912
+ mirrorTimingToCss(id, ALL_TIMING_TOKENS, params, ctx);
913
+ return prepareReveal(el, params, placed, ctx);
914
+ })
915
+ ) : stylesheetTimingPrepare(id, contract)
916
+ };
917
+ }
918
+ var OPPOSITE = { top: "bottom", bottom: "top", left: "right", right: "left" };
919
+ function isSide(value) {
920
+ return value !== null && Object.hasOwn(OPPOSITE, value);
921
+ }
922
+ function chooseSide(preferred, room, extent) {
923
+ if (room[preferred] >= extent) return preferred;
924
+ const opposite = OPPOSITE[preferred];
925
+ return room[opposite] > room[preferred] ? opposite : preferred;
926
+ }
927
+ function extentOf(part, host, side) {
928
+ switch (side) {
929
+ case "top":
930
+ return -part.offsetTop;
931
+ case "bottom":
932
+ return part.offsetTop + part.offsetHeight - host.clientHeight;
933
+ case "left":
934
+ return -part.offsetLeft;
935
+ case "right":
936
+ return part.offsetLeft + part.offsetWidth - host.clientWidth;
937
+ }
938
+ }
939
+ function prepareReveal(el, params, spec, ctx) {
940
+ const release2 = claimPart(el, params, spec, ctx);
941
+ const attrs = createAttributeLedger(el);
942
+ const restore = () => {
943
+ attrs.restore();
944
+ release2();
945
+ };
946
+ const place2 = params.text("place");
947
+ const teaseMs = params.ms("tease", 0);
948
+ const auto = place2 === "auto";
949
+ if (!auto && isSide(place2)) attrs.set(spec.attribute, place2);
950
+ const placeSide = auto ? autoPlacer(el, spec, attrs, ctx.win) : void 0;
951
+ const shift = shifter(el, spec, ctx);
952
+ const measure = () => {
953
+ placeSide?.();
954
+ shift();
955
+ };
956
+ let endTease = () => {
957
+ };
958
+ const shown = whileShown(el, ctx, measure, () => endTease());
959
+ const tease = teaseMs > 0 ? teaseOnEnter(el, ctx, { ms: teaseMs, attribute: spec.tease, attrs, shown }) : void 0;
960
+ if (tease) endTease = tease.end;
961
+ return () => {
962
+ tease?.stop();
963
+ shown.stop();
964
+ restore();
965
+ };
966
+ }
967
+ function claimPart(el, params, spec, ctx) {
968
+ const selector = resolveTarget(params.text("target"), ctx, spec.name);
969
+ if (!selector) return () => {
970
+ };
971
+ const matches = queryScoped(el, ctx, selector, "self");
972
+ const part = matches[0];
973
+ if (!part) {
974
+ ctx.warn(`${spec.name} target "${selector}" matched nothing inside this element`);
975
+ return () => {
976
+ };
977
+ }
978
+ if (matches.length > 1) {
979
+ ctx.warn(
980
+ `${spec.name} target "${selector}" matched ${matches.length} elements; only the first is used`
981
+ );
982
+ }
983
+ const ledger = createAttributeLedger(part);
984
+ if (!part.hasAttribute(spec.marker)) ledger.set(spec.marker, "");
985
+ return () => ledger.restore();
986
+ }
987
+ function autoPlacer(el, spec, attrs, win) {
988
+ const preferred = spec.preferred(el);
989
+ let side = preferred;
990
+ attrs.set(spec.attribute, side);
991
+ return () => {
992
+ const part = el.querySelector(`[${spec.marker}]`);
993
+ if (!part) return;
994
+ const rect = el.getBoundingClientRect();
995
+ const room = {
996
+ top: rect.top,
997
+ bottom: win.innerHeight - rect.bottom,
998
+ left: rect.left,
999
+ right: win.innerWidth - rect.right
1000
+ };
1001
+ const next = chooseSide(preferred, room, extentOf(part, el, side));
1002
+ if (next === side) return;
1003
+ side = next;
1004
+ attrs.set(spec.attribute, side);
1005
+ };
1006
+ }
1007
+ var VIEWPORT_MARGIN = 8;
1008
+ function shiftWithin(start, size, viewport, margin = VIEWPORT_MARGIN) {
1009
+ const low = margin;
1010
+ const high = viewport - margin;
1011
+ if (start < low || size > high - low) return low - start;
1012
+ if (start + size > high) return high - start - size;
1013
+ return 0;
1014
+ }
1015
+ function shifter(el, spec, ctx) {
1016
+ let written = 0;
1017
+ return () => {
1018
+ const part = el.querySelector(`[${spec.marker}]`);
1019
+ if (!part) return;
1020
+ const stamped = el.getAttribute(spec.attribute);
1021
+ const side = isSide(stamped) ? stamped : spec.preferred(el);
1022
+ const rect = part.getBoundingClientRect();
1023
+ const hostRect = el.getBoundingClientRect();
1024
+ const [startKey, sizeKey, offsetKey, borderKey, layoutKey, viewportKey, windowKey] = side === "top" || side === "bottom" ? ["left", "width", "offsetLeft", "clientLeft", "offsetWidth", "clientWidth", "innerWidth"] : ["top", "height", "offsetTop", "clientTop", "offsetHeight", "clientHeight", "innerHeight"];
1025
+ const hostSize = el[layoutKey];
1026
+ const zoom = hostSize > 0 ? hostRect[sizeKey] / hostSize : 1;
1027
+ if (zoom <= 0) return;
1028
+ const entranceScale = parseFloat(ctx.win.getComputedStyle(part).scale) || 1;
1029
+ const size = rect[sizeKey] / entranceScale;
1030
+ const start = hostRect[startKey] + (el[borderKey] + part[offsetKey]) * zoom - size / 2;
1031
+ const viewport = ctx.doc.documentElement[viewportKey] || ctx.win[windowKey];
1032
+ const next = shiftWithin(start, size, viewport) / zoom;
1033
+ if (next === written) return;
1034
+ written = next;
1035
+ ctx.style.set(spec.shift, `${next}px`);
1036
+ };
1037
+ }
1038
+ function whileShown(el, ctx, measure, onUser) {
1039
+ const { win } = ctx;
1040
+ const frame = frameScheduler(win, measure);
1041
+ let hovered = false;
1042
+ let focused = false;
1043
+ let teasing = false;
1044
+ let watching;
1045
+ const sync = () => {
1046
+ const shown = hovered || focused || teasing;
1047
+ if (shown && !watching) {
1048
+ measure();
1049
+ watching = new AbortController();
1050
+ const options = { capture: true, passive: true, signal: watching.signal };
1051
+ win.addEventListener("scroll", frame.request, options);
1052
+ win.addEventListener("resize", frame.request, options);
1053
+ } else if (!shown && watching) {
1054
+ watching.abort();
1055
+ watching = void 0;
1056
+ frame.cancel();
1057
+ }
1058
+ };
1059
+ const on = (type, update) => el.addEventListener(type, (event) => {
1060
+ update(event);
1061
+ sync();
1062
+ }, { signal: ctx.signal });
1063
+ on("pointerenter", () => {
1064
+ hovered = true;
1065
+ onUser();
1066
+ });
1067
+ on("pointerleave", () => hovered = false);
1068
+ on("focusin", () => {
1069
+ focused = true;
1070
+ onUser();
1071
+ });
1072
+ on("focusout", (event) => {
1073
+ focused = el.contains(event.relatedTarget);
1074
+ });
1075
+ return {
1076
+ setTeasing(on2) {
1077
+ teasing = on2;
1078
+ sync();
1079
+ },
1080
+ stop() {
1081
+ watching?.abort();
1082
+ frame.cancel();
1083
+ }
1084
+ };
1085
+ }
1086
+ function teaseOnEnter(el, ctx, {
1087
+ ms,
1088
+ attribute,
1089
+ attrs,
1090
+ shown
1091
+ }) {
1092
+ const { win } = ctx;
1093
+ let timer;
1094
+ let observer;
1095
+ const end = () => {
1096
+ if (timer === void 0) return;
1097
+ win.clearTimeout(timer);
1098
+ timer = void 0;
1099
+ attrs.remove(attribute);
1100
+ shown.setTeasing(false);
1101
+ };
1102
+ const start = () => {
1103
+ shown.setTeasing(true);
1104
+ attrs.set(attribute, "");
1105
+ timer = win.setTimeout(end, ms);
1106
+ };
1107
+ const Observer = win.IntersectionObserver;
1108
+ if (Observer) {
1109
+ observer = new Observer(
1110
+ (entries) => {
1111
+ if (!entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= 0.5)) return;
1112
+ observer.disconnect();
1113
+ start();
1114
+ },
1115
+ { threshold: 0.5 }
1116
+ );
1117
+ observer.observe(el);
1118
+ }
1119
+ return {
1120
+ end,
1121
+ stop() {
1122
+ observer?.disconnect();
1123
+ if (timer !== void 0) win.clearTimeout(timer);
1124
+ timer = void 0;
1125
+ }
856
1126
  };
857
1127
  }
858
1128
  var labelSwapParams = {
@@ -885,10 +1155,34 @@ var hoverIntentParams = {
885
1155
  duration: { type: "time", default: "160ms", cssProperty: "--kui-duration" },
886
1156
  delay: { type: "time", default: "1000ms", cssProperty: "--kui-delay" },
887
1157
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
888
- distance: { type: "length", default: "4px", cssProperty: "--kui-hover-intent-distance" }
1158
+ distance: { type: "length", default: "4px", cssProperty: "--kui-hover-intent-distance" },
1159
+ color: { type: "color", default: "#f4f4f0", cssProperty: "--kui-hover-intent-color" },
1160
+ "bg-color": { type: "color", default: "#111111", cssProperty: "--kui-hover-intent-bg-color" },
1161
+ radius: { type: "length", default: "12px", cssProperty: "--kui-hover-intent-radius" },
1162
+ // `place` and `tease` are read by `prepareReveal`, not by any stylesheet — see
1163
+ // `ParamSpecBase.cssProperty`.
1164
+ place: {
1165
+ type: "keyword",
1166
+ default: "top",
1167
+ keywords: ["top", "bottom", "auto"],
1168
+ cssProperty: "--kui-hover-intent-place"
1169
+ },
1170
+ tease: { type: "time", default: "0ms", cssProperty: "--kui-hover-intent-tease" },
1171
+ // Names the hint, in place of a `data-kui-hint` in the markup — see `claimPart`. Declared, so
1172
+ // `compile.ts` leaves it here rather than moving the effect onto the hint.
1173
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
889
1174
  };
890
1175
  var HOVER_INTENT_PRIMITIVES = [
891
- revealPrimitive("hover-intent", ["hint"], hoverIntentParams)
1176
+ revealPrimitive("hover-intent", ["hint"], hoverIntentParams, {
1177
+ placed: {
1178
+ name: "hover-intent",
1179
+ marker: "data-kui-hint",
1180
+ attribute: "data-kui-hint-place",
1181
+ tease: "data-kui-hint-tease",
1182
+ shift: "--kui-hover-intent-shift",
1183
+ preferred: () => "top"
1184
+ }
1185
+ })
892
1186
  ];
893
1187
  var HOVER_INTENT_PRESETS = [
894
1188
  { name: "hover-intent", phase: "state", primitive: "hover-intent" }
@@ -906,16 +1200,65 @@ var anchoredPreviewParams = {
906
1200
  finite: true,
907
1201
  minimum: 0,
908
1202
  maximum: 1
909
- }
1203
+ },
1204
+ color: { type: "color", default: "currentcolor", cssProperty: "--kui-anchored-preview-color" },
1205
+ "bg-color": {
1206
+ type: "color",
1207
+ default: "transparent",
1208
+ cssProperty: "--kui-anchored-preview-bg-color"
1209
+ },
1210
+ radius: { type: "length", default: "0px", cssProperty: "--kui-anchored-preview-radius" },
1211
+ // `place` and `tease` are read by `prepareReveal`, not by any stylesheet — see
1212
+ // `ParamSpecBase.cssProperty`. `tease` is `hover-intent`'s, unchanged: `0ms` is off.
1213
+ place: {
1214
+ type: "keyword",
1215
+ default: "top",
1216
+ keywords: ["top", "bottom", "left", "right", "auto"],
1217
+ cssProperty: "--kui-anchored-preview-place"
1218
+ },
1219
+ tease: { type: "time", default: "0ms", cssProperty: "--kui-anchored-preview-tease" },
1220
+ // Names the preview, in place of a `data-kui-preview` in the markup — see `claimPart`.
1221
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
910
1222
  };
1223
+ function previewSideOf(el) {
1224
+ for (const token of (el.getAttribute(ATTR.normalized) ?? "").split(/\s+/)) {
1225
+ const side = /^anchored-preview(?:-(bottom|left|right))?$/.exec(token);
1226
+ if (side) return side[1] ?? "top";
1227
+ }
1228
+ return "top";
1229
+ }
911
1230
  var ANCHORED_PREVIEW_PRIMITIVES = [
912
- revealPrimitive("anchored-preview", ["preview"], anchoredPreviewParams)
1231
+ revealPrimitive("anchored-preview", ["preview"], anchoredPreviewParams, {
1232
+ placed: {
1233
+ name: "anchored-preview",
1234
+ marker: "data-kui-preview",
1235
+ attribute: "data-kui-preview-place",
1236
+ tease: "data-kui-preview-tease",
1237
+ shift: "--kui-anchored-preview-shift",
1238
+ preferred: previewSideOf
1239
+ }
1240
+ })
913
1241
  ];
914
1242
  var ANCHORED_PREVIEW_PRESETS = [
915
1243
  { name: "anchored-preview", phase: "state", primitive: "anchored-preview" },
916
- { name: "anchored-preview-bottom", phase: "state", primitive: "anchored-preview" },
917
- { name: "anchored-preview-left", phase: "state", primitive: "anchored-preview" },
918
- { name: "anchored-preview-right", phase: "state", primitive: "anchored-preview" }
1244
+ {
1245
+ name: "anchored-preview-bottom",
1246
+ phase: "state",
1247
+ primitive: "anchored-preview",
1248
+ params: { place: "bottom" }
1249
+ },
1250
+ {
1251
+ name: "anchored-preview-left",
1252
+ phase: "state",
1253
+ primitive: "anchored-preview",
1254
+ params: { place: "left" }
1255
+ },
1256
+ {
1257
+ name: "anchored-preview-right",
1258
+ phase: "state",
1259
+ primitive: "anchored-preview",
1260
+ params: { place: "right" }
1261
+ }
919
1262
  ];
920
1263
  var searchExpandParams = {
921
1264
  duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
@@ -933,7 +1276,7 @@ var SEARCH_EXPAND_PRIMITIVES = [
933
1276
  "search-expand",
934
1277
  ["expand", "discrete", "search-field"],
935
1278
  searchExpandParams,
936
- "layout"
1279
+ { perfClass: "layout" }
937
1280
  )
938
1281
  ];
939
1282
  var SEARCH_EXPAND_PRESETS = [
@@ -1349,7 +1692,10 @@ var cursorDotParams = {
1349
1692
  label: { type: "text", default: "", cssProperty: "--kui-label" }
1350
1693
  };
1351
1694
  var POINTER_PRIMITIVES = [
1352
- pointerPrimitive("tilt-3d", ["rotate"], tiltParams, deferPrepare(prepareTilt3d)),
1695
+ // `skew` is the `transform` shorthand's channel, which is what `prepareTilt3d` writes (a
1696
+ // perspective and two axis rotations) — never the `rotate` property — beside an inline
1697
+ // `transition` for its settle.
1698
+ pointerPrimitive("tilt-3d", ["skew", "transition"], tiltParams, deferPrepare(prepareTilt3d)),
1353
1699
  pointerPrimitive("tilt-parallax", ["translate"], parallaxParams, deferPrepare(prepareTiltParallax)),
1354
1700
  pointerPrimitive("cursor-follow", ["translate"], cursorDotParams, deferPrepare(prepareCursorFollow)),
1355
1701
  pointerPrimitive("cursor-lag", ["translate"], cursorDotParams, deferPrepare(prepareCursorLag)),
@@ -1358,7 +1704,13 @@ var POINTER_PRIMITIVES = [
1358
1704
  // `pseudo-before`: the glow overlay is a `::before` rule in `interaction.css`, so this primitive
1359
1705
  // owns that box the same way the border family does. See `border-draw`'s comment in
1360
1706
  // `HOVER_PRIMITIVES` for what the token means and why it was added here in the same change.
1361
- pointerPrimitive("cursor-spotlight", ["spotlight", "pseudo-before"], {}, deferPrepare(prepareSpotlight))
1707
+ // `position`: the `relative` it gives an unpositioned host so that overlay has an anchor.
1708
+ pointerPrimitive(
1709
+ "cursor-spotlight",
1710
+ ["spotlight", "pseudo-before", "position"],
1711
+ {},
1712
+ deferPrepare(prepareSpotlight)
1713
+ )
1362
1714
  ];
1363
1715
  function prepareCursorFollow(el, params, ctx) {
1364
1716
  return prepareCursorDot(el, params, ctx, "kui-cursor-dot-follow");
@@ -1838,52 +2190,6 @@ function installBackgroundMedia(el, ctx, options) {
1838
2190
  };
1839
2191
  }
1840
2192
 
1841
- // src/core/element-size.ts
1842
- function watchElementSize(win, onChange) {
1843
- const ResizeObserverCtor = win.ResizeObserver;
1844
- const observer = ResizeObserverCtor ? new ResizeObserverCtor(onChange) : void 0;
1845
- const elements = /* @__PURE__ */ new Set();
1846
- win.addEventListener("resize", onChange, { passive: true });
1847
- return {
1848
- observe(element) {
1849
- if (elements.has(element)) return;
1850
- elements.add(element);
1851
- observer?.observe(element);
1852
- },
1853
- unobserve(element) {
1854
- elements.delete(element);
1855
- observer?.unobserve(element);
1856
- },
1857
- disconnect() {
1858
- win.removeEventListener("resize", onChange);
1859
- observer?.disconnect();
1860
- elements.clear();
1861
- }
1862
- };
1863
- }
1864
- function frameScheduler(win, callback) {
1865
- let frame;
1866
- let timeout;
1867
- const run = () => {
1868
- frame = void 0;
1869
- timeout = void 0;
1870
- callback();
1871
- };
1872
- return {
1873
- request() {
1874
- if (frame !== void 0 || timeout !== void 0) return;
1875
- if (win.requestAnimationFrame) frame = win.requestAnimationFrame(run);
1876
- else timeout = win.setTimeout(run, 16);
1877
- },
1878
- cancel() {
1879
- if (frame !== void 0) win.cancelAnimationFrame(frame);
1880
- if (timeout !== void 0) win.clearTimeout(timeout);
1881
- frame = void 0;
1882
- timeout = void 0;
1883
- }
1884
- };
1885
- }
1886
-
1887
2193
  // src/effects/catalog/media-shared.ts
1888
2194
  var AXIS_DEGREES = { vertical: 0, horizontal: 90 };
1889
2195
  var UNIT_DEGREES = { deg: 1, d: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 };
@@ -2242,14 +2548,14 @@ function prepareSlatAssemble(el, params, ctx) {
2242
2548
  settle2 = resolve;
2243
2549
  });
2244
2550
  let landed = false;
2245
- const land = () => {
2551
+ const land2 = () => {
2246
2552
  if (landed) return;
2247
2553
  landed = true;
2248
2554
  stage.classList.remove("kui-slat-animating");
2249
2555
  built.restore();
2250
2556
  settle2();
2251
2557
  };
2252
- const timer = ctx.win.setTimeout(land, slatAssembleFinishMs(params, count));
2558
+ const timer = ctx.win.setTimeout(land2, slatAssembleFinishMs(params, count));
2253
2559
  return {
2254
2560
  cleanup: () => {
2255
2561
  ctx.win.clearTimeout(timer);
@@ -2260,7 +2566,7 @@ function prepareSlatAssemble(el, params, ctx) {
2260
2566
  finished,
2261
2567
  finish: () => {
2262
2568
  ctx.win.clearTimeout(timer);
2263
- land();
2569
+ land2();
2264
2570
  }
2265
2571
  };
2266
2572
  }
@@ -2395,7 +2701,9 @@ var MEDIA_JS_PRIMITIVES = [
2395
2701
  {
2396
2702
  id: "slat-assemble",
2397
2703
  renderer: "javascript",
2398
- channels: [CHANNEL.opacity, CHANNEL.translate, CHANNEL.rotate],
2704
+ // `position` for the `relative` it gives an unpositioned host, and the subtree because
2705
+ // `installSlatStage` appends its slat stage there.
2706
+ channels: [CHANNEL.opacity, CHANNEL.translate, CHANNEL.rotate, "position", SUBTREE_CHANNEL],
2399
2707
  parameters: slatParams,
2400
2708
  supportedTimelines: ["time"],
2401
2709
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -2419,12 +2727,15 @@ var MEDIA_JS_PRIMITIVES = [
2419
2727
  * and it is what makes `background-media, video-scrub` on one element a reported conflict
2420
2728
  * rather than two effects silently fighting over the same picture.
2421
2729
  *
2422
- * `layout` is claimed for the same reason `pin` claims it: preparation writes `position` and
2423
- * `isolation` on the *host*, which is a stacking-context claim on someone else's element. Left
2424
- * undeclared, `background-media, pin-section` composed silently while both decided what
2425
- * `position` the host has — the conflict detector cannot report a claim it was never told about.
2730
+ * `position` and `isolation` are the two properties preparation writes on the *host*, a
2731
+ * stacking-context claim on someone else's element. Left undeclared, `background-media,
2732
+ * pin-section` composed silently while both decided what `position` the host has — the
2733
+ * conflict detector cannot report a claim it was never told about.
2734
+ *
2735
+ * And the subtree, because the layer is appended into the host (`installBackgroundMedia`): it
2736
+ * would become a deck's last slide, or `scroll-story`'s `:last-child` in place of its sections.
2426
2737
  */
2427
- channels: ["media", "layout"],
2738
+ channels: ["media", "position", "isolation", SUBTREE_CHANNEL],
2428
2739
  renderer: "javascript",
2429
2740
  parameters: backgroundMediaParams,
2430
2741
  // Not a claim to support four timelines — an abstention. A backdrop is not driven by progress
@@ -2953,7 +3264,9 @@ function countPrimitive(id, parameters, prepare) {
2953
3264
  return {
2954
3265
  id,
2955
3266
  renderer: "javascript",
2956
- channels: ["content"],
3267
+ // The subtree too: both counters rebuild the host's children (the number, or the odometer's
3268
+ // digit columns), so a deck or a widget beside one would lose what it put there.
3269
+ channels: ["content", SUBTREE_CHANNEL],
2957
3270
  parameters,
2958
3271
  supportedTimelines: ["time"],
2959
3272
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -3341,7 +3654,9 @@ function jsTextPrimitive(id, channels, options) {
3341
3654
  return {
3342
3655
  id,
3343
3656
  renderer: "javascript",
3344
- channels,
3657
+ // Every one of these replaces the host's children with its own spans or layers, so it owns
3658
+ // them: beside a deck it would split the slides, beside `slow-mo` it would swallow the toggle.
3659
+ channels: [...channels, SUBTREE_CHANNEL],
3345
3660
  parameters: options.parameters,
3346
3661
  supportedTimelines: ["time"],
3347
3662
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -3471,21 +3786,21 @@ function lineCompletion(ctx, params, layers, count) {
3471
3786
  const finished = new Promise((resolve) => {
3472
3787
  settle2 = resolve;
3473
3788
  });
3474
- const land = () => {
3789
+ const land2 = () => {
3475
3790
  ctx.win.clearTimeout(timer);
3476
3791
  timer = void 0;
3477
3792
  layers.decorative.removeAttribute("aria-hidden");
3478
3793
  layers.srOnly.remove();
3479
3794
  settle2();
3480
3795
  };
3481
- let timer = ctx.win.setTimeout(land, splitRevealFinishMs(params, count));
3796
+ let timer = ctx.win.setTimeout(land2, splitRevealFinishMs(params, count));
3482
3797
  return {
3483
3798
  finished,
3484
- land,
3799
+ land: land2,
3485
3800
  rearm(nextCount) {
3486
3801
  if (timer === void 0) return;
3487
3802
  ctx.win.clearTimeout(timer);
3488
- timer = ctx.win.setTimeout(land, splitRevealFinishMs(params, nextCount));
3803
+ timer = ctx.win.setTimeout(land2, splitRevealFinishMs(params, nextCount));
3489
3804
  },
3490
3805
  cancel() {
3491
3806
  if (timer !== void 0) ctx.win.clearTimeout(timer);
@@ -3724,7 +4039,8 @@ var TEXT_JS_PRIMITIVES = [
3724
4039
  prepare: deferPrepare(prepareTypewriter),
3725
4040
  perfClass: "continuous"
3726
4041
  }),
3727
- jsTextPrimitive("scramble-text", ["content"], {
4042
+ // `min-width`/`min-height`: the rest box it pins while the glyphs churn (`prepareScramble`).
4043
+ jsTextPrimitive("scramble-text", ["content", "min-width", "min-height"], {
3728
4044
  parameters: scrambleParams,
3729
4045
  prepare: deferPrepare(prepareScramble),
3730
4046
  perfClass: "continuous"
@@ -4133,6 +4449,36 @@ function registerCore(registry) {
4133
4449
  return registry;
4134
4450
  }
4135
4451
 
4452
+ // src/core/deck-viewer.ts
4453
+ var provided = null;
4454
+ function provideDeckViewer(viewer) {
4455
+ provided = viewer;
4456
+ }
4457
+ function attachDeckViewer(request) {
4458
+ return provided ? provided(request) : null;
4459
+ }
4460
+ var VIEWER_EVENT = "kui:viewer";
4461
+ function announceViewer(doc, opener, open2) {
4462
+ const Ctor = doc.defaultView?.CustomEvent ?? CustomEvent;
4463
+ const source = opener.isConnected ? opener : doc;
4464
+ source.dispatchEvent(new Ctor(VIEWER_EVENT, { bubbles: true, detail: { open: open2 } }));
4465
+ }
4466
+ var DECK_LIGHTBOX_PARAM = {
4467
+ type: "keyword",
4468
+ default: "false",
4469
+ cssProperty: "--kui-deck-lightbox",
4470
+ keywords: ["true", "false"]
4471
+ };
4472
+ function deckLightbox(enabled, request, warn) {
4473
+ if (!enabled) return () => {
4474
+ };
4475
+ const release2 = attachDeckViewer(request);
4476
+ if (release2) return release2;
4477
+ warn("lightbox:true needs the showcase module (the lightbox gallery), which is not loaded; cards will not open");
4478
+ return () => {
4479
+ };
4480
+ }
4481
+
4136
4482
  // src/effects/step-marking.ts
4137
4483
  var STEP_STATE_ATTR = "data-kui-step-state";
4138
4484
  var STEP_OFFSET_ATTR = "data-kui-step-offset";
@@ -4221,28 +4567,49 @@ function countSteps(params, resolveSteps) {
4221
4567
  }
4222
4568
  return Math.max(1, ...groups.values());
4223
4569
  }
4570
+ function bindControl(request, param, run) {
4571
+ const { el, ctx, params, label } = request;
4572
+ const selector = resolveTarget(params.text(param), ctx, `${label} ${param}`);
4573
+ if (!selector) return null;
4574
+ if (queryControls(el, ctx, selector).length === 0) ctx.warn(`${label} ${param} "${selector}" matched nothing`);
4575
+ return { selector, run };
4576
+ }
4224
4577
  function delegateControls(request) {
4225
- const { el, ctx, scope, groups } = request;
4578
+ const { el, ctx, groups } = request;
4226
4579
  if (groups.length === 0) return () => {
4227
4580
  };
4228
- const root = scope === "page" ? ctx.doc : el;
4229
- const onClick = (event) => {
4230
- const from = event.target;
4231
- if (typeof from?.closest !== "function") return;
4581
+ const press = (from, inside) => {
4232
4582
  for (const { selector, run } of groups) {
4233
- const matches = queryScoped(el, ctx, selector, scope);
4583
+ const matches = queryControls(el, ctx, selector);
4234
4584
  const matched = new Set(matches);
4585
+ const stop = inside ? el : null;
4235
4586
  let node = from;
4236
- while (node && !matched.has(node)) node = node.parentElement;
4237
- if (!node) continue;
4587
+ while (node && node !== stop && !matched.has(node)) node = node.parentElement;
4588
+ if (!node || node === stop) continue;
4238
4589
  run(node, matches.indexOf(node));
4239
4590
  }
4240
4591
  };
4241
- root.addEventListener("click", onClick);
4242
- return () => root.removeEventListener("click", onClick);
4592
+ const pressed = (event) => {
4593
+ const from = event.target;
4594
+ return typeof from?.closest === "function" ? from : null;
4595
+ };
4596
+ const onInside = (event) => {
4597
+ const from = pressed(event);
4598
+ if (from) press(from, true);
4599
+ };
4600
+ const onOutside = (event) => {
4601
+ const from = pressed(event);
4602
+ if (from && !el.contains(from)) press(from, false);
4603
+ };
4604
+ el.addEventListener("click", onInside);
4605
+ ctx.doc.addEventListener("click", onOutside);
4606
+ return () => {
4607
+ el.removeEventListener("click", onInside);
4608
+ ctx.doc.removeEventListener("click", onOutside);
4609
+ };
4243
4610
  }
4244
4611
  function createStepIndex(options) {
4245
- const { el, params, ctx, scope, resolveSteps, onRender } = options;
4612
+ const { el, params, ctx, resolveSteps, onRender } = options;
4246
4613
  const name = options.name ?? "step-progress";
4247
4614
  const marker = createStepMarker(resolveSteps, (message) => ctx.warn(`${name} ${message}`));
4248
4615
  const self = createAttributeLedger(el);
@@ -4261,26 +4628,23 @@ function createStepIndex(options) {
4261
4628
  };
4262
4629
  const next = () => goTo(nextStep(step, total()));
4263
4630
  const prev = () => goTo(prevStep(step, total()));
4264
- const groups = [];
4265
- const bindControl = (param, run) => {
4266
- const selector = resolveTarget(params.text(param), ctx, `${name} ${param}`);
4267
- if (!selector) return false;
4268
- if (queryScoped(el, ctx, selector, scope).length === 0) {
4269
- ctx.warn(`${name} ${param} "${selector}" matched nothing`);
4270
- }
4271
- groups.push({ selector, run });
4272
- return true;
4631
+ const byHand = (move) => {
4632
+ options.onInput?.();
4633
+ move();
4273
4634
  };
4274
- const named = [
4275
- bindControl("next", next),
4276
- bindControl("prev", prev),
4277
- bindControl("jump", (_node, position) => {
4278
- if (position >= 0) goTo(position);
4279
- })
4280
- ].some(Boolean);
4281
- const fallback = options.clickFallback !== false && !named;
4282
- if (fallback) el.addEventListener("click", next);
4283
- const releaseControls = delegateControls({ el, ctx, scope, groups });
4635
+ const request = { el, ctx, params, label: name };
4636
+ const groups = [
4637
+ bindControl(request, "next", () => byHand(next)),
4638
+ bindControl(request, "prev", () => byHand(prev)),
4639
+ bindControl(request, "jump", (_node, position) => {
4640
+ if (position >= 0) byHand(() => goTo(position));
4641
+ }),
4642
+ ...options.controls ?? []
4643
+ ].filter((group) => group !== null);
4644
+ const fallback = options.clickFallback !== false && groups.length === 0;
4645
+ const onContainerClick = () => byHand(next);
4646
+ if (fallback) el.addEventListener("click", onContainerClick);
4647
+ const releaseControls = delegateControls({ el, ctx, groups });
4284
4648
  render();
4285
4649
  return {
4286
4650
  goTo,
@@ -4289,7 +4653,7 @@ function createStepIndex(options) {
4289
4653
  current: () => step,
4290
4654
  total,
4291
4655
  release: () => {
4292
- if (fallback) el.removeEventListener("click", next);
4656
+ if (fallback) el.removeEventListener("click", onContainerClick);
4293
4657
  releaseControls();
4294
4658
  self.restore();
4295
4659
  selfStyle.restore();
@@ -4298,51 +4662,327 @@ function createStepIndex(options) {
4298
4662
  };
4299
4663
  }
4300
4664
 
4301
- // src/effects/forms/primitives.ts
4302
- var timing = {
4303
- duration: { type: "time", default: "400ms", cssProperty: "--kui-duration" },
4304
- ...TRIGGER_DELAY_PARAM,
4305
- ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
4306
- };
4307
- var FORM_STATE_TIMING = {
4308
- because: "forms.css pins its timing literally \u2014 the motion lands on a sibling that inline custom properties cannot reach"
4309
- };
4310
- var NATIVE_STATE_PRIMITIVE = {
4311
- id: "native-state",
4312
- renderer: "javascript",
4313
- channels: [CHANNEL.translate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.stroke, CHANNEL.color],
4314
- parameters: {},
4315
- supportedTimelines: ["time"],
4316
- supportedActivations: ["load"],
4317
- defaultActivation: "load",
4318
- perfClass: "compositor",
4319
- // Native pseudo-classes drive these and the motion lands on a sibling, not on the control that
4320
- // carries the attribute, so base.css's policy layer enforces this through its sibling
4321
- // `transition-duration` rules rather than the `animation-*` ones.
4322
- reducedMotion: "disable",
4323
- prepare: withTimingContract("native-state", FORM_STATE_TIMING, () => inertInstance())
4324
- };
4325
- function prepareSiblingScale(cssProperty) {
4326
- return (el, params) => {
4327
- const sibling = el.nextElementSibling;
4328
- sibling?.style.setProperty(cssProperty, String(params.num("scale", 1)));
4329
- return () => sibling?.style.removeProperty(cssProperty);
4330
- };
4665
+ // src/effects/auto-motion.ts
4666
+ var MIN_PERIOD_MS = 2e3;
4667
+ var MAX_FRAME_MS = 100;
4668
+ function clampPeriod(ms) {
4669
+ if (!Number.isFinite(ms) || ms === 0) return 0;
4670
+ return Math.sign(ms) * Math.max(MIN_PERIOD_MS, Math.abs(ms));
4671
+ }
4672
+ function cyclesFor(dtMs, periodMs) {
4673
+ return Math.min(Math.max(dtMs, 0), MAX_FRAME_MS) / periodMs;
4674
+ }
4675
+ function isKeyboardFocus(target) {
4676
+ const node = target;
4677
+ if (typeof node?.matches !== "function") return true;
4678
+ try {
4679
+ return node.matches(":focus-visible");
4680
+ } catch {
4681
+ return true;
4682
+ }
4331
4683
  }
4332
- function siblingScalePrimitive(id, cssProperty) {
4333
- return {
4334
- id,
4335
- renderer: "javascript",
4336
- channels: [CHANNEL.translate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.stroke, CHANNEL.color],
4337
- parameters: { scale: { type: "number", default: "1", cssProperty } },
4338
- supportedTimelines: ["time"],
4339
- supportedActivations: ["load"],
4340
- defaultActivation: "load",
4341
- perfClass: "compositor",
4342
- reducedMotion: "disable",
4343
- prepare: withTimingContract(id, FORM_STATE_TIMING, deferPrepare(prepareSiblingScale(cssProperty)))
4684
+ function createAutoMotion(request) {
4685
+ const { el, ctx, spinMs, autoplayMs, settleMs, advance, step, setSpinning, onPausedChange } = request;
4686
+ const pauseOnHover = request.pauseOnHover ?? true;
4687
+ const enabled = spinMs !== 0 || autoplayMs !== 0;
4688
+ if (!enabled) {
4689
+ return {
4690
+ enabled,
4691
+ interrupt() {
4692
+ },
4693
+ hold() {
4694
+ },
4695
+ toggle() {
4696
+ },
4697
+ release() {
4698
+ }
4699
+ };
4700
+ }
4701
+ const win = ctx.win;
4702
+ let userPaused = ctx.reducedMotion;
4703
+ let hovered = false;
4704
+ let focused = false;
4705
+ let onscreen = true;
4706
+ let gesture = false;
4707
+ let held = false;
4708
+ let viewing = false;
4709
+ let viewerOpen = false;
4710
+ let focusBeforeViewer = false;
4711
+ let frame = 0;
4712
+ let lastFrameAt = null;
4713
+ let timer = null;
4714
+ let holdTimer = null;
4715
+ let spinning = false;
4716
+ const active = () => !userPaused && !hovered && !focused && onscreen && !gesture && !held && !viewing && !ctx.doc.hidden;
4717
+ const requestFrame = (callback) => typeof win.requestAnimationFrame === "function" ? win.requestAnimationFrame(callback) : win.setTimeout(() => callback(Date.now()), 16);
4718
+ const cancelFrame = (handle) => {
4719
+ if (typeof win.cancelAnimationFrame === "function") win.cancelAnimationFrame(handle);
4720
+ else win.clearTimeout(handle);
4721
+ };
4722
+ const publishSpinning = (next) => {
4723
+ if (next === spinning) return;
4724
+ spinning = next;
4725
+ setSpinning(next);
4726
+ };
4727
+ const tick = (now) => {
4728
+ frame = 0;
4729
+ if (!active()) return;
4730
+ if (lastFrameAt !== null) advance(cyclesFor(now - lastFrameAt, spinMs));
4731
+ lastFrameAt = now;
4732
+ frame = requestFrame(tick);
4733
+ };
4734
+ const startSpin = () => {
4735
+ if (frame !== 0) return;
4736
+ publishSpinning(true);
4737
+ lastFrameAt = null;
4738
+ frame = requestFrame(tick);
4739
+ };
4740
+ const stopSpin = () => {
4741
+ if (frame !== 0) cancelFrame(frame);
4742
+ frame = 0;
4743
+ publishSpinning(false);
4744
+ };
4745
+ const armAutoplay = () => {
4746
+ if (timer !== null) return;
4747
+ timer = win.setTimeout(() => {
4748
+ timer = null;
4749
+ step(autoplayMs > 0 ? 1 : -1);
4750
+ update();
4751
+ }, Math.abs(autoplayMs));
4344
4752
  };
4345
- }
4753
+ const clearAutoplay = () => {
4754
+ if (timer === null) return;
4755
+ win.clearTimeout(timer);
4756
+ timer = null;
4757
+ };
4758
+ function update() {
4759
+ const running = active();
4760
+ if (spinMs !== 0) (running ? startSpin : stopSpin)();
4761
+ if (autoplayMs !== 0) (running ? armAutoplay : clearAutoplay)();
4762
+ }
4763
+ const onEnter = () => {
4764
+ hovered = true;
4765
+ update();
4766
+ };
4767
+ const onLeave = () => {
4768
+ hovered = false;
4769
+ update();
4770
+ };
4771
+ const onFocusIn = (event) => {
4772
+ focused = (!viewerOpen || focusBeforeViewer) && isKeyboardFocus(event.target);
4773
+ update();
4774
+ };
4775
+ const onFocusOut = (event) => {
4776
+ if (!viewerOpen) focusBeforeViewer = focused;
4777
+ const next = event.relatedTarget;
4778
+ focused = next !== null && el.contains(next) && isKeyboardFocus(next);
4779
+ update();
4780
+ };
4781
+ const onVisibility = () => update();
4782
+ const onViewer = (event) => {
4783
+ const open2 = event.detail?.open === true;
4784
+ viewerOpen = open2;
4785
+ viewing = open2 && el.contains(event.target);
4786
+ update();
4787
+ };
4788
+ if (pauseOnHover) {
4789
+ el.addEventListener("pointerenter", onEnter);
4790
+ el.addEventListener("pointerleave", onLeave);
4791
+ }
4792
+ el.addEventListener("focusin", onFocusIn);
4793
+ el.addEventListener("focusout", onFocusOut);
4794
+ ctx.doc.addEventListener("visibilitychange", onVisibility);
4795
+ ctx.doc.addEventListener(VIEWER_EVENT, onViewer);
4796
+ const Observer = win.IntersectionObserver;
4797
+ const observer = Observer ? new Observer((entries) => {
4798
+ for (const entry of entries) onscreen = entry.isIntersecting;
4799
+ update();
4800
+ }) : null;
4801
+ observer?.observe(el);
4802
+ onPausedChange(userPaused);
4803
+ update();
4804
+ return {
4805
+ enabled,
4806
+ interrupt() {
4807
+ held = true;
4808
+ if (holdTimer !== null) win.clearTimeout(holdTimer);
4809
+ holdTimer = win.setTimeout(() => {
4810
+ holdTimer = null;
4811
+ held = false;
4812
+ update();
4813
+ }, Math.max(0, settleMs));
4814
+ update();
4815
+ },
4816
+ hold(holding) {
4817
+ gesture = holding;
4818
+ update();
4819
+ },
4820
+ toggle() {
4821
+ userPaused = !userPaused;
4822
+ onPausedChange(userPaused);
4823
+ update();
4824
+ },
4825
+ release() {
4826
+ if (frame !== 0) cancelFrame(frame);
4827
+ frame = 0;
4828
+ if (timer !== null) win.clearTimeout(timer);
4829
+ if (holdTimer !== null) win.clearTimeout(holdTimer);
4830
+ timer = null;
4831
+ holdTimer = null;
4832
+ observer?.disconnect();
4833
+ el.removeEventListener("pointerenter", onEnter);
4834
+ el.removeEventListener("pointerleave", onLeave);
4835
+ el.removeEventListener("focusin", onFocusIn);
4836
+ el.removeEventListener("focusout", onFocusOut);
4837
+ ctx.doc.removeEventListener("visibilitychange", onVisibility);
4838
+ ctx.doc.removeEventListener(VIEWER_EVENT, onViewer);
4839
+ publishSpinning(false);
4840
+ }
4841
+ };
4842
+ }
4843
+ var AUTOPLAY_PARAM = { type: "time", default: "0s", cssProperty: "--kui-autoplay" };
4844
+ var PAUSE_PARAM = { type: "text", default: "", cssProperty: "--kui-pause" };
4845
+ var HOVER_PARAM = {
4846
+ type: "keyword",
4847
+ default: "pause",
4848
+ keywords: ["pause", "none"],
4849
+ cssProperty: "--kui-hover"
4850
+ };
4851
+ function motionPeriods(request) {
4852
+ const { params, ctx, label } = request;
4853
+ const spinRaw = request.spin ? params.ms("spin", 0) : 0;
4854
+ const autoplayRaw = params.ms("autoplay", 0);
4855
+ const spin2 = clampPeriod(spinRaw);
4856
+ const autoplay = clampPeriod(autoplayRaw);
4857
+ if (spin2 !== spinRaw) ctx.warn(`${label} spin: minimum is 2s; clamped to ${Math.abs(spin2) / 1e3}s`);
4858
+ if (autoplay !== autoplayRaw) {
4859
+ ctx.warn(`${label} autoplay: minimum is 2s; clamped to ${Math.abs(autoplay) / 1e3}s`);
4860
+ }
4861
+ if (spin2 !== 0 && autoplay !== 0) {
4862
+ ctx.warn(`${label}: spin: and autoplay: are both set; spin: wins`);
4863
+ return { spin: spin2, autoplay: 0 };
4864
+ }
4865
+ return { spin: spin2, autoplay };
4866
+ }
4867
+ function createDeckMotion(request) {
4868
+ const { el, ctx, params, label, settleMs, step } = request;
4869
+ const { spin: spin2, autoplay } = motionPeriods(request);
4870
+ const ledgers = /* @__PURE__ */ new Map();
4871
+ const pauseSelector = resolveTarget(params.text("pause"), ctx, `${label} pause`);
4872
+ const reflectPaused = (paused) => {
4873
+ if (!pauseSelector) return;
4874
+ for (const node of queryControls(el, ctx, pauseSelector)) {
4875
+ let ledger = ledgers.get(node);
4876
+ if (!ledger) {
4877
+ ledger = createAttributeLedger(node);
4878
+ ledgers.set(node, ledger);
4879
+ }
4880
+ ledger.set("aria-pressed", String(paused));
4881
+ }
4882
+ };
4883
+ const motion = createAutoMotion({
4884
+ el,
4885
+ ctx,
4886
+ spinMs: spin2,
4887
+ autoplayMs: autoplay,
4888
+ settleMs,
4889
+ pauseOnHover: !params.is("hover", "none"),
4890
+ advance: request.spin?.advance,
4891
+ step,
4892
+ setSpinning: request.spin?.setSpinning,
4893
+ onPausedChange: reflectPaused
4894
+ });
4895
+ let pause = null;
4896
+ if (pauseSelector && !motion.enabled) {
4897
+ ctx.warn(`${label} pause: has nothing to pause without ${request.spin ? "spin: or " : ""}autoplay:`);
4898
+ } else if (pauseSelector) {
4899
+ pause = bindControl({ el, ctx, params, label }, "pause", () => motion.toggle());
4900
+ }
4901
+ return {
4902
+ motion,
4903
+ pause,
4904
+ release() {
4905
+ motion.release();
4906
+ for (const ledger of ledgers.values()) ledger.restore();
4907
+ ledgers.clear();
4908
+ }
4909
+ };
4910
+ }
4911
+
4912
+ // src/effects/swipe-event.ts
4913
+ var SWIPE_EVENT = "kui:swipe";
4914
+ var DIRECTIONS = /* @__PURE__ */ new Set(["left", "right", "up", "down"]);
4915
+ function announceSwipe(win, el, direction) {
4916
+ const Ctor = win.CustomEvent;
4917
+ el.dispatchEvent(new Ctor(SWIPE_EVENT, { bubbles: true, detail: { direction } }));
4918
+ }
4919
+ function swipeStep(direction) {
4920
+ return direction === "left" || direction === "up" ? 1 : -1;
4921
+ }
4922
+ function swipeReaches(source, el) {
4923
+ if (source === el) return true;
4924
+ if (!source.contains(el)) return false;
4925
+ for (let node = el.parentElement; node && node !== source; node = node.parentElement) {
4926
+ if (node.hasAttribute("data-kui-step")) return false;
4927
+ }
4928
+ return true;
4929
+ }
4930
+ function stepOnSwipe(doc, el, step) {
4931
+ const onSwipe = (event) => {
4932
+ const direction = event.detail?.direction;
4933
+ const source = event.target;
4934
+ if (!direction || !DIRECTIONS.has(direction) || source.nodeType !== 1) return;
4935
+ if (swipeReaches(source, el)) step(swipeStep(direction));
4936
+ };
4937
+ doc.addEventListener(SWIPE_EVENT, onSwipe);
4938
+ return () => doc.removeEventListener(SWIPE_EVENT, onSwipe);
4939
+ }
4940
+
4941
+ // src/effects/forms/primitives.ts
4942
+ var timing = {
4943
+ duration: { type: "time", default: "400ms", cssProperty: "--kui-duration" },
4944
+ ...TRIGGER_DELAY_PARAM,
4945
+ ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
4946
+ };
4947
+ var FORM_STATE_TIMING = {
4948
+ because: "forms.css pins its timing literally \u2014 the motion lands on a sibling that inline custom properties cannot reach"
4949
+ };
4950
+ var NATIVE_STATE_PRIMITIVE = {
4951
+ id: "native-state",
4952
+ renderer: "javascript",
4953
+ channels: [CHANNEL.translate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.stroke, CHANNEL.color],
4954
+ parameters: {},
4955
+ supportedTimelines: ["time"],
4956
+ supportedActivations: ["load"],
4957
+ defaultActivation: "load",
4958
+ perfClass: "compositor",
4959
+ // Native pseudo-classes drive these and the motion lands on a sibling, not on the control that
4960
+ // carries the attribute, so base.css's policy layer enforces this through its sibling
4961
+ // `transition-duration` rules rather than the `animation-*` ones.
4962
+ reducedMotion: "disable",
4963
+ prepare: withTimingContract("native-state", FORM_STATE_TIMING, () => inertInstance())
4964
+ };
4965
+ function prepareSiblingScale(cssProperty) {
4966
+ return (el, params) => {
4967
+ const sibling = el.nextElementSibling;
4968
+ sibling?.style.setProperty(cssProperty, String(params.num("scale", 1)));
4969
+ return () => sibling?.style.removeProperty(cssProperty);
4970
+ };
4971
+ }
4972
+ function siblingScalePrimitive(id, cssProperty) {
4973
+ return {
4974
+ id,
4975
+ renderer: "javascript",
4976
+ channels: [CHANNEL.translate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.stroke, CHANNEL.color],
4977
+ parameters: { scale: { type: "number", default: "1", cssProperty } },
4978
+ supportedTimelines: ["time"],
4979
+ supportedActivations: ["load"],
4980
+ defaultActivation: "load",
4981
+ perfClass: "compositor",
4982
+ reducedMotion: "disable",
4983
+ prepare: withTimingContract(id, FORM_STATE_TIMING, deferPrepare(prepareSiblingScale(cssProperty)))
4984
+ };
4985
+ }
4346
4986
  var TOGGLE_MORPH_PRIMITIVE = siblingScalePrimitive("toggle-morph", "--kui-toggle-scale");
4347
4987
  var RADIO_FILL_PRIMITIVE = siblingScalePrimitive("radio-fill", "--kui-radio-scale");
4348
4988
  var FOCUS_RING_PRIMITIVE = cssPrimitive("focus-ring", ["shadow"], {
@@ -4418,7 +5058,7 @@ function prepareRangeFill(el, params, ctx) {
4418
5058
  }
4419
5059
  var STRENGTH_METER_PRIMITIVE = jsInputPrimitive(
4420
5060
  "strength-meter",
4421
- ["meter"],
5061
+ ["meter", attributeChannel("data-kui-strength-level")],
4422
5062
  {},
4423
5063
  deferPrepare(prepareStrengthMeter)
4424
5064
  );
@@ -4429,14 +5069,55 @@ var RANGE_FILL_PRIMITIVE = jsInputPrimitive(
4429
5069
  deferPrepare(prepareRangeFill)
4430
5070
  );
4431
5071
  function prepareStepProgress(el, params, ctx) {
4432
- const selector = resolveTarget(params.text("target"), ctx, "step-progress");
5072
+ const label = "step-progress";
5073
+ const selector = resolveTarget(params.text("target"), ctx, label);
4433
5074
  const scope = scopeParam(params, "page");
4434
5075
  const resolveSteps = () => selector ? queryScoped(el, ctx, selector, scope) : el.children;
4435
- return createStepIndex({ el, params, ctx, scope, resolveSteps }).release;
5076
+ const lightbox = params.is("lightbox");
5077
+ const { motion, pause, release: releaseMotion } = createDeckMotion({
5078
+ el,
5079
+ ctx,
5080
+ params,
5081
+ label,
5082
+ settleMs: effectDurationMs(params, 400),
5083
+ step: (direction) => direction > 0 ? index.next() : index.prev()
5084
+ });
5085
+ const index = createStepIndex({
5086
+ el,
5087
+ params,
5088
+ ctx,
5089
+ resolveSteps,
5090
+ controls: pause ? [pause] : [],
5091
+ onInput: () => motion.interrupt(),
5092
+ /*
5093
+ * With `lightbox:true` a click on a slide opens the viewer, so the container click cannot also
5094
+ * advance the deck: one press would both open the gallery and move the slide behind it. The
5095
+ * deck still moves on its arrows, dots, swipe and autoplay.
5096
+ */
5097
+ clickFallback: !lightbox
5098
+ });
5099
+ const releaseViewer = deckLightbox(
5100
+ lightbox,
5101
+ { host: el, cards: resolveSteps, doc: ctx.doc, reducedMotion: ctx.reducedMotion },
5102
+ (message) => ctx.warn(`${label} ${message}`)
5103
+ );
5104
+ const releaseSwipe = stepOnSwipe(ctx.doc, el, (direction) => {
5105
+ motion.interrupt();
5106
+ if (direction > 0) index.next();
5107
+ else index.prev();
5108
+ });
5109
+ return () => {
5110
+ releaseViewer();
5111
+ releaseSwipe();
5112
+ releaseMotion();
5113
+ index.release();
5114
+ };
4436
5115
  }
4437
5116
  var STEP_PROGRESS_BASE = jsInputPrimitive(
4438
5117
  "step-progress",
4439
- ["state"],
5118
+ // The host's children are its steps unless `target:` says otherwise, so a widget that inserts
5119
+ // one (`slow-mo`'s toggle) would become a step: the subtree is this deck's.
5120
+ [SUBTREE_CHANNEL, attributeChannel("data-kui-step")],
4440
5121
  {
4441
5122
  // Empty default, not '4': `readParams` fills every declared default in unconditionally and
4442
5123
  // does not validate it, so an empty one is how `prepareStepProgress` tells "unauthored" from
@@ -4483,6 +5164,16 @@ var STEP_PROGRESS_BASE = jsInputPrimitive(
4483
5164
  * at 390px and at 1440px, and the page keeps owning the box. Same reason `rest:` is a number.
4484
5165
  */
4485
5166
  main: { type: "number", default: "1", cssProperty: "--kui-main", finite: true, minimum: 0 },
5167
+ /*
5168
+ * The deck steps itself every `autoplay:` (`0s`, the default, is off), and `pause:` names the
5169
+ * author's play/pause control, and `hover:none` stops a resting pointer pausing it. The spatial
5170
+ * decks' declarations and scheduler, shared — see `prepareStepProgress` and `effects/auto-motion.ts`.
5171
+ */
5172
+ autoplay: AUTOPLAY_PARAM,
5173
+ pause: PAUSE_PARAM,
5174
+ hover: HOVER_PARAM,
5175
+ // A click on a slide opens every slide in the lightbox gallery. See `core/deck-viewer.ts`.
5176
+ lightbox: DECK_LIGHTBOX_PARAM,
4486
5177
  // Which tree `target:` is searched in. Unset means this primitive's own historical answer —
4487
5178
  // see `prepareStepProgress`. One declaration, shared: `effects/step-marking.ts`.
4488
5179
  scope: SCOPE_PARAM
@@ -4529,7 +5220,7 @@ function prepareSubmitFlow(el, params, ctx) {
4529
5220
  }
4530
5221
  var SUBMIT_FLOW_PRIMITIVE = jsInputPrimitive(
4531
5222
  "submit-flow",
4532
- ["state"],
5223
+ [attributeChannel("data-kui-stage")],
4533
5224
  {
4534
5225
  load: { type: "time", default: "1200ms", cssProperty: "--kui-load" },
4535
5226
  hold: { type: "time", default: "1500ms", cssProperty: "--kui-hold" }
@@ -4579,9 +5270,12 @@ var FORMS_PRESETS = [
4579
5270
  * two readings is the stylesheet. `step-progress` was the wrong word to type on a deck of
4580
5271
  * slides, which is the only reason this alias exists; nothing behaves differently under it.
4581
5272
  *
4582
- * It is still an *index*, not a carousel component: no ARIA, no roving focus, no autoplay. That
4583
- * boundary is the one section H states — the library animates elements you control and does not
4584
- * own the widget — and naming this `carousel` does not move it.
5273
+ * It is still an *index*, not a carousel component: no ARIA, no roving focus. That boundary is
5274
+ * the one section H states — the library animates elements you control and does not own the
5275
+ * widget — and naming this `carousel` does not move it. `autoplay:`/`pause:` do not cross it: they
5276
+ * press the same "next" a visitor's control does, on a timer, and the control is still the
5277
+ * author's markup (`effects/auto-motion.ts`, shared with the spatial decks). Likewise
5278
+ * `lightbox:true` hands a click on a slide to the showcase gallery (`core/deck-viewer.ts`).
4585
5279
  *
4586
5280
  * No `requiresOwnSubtree` here, unlike `step-progress` beside it. That flag means "this name's
4587
5281
  * shipped CSS reaches past the element into its descendants, so the universal `target:` must
@@ -4643,6 +5337,10 @@ function recognise(el, handlers, options = {}, deps = defaultGestureDeps()) {
4643
5337
  let active = false;
4644
5338
  let longPressTimer = null;
4645
5339
  let longPressFired = false;
5340
+ let captured = false;
5341
+ let pointerId = null;
5342
+ let lastHandled = null;
5343
+ let documentListening = null;
4646
5344
  function sampleOf(event) {
4647
5345
  return { x: event.clientX, y: event.clientY, time: deps.now() };
4648
5346
  }
@@ -4656,11 +5354,24 @@ function recognise(el, handlers, options = {}, deps = defaultGestureDeps()) {
4656
5354
  longPressTimer = null;
4657
5355
  }
4658
5356
  function onDown(event) {
5357
+ if (origin && pointerId !== null && event.pointerId !== pointerId) {
5358
+ clearLongPress();
5359
+ const last = samples[samples.length - 1];
5360
+ releaseCapture(pointerId);
5361
+ finish({ x: last.x, y: last.y, time: deps.now() });
5362
+ return;
5363
+ }
5364
+ if (options.accept && !options.accept(event)) {
5365
+ origin = null;
5366
+ return;
5367
+ }
4659
5368
  origin = sampleOf(event);
4660
5369
  samples = [origin];
4661
5370
  active = false;
4662
5371
  longPressFired = false;
4663
- if (capturePointer) el.setPointerCapture?.(event.pointerId);
5372
+ pointerId = event.pointerId;
5373
+ captured = capturePointer === true && takeCapture(event.pointerId);
5374
+ if (!captured) listenOnDocument();
4664
5375
  if (longPressMs > 0) {
4665
5376
  longPressTimer = deps.setTimer(() => {
4666
5377
  longPressFired = true;
@@ -4668,8 +5379,43 @@ function recognise(el, handlers, options = {}, deps = defaultGestureDeps()) {
4668
5379
  }, longPressMs);
4669
5380
  }
4670
5381
  }
5382
+ function takeCapture(id) {
5383
+ if (typeof el.setPointerCapture !== "function") return false;
5384
+ try {
5385
+ el.setPointerCapture(id);
5386
+ return true;
5387
+ } catch {
5388
+ return false;
5389
+ }
5390
+ }
5391
+ function captureOnDrag(event) {
5392
+ if (capturePointer !== "drag") return;
5393
+ captured = takeCapture(event.pointerId);
5394
+ if (captured) stopListeningOnDocument();
5395
+ }
5396
+ function listenOnDocument() {
5397
+ const doc = el.ownerDocument;
5398
+ if (!doc || documentListening) return;
5399
+ documentListening = doc;
5400
+ doc.addEventListener("pointermove", onMove, { passive: true });
5401
+ doc.addEventListener("pointerup", onUp, { passive: true });
5402
+ doc.addEventListener("pointercancel", onUp, { passive: true });
5403
+ }
5404
+ function stopListeningOnDocument() {
5405
+ const doc = documentListening;
5406
+ if (!doc) return;
5407
+ documentListening = null;
5408
+ doc.removeEventListener("pointermove", onMove);
5409
+ doc.removeEventListener("pointerup", onUp);
5410
+ doc.removeEventListener("pointercancel", onUp);
5411
+ }
5412
+ function firstHearing(event) {
5413
+ if (event === lastHandled) return false;
5414
+ lastHandled = event;
5415
+ return true;
5416
+ }
4671
5417
  function onMove(event) {
4672
- if (!origin) return;
5418
+ if (!origin || !firstHearing(event)) return;
4673
5419
  const sample = sampleOf(event);
4674
5420
  samples.push(sample);
4675
5421
  if (samples.length > MAX_SAMPLES) samples.shift();
@@ -4678,17 +5424,17 @@ function recognise(el, handlers, options = {}, deps = defaultGestureDeps()) {
4678
5424
  if (!active) {
4679
5425
  active = true;
4680
5426
  clearLongPress();
5427
+ captureOnDrag(event);
4681
5428
  handlers.onStart?.(origin);
4682
5429
  }
4683
5430
  handlers.onMove?.(vector, sample);
4684
5431
  }
4685
- function onUp(event) {
4686
- clearLongPress();
4687
- if (capturePointer) el.releasePointerCapture?.(event.pointerId);
4688
- if (!origin) return;
4689
- const sample = sampleOf(event);
4690
- samples.push(sample);
4691
- const vector = vectorNow(sample);
5432
+ function releaseCapture(id) {
5433
+ if (captured) el.releasePointerCapture?.(id);
5434
+ captured = false;
5435
+ pointerId = null;
5436
+ }
5437
+ function reportEnd(vector, sample) {
4692
5438
  if (active) {
4693
5439
  handlers.onEnd?.(vector, sample);
4694
5440
  const direction = swipeDirection(vector, swipeVelocity);
@@ -4696,21 +5442,41 @@ function recognise(el, handlers, options = {}, deps = defaultGestureDeps()) {
4696
5442
  } else if (longPressFired) {
4697
5443
  handlers.onEnd?.(vector, sample);
4698
5444
  }
5445
+ }
5446
+ function onUp(event) {
5447
+ clearLongPress();
5448
+ stopListeningOnDocument();
5449
+ releaseCapture(event.pointerId);
5450
+ if (!origin) return;
5451
+ finish(sampleOf(event));
5452
+ }
5453
+ function finish(sample) {
5454
+ samples.push(sample);
5455
+ reportEnd(vectorNow(sample), sample);
4699
5456
  origin = null;
4700
5457
  active = false;
4701
5458
  longPressFired = false;
4702
5459
  samples = [];
4703
5460
  }
5461
+ function onLostCapture(event) {
5462
+ if (event.target !== el) return;
5463
+ if (!captured || event.pointerId !== pointerId) return;
5464
+ captured = false;
5465
+ onUp(event);
5466
+ }
4704
5467
  el.addEventListener("pointerdown", onDown);
4705
5468
  el.addEventListener("pointermove", onMove, { passive: true });
4706
5469
  el.addEventListener("pointerup", onUp, { passive: true });
4707
5470
  el.addEventListener("pointercancel", onUp, { passive: true });
5471
+ el.addEventListener("lostpointercapture", onLostCapture, { passive: true });
4708
5472
  return () => {
4709
5473
  clearLongPress();
5474
+ stopListeningOnDocument();
4710
5475
  el.removeEventListener("pointerdown", onDown);
4711
5476
  el.removeEventListener("pointermove", onMove);
4712
5477
  el.removeEventListener("pointerup", onUp);
4713
5478
  el.removeEventListener("pointercancel", onUp);
5479
+ el.removeEventListener("lostpointercapture", onLostCapture);
4714
5480
  };
4715
5481
  }
4716
5482
  function defaultGestureDeps() {
@@ -4848,22 +5614,26 @@ function settle(runners, offset, vector, mode) {
4848
5614
  runners.x.to(offset.x + vector.vx * carry);
4849
5615
  runners.y.to(offset.y + vector.vy * carry);
4850
5616
  }
4851
- function prepareSwipeable(el, params) {
5617
+ function prepareSwipeable(el, params, ctx) {
5618
+ const axis = params.text("axis", "both");
5619
+ if (axis !== "both") ctx.style.set("touch-action", `${axis === "x" ? "pan-y" : "pan-x"} pinch-zoom`);
4852
5620
  const stop = recognise(
4853
5621
  el,
4854
5622
  {
4855
5623
  onSwipe(direction) {
4856
5624
  el.setAttribute("data-kui-swipe", direction);
5625
+ announceSwipe(ctx.win, el, direction);
4857
5626
  }
4858
5627
  },
4859
5628
  {
4860
- axis: params.text("axis", "both"),
5629
+ axis,
4861
5630
  swipeVelocity: params.num("velocity", 300),
4862
- // This primitive publishes an attribute and moves nothing, so it has no reason to hold the
4863
- // pointer — and holding it retargets the following `click` at this element, which silently
4864
- // breaks every interactive child. A `swipe-x` on a carousel shell killed its own dots and
4865
- // buttons that way. See `capturePointer` in `core/gesture.ts`.
4866
- capturePointer: false
5631
+ // This primitive moves nothing, so it must not hold the pointer from `pointerdown` — that
5632
+ // retargets the following `click` at this element and kills every interactive child (a
5633
+ // `swipe-x` on a carousel shell killed its own dots and buttons). But a flick that leaves a
5634
+ // small element never delivers `pointerup` to it unless captured, so capture only once the
5635
+ // drag starts. See `capturePointer` in `core/gesture.ts`.
5636
+ capturePointer: "drag"
4867
5637
  }
4868
5638
  );
4869
5639
  return () => {
@@ -4922,7 +5692,7 @@ function prepareMagnetic(el, params, ctx) {
4922
5692
  var GESTURE_PRIMITIVES = [
4923
5693
  gesturePrimitive(
4924
5694
  "draggable",
4925
- ["translate"],
5695
+ ["translate", attributeChannel("data-kui-dragging")],
4926
5696
  {
4927
5697
  ...springParams2,
4928
5698
  axis: { type: "keyword", default: "both", cssProperty: "--kui-axis", keywords: ["x", "y", "both"] },
@@ -4953,7 +5723,7 @@ var GESTURE_PRIMITIVES = [
4953
5723
  ),
4954
5724
  gesturePrimitive(
4955
5725
  "swipeable",
4956
- ["state"],
5726
+ [attributeChannel("data-kui-swipe")],
4957
5727
  {
4958
5728
  axis: { type: "keyword", default: "both", cssProperty: "--kui-axis", keywords: ["x", "y", "both"] },
4959
5729
  velocity: { type: "number", default: "300", cssProperty: "--kui-velocity" }
@@ -4962,7 +5732,7 @@ var GESTURE_PRIMITIVES = [
4962
5732
  ),
4963
5733
  gesturePrimitive(
4964
5734
  "pressable",
4965
- ["state"],
5735
+ [attributeChannel("data-kui-pressed")],
4966
5736
  { duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" } },
4967
5737
  // `duration` here is the hold threshold, not a span of motion — `long-press 800ms` means
4968
5738
  // "count it once the finger has been down for 800ms". It is read (see `preparePressable`), so
@@ -5013,6 +5783,7 @@ var GESTURE_PRESETS = [
5013
5783
  },
5014
5784
  { name: "swipe", primitive: "swipeable", phase: "state" },
5015
5785
  { name: "swipe-x", primitive: "swipeable", params: { axis: "x" }, phase: "state" },
5786
+ { name: "swipe-y", primitive: "swipeable", params: { axis: "y" }, phase: "state" },
5016
5787
  { name: "long-press", primitive: "pressable", phase: "state" },
5017
5788
  { name: "magnetic", primitive: "magnetic", phase: "state" },
5018
5789
  {
@@ -5308,13 +6079,15 @@ var LAYOUT_PRIMITIVES = [
5308
6079
  ),
5309
6080
  layoutPrimitive(
5310
6081
  "auto-height",
5311
- ["layout"],
6082
+ // The two properties it writes on the panel: `overflow: hidden`, and the `height` it animates.
6083
+ ["overflow", "height"],
5312
6084
  { attribute: { type: "text", default: "data-open", cssProperty: "--kui-attribute" } },
5313
6085
  deferPrepare(prepareAutoHeight)
5314
6086
  ),
5315
6087
  layoutPrimitive(
5316
6088
  "flip-indicator",
5317
- ["translate", "layout"],
6089
+ // The bar's landing spot (`translate`) and its `width`, matched to the selected tab.
6090
+ ["translate", "width"],
5318
6091
  {
5319
6092
  follow: { type: "text", default: "", cssProperty: "--kui-follow" },
5320
6093
  attribute: { type: "text", default: "aria-selected", cssProperty: "--kui-attribute" }
@@ -5563,26 +6336,25 @@ function prepareBackToTop(el, params, ctx) {
5563
6336
  var NAV_JS_PRIMITIVES = [
5564
6337
  navPrimitive(
5565
6338
  "header-shrink",
5566
- // `'shadow'` alongside `'layout'`: `header-shrink`'s host rule also transitions `box-shadow`
5567
- // (navigation.css), which `layout` alone does not cover — see `header-shrink`'s own
5568
- // `Preset.transitions` below and `channel-properties.ts`'s `layout` entry for why the two
5569
- // interpolated properties (`padding-block`/`font-size`) stay on `layout` while this one moves
5570
- // to the channel that already owns every other box-shadow writer (`lift-shadow`,
5571
- // `border-glow`). Composing `header-shrink` with either of those is now a refused conflict
5572
- // instead of a silent clobber on the same property — the self-consistency bug this closes.
5573
- ["layout", "shadow"],
6339
+ // The three properties its host rule writes (navigation.css): the two it interpolates from
6340
+ // `--kui-shrink`, `padding-block` and `font-size`, each under its own name, and `box-shadow`
6341
+ // on the channel that already owns every other box-shadow writer (`lift-shadow`,
6342
+ // `border-glow`), so composing `header-shrink` with either of those is a refused conflict
6343
+ // instead of a silent clobber. The two used to sit in the shared `'layout'` bucket, which also
6344
+ // refused `header-shrink, pin-until` — a sticky header that shrinks — over nothing they share.
6345
+ ["padding", "font-size", "shadow", attributeChannel("data-kui-shrunk")],
5574
6346
  { offset: { type: "number", default: "120", cssProperty: "--kui-offset" } },
5575
6347
  deferPrepare(prepareHeaderShrink)
5576
6348
  ),
5577
6349
  navPrimitive(
5578
6350
  "header-hide-on-scroll",
5579
- [CHANNEL.translate],
6351
+ [CHANNEL.translate, attributeChannel("data-kui-hidden")],
5580
6352
  { offset: { type: "number", default: "8", cssProperty: "--kui-offset" } },
5581
6353
  deferPrepare(prepareHeaderHide)
5582
6354
  ),
5583
6355
  navPrimitive(
5584
6356
  "back-to-top-fade",
5585
- [CHANNEL.opacity, CHANNEL.translate],
6357
+ [CHANNEL.opacity, CHANNEL.translate, attributeChannel("data-kui-visible")],
5586
6358
  { offset: { type: "number", default: "400", cssProperty: "--kui-offset" } },
5587
6359
  deferPrepare(prepareBackToTop)
5588
6360
  )
@@ -6145,7 +6917,10 @@ function installSnapContainer(axis, ctx) {
6145
6917
  var SCROLL_PRIMITIVES = [
6146
6918
  scrollPrimitive({
6147
6919
  id: "pin",
6148
- channels: ["layout", "progress"],
6920
+ // `position: sticky` and its `top` (`installSticky`): the host's position is what it holds. The
6921
+ // spacer it can insert is a sibling, not a child, so the host's subtree is not claimed. And the
6922
+ // `data-kui-pinned` flag it publishes while holding.
6923
+ channels: ["position", "progress", attributeChannel("data-kui-pinned")],
6149
6924
  parameters: {
6150
6925
  ...distanceParam2,
6151
6926
  ...stickyParams
@@ -6155,7 +6930,13 @@ var SCROLL_PRIMITIVES = [
6155
6930
  }),
6156
6931
  scrollPrimitive({
6157
6932
  id: "scroll-progress",
6158
- channels: ["progress"],
6933
+ // `data-kui-step` beside `progress`: with `steps:` this publishes the same index attribute (and
6934
+ // its `--kui-step` twin) a `carousel` does, so the two on one element would overwrite each
6935
+ // other's step every frame. The claim is static, so it holds with `steps:0` too — a list can't
6936
+ // be composed or refused per param value. One claim covers both writes: every primitive that
6937
+ // writes the `--kui-step` index also writes the attribute, so a separate channel for the
6938
+ // property could never change a decision.
6939
+ channels: ["progress", attributeChannel("data-kui-step")],
6159
6940
  parameters: {
6160
6941
  ...distanceParam2,
6161
6942
  steps: { type: "number", default: "0", cssProperty: "--kui-steps" },
@@ -6191,7 +6972,8 @@ var SCROLL_PRIMITIVES = [
6191
6972
  }),
6192
6973
  scrollPrimitive({
6193
6974
  id: "media-scrub",
6194
- channels: ["media", "progress"],
6975
+ // `position`: it holds its host the way a pin does (`position: sticky` and its `top`).
6976
+ channels: ["media", "progress", "position"],
6195
6977
  parameters: {
6196
6978
  ...distanceParam2,
6197
6979
  // A scrub is a hold, so it needs the same two knobs a pin does. Declaring them here is what
@@ -6210,7 +6992,7 @@ var SCROLL_PRIMITIVES = [
6210
6992
  }),
6211
6993
  scrollPrimitive({
6212
6994
  id: "scroll-spy",
6213
- channels: ["state"],
6995
+ channels: [attributeChannel("data-kui-active")],
6214
6996
  parameters: {
6215
6997
  // `distance`: the per-section form only. `offset-top`: the container form only. Each is a
6216
6998
  // no-op — warned, not silent — in the other; see `prepareScrollSpySingle` and
@@ -6236,8 +7018,8 @@ var SCROLL_PRIMITIVES = [
6236
7018
  scrollPrimitive({
6237
7019
  id: "smooth-scroll",
6238
7020
  /*
6239
- * Its own channel, not the `'layout'` it used to share with `pin`, `stacking-cards` and
6240
- * `scroll-snap`. The channel model exists to stop two effects fighting over the same CSS
7021
+ * Its own channel, not the `'layout'` bucket it used to share with `pin`, `stacking-cards` and
7022
+ * `scroll-snap` (a bucket since split into the properties each writes). The channel model exists to stop two effects fighting over the same CSS
6241
7023
  * property, and this one writes exactly `scroll-behavior` — a property that describes how a
6242
7024
  * *user-or-script-initiated* scroll is performed, and that no other primitive touches.
6243
7025
  *
@@ -6258,7 +7040,11 @@ var SCROLL_PRIMITIVES = [
6258
7040
  }),
6259
7041
  scrollPrimitive({
6260
7042
  id: "scroll-snap",
6261
- channels: ["layout"],
7043
+ // `scroll-snap-type`, and with `target:` the container it makes of the host (`installSnapContainer`):
7044
+ // `overflow`, and `display: flex` on the x axis — `discrete`'s property. A claim is static, so
7045
+ // it holds without `target:` too. The subtree likewise: with no `target:` the host's children
7046
+ // are the snap items, each given a `scroll-snap-align`.
7047
+ channels: ["scroll-snap-type", "overflow", "discrete", SUBTREE_CHANNEL],
6262
7048
  parameters: {
6263
7049
  axis: { type: "keyword", default: "y", cssProperty: "--kui-axis", keywords: ["x", "y"] },
6264
7050
  strictness: {
@@ -6600,7 +7386,7 @@ function findBars(el) {
6600
7386
  return children.filter((child) => child.textContent.trim() === "");
6601
7387
  }
6602
7388
  function withIconParts(inner) {
6603
- return (el, params, ctx) => {
7389
+ return inheritTimingContract(inner, (el, params, ctx) => {
6604
7390
  if (!el.querySelector(".kui-bar")) {
6605
7391
  const bars = findBars(el);
6606
7392
  if (bars.length > 0) {
@@ -6619,7 +7405,7 @@ function withIconParts(inner) {
6619
7405
  }
6620
7406
  }
6621
7407
  return inner(el, params, ctx);
6622
- };
7408
+ });
6623
7409
  }
6624
7410
 
6625
7411
  // src/effects/svg/index.ts
@@ -6658,18 +7444,18 @@ function prepareMorph(el, params, ctx) {
6658
7444
  };
6659
7445
  let current = 0;
6660
7446
  const enter = () => drive(1);
6661
- const leave = () => drive(0);
7447
+ const leave2 = () => drive(0);
6662
7448
  el.addEventListener("pointerenter", enter, { passive: true });
6663
7449
  el.addEventListener("focusin", enter, { passive: true });
6664
- el.addEventListener("pointerleave", leave, { passive: true });
6665
- el.addEventListener("focusout", leave, { passive: true });
7450
+ el.addEventListener("pointerleave", leave2, { passive: true });
7451
+ el.addEventListener("focusout", leave2, { passive: true });
6666
7452
  return () => {
6667
7453
  cancelled = true;
6668
7454
  cancelAnimationFrame(frame);
6669
7455
  el.removeEventListener("pointerenter", enter);
6670
7456
  el.removeEventListener("focusin", enter);
6671
- el.removeEventListener("pointerleave", leave);
6672
- el.removeEventListener("focusout", leave);
7457
+ el.removeEventListener("pointerleave", leave2);
7458
+ el.removeEventListener("focusout", leave2);
6673
7459
  path2.setAttribute("d", startPath);
6674
7460
  };
6675
7461
  }
@@ -6690,7 +7476,16 @@ var LOGO_BUILD_PRIMITIVE = cssPrimitive("logo-assemble", [
6690
7476
  var ICON_TOGGLE_PRIMITIVE = {
6691
7477
  id: "icon-toggle",
6692
7478
  renderer: "javascript",
6693
- channels: [CHANNEL.translate, CHANNEL.rotate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.clip],
7479
+ // The subtree too: with no `.kui-bar` markup the host's own children are the bars
7480
+ // (`icon-parts.ts` stamps them), so a deck or widget beside it would have its children moved.
7481
+ channels: [
7482
+ CHANNEL.translate,
7483
+ CHANNEL.rotate,
7484
+ CHANNEL.scale,
7485
+ CHANNEL.opacity,
7486
+ CHANNEL.clip,
7487
+ SUBTREE_CHANNEL
7488
+ ],
6694
7489
  parameters: {
6695
7490
  duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
6696
7491
  // The state flip is the start moment: `aria-expanded` goes true and the bars begin to move.
@@ -6888,8 +7683,10 @@ var CARD_TOGGLE_PRIMITIVE = {
6888
7683
  // `'discrete'` alongside `rotate`: the unconditional `[data-kui-fx~='flip-card']` rule pins
6889
7684
  // `display: grid` so the two faces stack in one cell instead of flowing as normal block
6890
7685
  // siblings — unrelated to `catalog/discrete.ts`'s show/hide use of the same physical property,
6891
- // but `display` is tracked as one channel regardless of the value written into it.
6892
- channels: [CHANNEL.rotate, "discrete"],
7686
+ // but `display` is tracked as one channel regardless of the value written into it. The subtree
7687
+ // because the host's children are the two faces (`flip-parts.ts` marks them, and injects the
7688
+ // toggle button when none is authored).
7689
+ channels: [CHANNEL.rotate, "discrete", SUBTREE_CHANNEL],
6893
7690
  parameters: {
6894
7691
  duration: { type: "time", default: "700ms", cssProperty: "--kui-duration" },
6895
7692
  // The turn has a start moment — the click, or the pointer arriving — so a delay before it is
@@ -7027,8 +7824,8 @@ function projectRelease(position, velocityPxPerSec, travelPx) {
7027
7824
  function snapTo(projected) {
7028
7825
  return Math.round(projected) + 0;
7029
7826
  }
7030
- function wrapPlace(place, total) {
7031
- return total > 0 ? (place % total + total) % total : 0;
7827
+ function wrapPlace(place2, total) {
7828
+ return total > 0 ? (place2 % total + total) % total : 0;
7032
7829
  }
7033
7830
  var KEY_DELTAS = {
7034
7831
  ArrowRight: 1,
@@ -7040,8 +7837,26 @@ var KEY_DELTAS = {
7040
7837
  Home: null,
7041
7838
  End: null
7042
7839
  };
7043
- function createRingDrag(request) {
7044
- const { el, ctx, enabled, travelPx, total, positionOf, moveTo, setDragging } = request;
7840
+ function holdSelection(doc) {
7841
+ let ledger;
7842
+ const release2 = () => {
7843
+ ledger?.restore();
7844
+ ledger = void 0;
7845
+ };
7846
+ return {
7847
+ hold() {
7848
+ release2();
7849
+ doc.getSelection()?.removeAllRanges();
7850
+ ledger = createStyleLedger(doc.documentElement);
7851
+ ledger.claim("user-select");
7852
+ ledger.claim("-webkit-user-select");
7853
+ ledger.set("user-select", "none");
7854
+ ledger.set("-webkit-user-select", "none");
7855
+ },
7856
+ release: release2
7857
+ };
7858
+ }
7859
+ function guardClicks(el, enabled) {
7045
7860
  let suppressClick = false;
7046
7861
  const onClickCapture = (event) => {
7047
7862
  if (!suppressClick) return;
@@ -7049,19 +7864,49 @@ function createRingDrag(request) {
7049
7864
  event.stopPropagation();
7050
7865
  event.preventDefault();
7051
7866
  };
7867
+ const onPressCapture = () => {
7868
+ suppressClick = false;
7869
+ };
7870
+ const onNativeDrag = (event) => {
7871
+ event.preventDefault();
7872
+ };
7873
+ el.addEventListener("click", onClickCapture, true);
7874
+ el.addEventListener("pointerdown", onPressCapture, true);
7875
+ if (enabled) el.addEventListener("dragstart", onNativeDrag);
7876
+ return {
7877
+ arm() {
7878
+ suppressClick = true;
7879
+ },
7880
+ release() {
7881
+ el.removeEventListener("click", onClickCapture, true);
7882
+ el.removeEventListener("pointerdown", onPressCapture, true);
7883
+ el.removeEventListener("dragstart", onNativeDrag);
7884
+ }
7885
+ };
7886
+ }
7887
+ function createRingDrag(request) {
7888
+ const { el, ctx, enabled, travelPx, total, positionOf, moveTo, setDragging } = request;
7889
+ const clicks = guardClicks(el, enabled);
7890
+ let dragFrom = 0;
7891
+ let sign = 1;
7892
+ const selection = holdSelection(el.ownerDocument);
7052
7893
  const stopRecognising = enabled ? recognise(
7053
7894
  el,
7054
7895
  {
7055
7896
  onStart() {
7897
+ dragFrom = positionOf();
7898
+ sign = request.travelSign();
7899
+ selection.hold();
7056
7900
  setDragging(true);
7057
7901
  },
7058
7902
  onMove(vector) {
7059
- moveTo(positionOf() - vector.dx / travelPx);
7903
+ moveTo(dragFrom - sign * vector.dx / travelPx);
7060
7904
  },
7061
7905
  onEnd(vector) {
7906
+ selection.release();
7062
7907
  setDragging(false);
7063
- suppressClick = true;
7064
- const settled = snapTo(projectRelease(positionOf(), vector.vx, travelPx));
7908
+ clicks.arm();
7909
+ const settled = snapTo(projectRelease(positionOf(), sign * vector.vx, travelPx));
7065
7910
  moveTo(wrapPlace(settled, total()));
7066
7911
  }
7067
7912
  },
@@ -7070,7 +7915,15 @@ function createRingDrag(request) {
7070
7915
  // threshold than `recognise`'s own default of 4px is what buys that — a ring occupies a
7071
7916
  // large slab of a phone screen, and a scroll that begins with a two-pixel horizontal wobble
7072
7917
  // should not capture the pointer away from the page.
7073
- { axis: "x", threshold: DRAG_THRESHOLD_PX }
7918
+ //
7919
+ // Capture only once the press becomes a drag (`'drag'`), never on `pointerdown`. Held from
7920
+ // the press, capture retargets the `click` that ends a plain tap at this host instead of the
7921
+ // card under the pointer — found by the `lightbox:true` browser tier, where a real click on
7922
+ // a card arrived as a click on the ring and opened nothing. The same retargeting made every
7923
+ // link and button inside a card dead in a real browser while jsdom (which has no capture)
7924
+ // stayed green. The host never moves under the pointer — its cards do — so the press has
7925
+ // nothing to stay captured for until it is a drag, and a drag's click is swallowed above.
7926
+ { axis: "x", threshold: DRAG_THRESHOLD_PX, capturePointer: "drag" }
7074
7927
  ) : () => {
7075
7928
  };
7076
7929
  if (enabled) ctx.style.set("touch-action", "pan-y");
@@ -7089,216 +7942,213 @@ function createRingDrag(request) {
7089
7942
  const grantedFocus = el.getAttribute("tabindex") === null;
7090
7943
  if (grantedFocus) el.setAttribute("tabindex", "0");
7091
7944
  el.addEventListener("keydown", onKeyDown);
7092
- el.addEventListener("click", onClickCapture, true);
7093
7945
  return () => {
7094
7946
  stopRecognising();
7947
+ selection.release();
7095
7948
  el.removeEventListener("keydown", onKeyDown);
7096
- el.removeEventListener("click", onClickCapture, true);
7949
+ clicks.release();
7097
7950
  if (grantedFocus) el.removeAttribute("tabindex");
7098
7951
  };
7099
7952
  }
7100
7953
 
7101
- // src/effects/carousel/index.ts
7954
+ // src/effects/carousel/deck.ts
7102
7955
  var DRAGGING_ATTR = "data-kui-ring-dragging";
7103
- var FACE_ATTR = "data-kui-ring-face";
7104
- var QUARTER_TURN_DEG = 90;
7105
- var FLATTENING = [
7106
- ["overflow", (value) => value !== "" && value !== "visible"],
7107
- ["clip-path", (value) => value !== "" && value !== "none"],
7108
- ["opacity", (value) => value !== "" && Number(value) < 1],
7109
- ["filter", (value) => value !== "" && value !== "none"],
7110
- ["backdrop-filter", (value) => value !== "" && value !== "none"]
7956
+ var SPATIAL_DECK_CHANNELS = [
7957
+ SUBTREE_CHANNEL,
7958
+ attributeChannel("data-kui-step"),
7959
+ attributeChannel(DRAGGING_ATTR),
7960
+ attributeChannel("tabindex")
7111
7961
  ];
7112
- function warnFlatteningAncestor(el, ctx) {
7113
- const body = el.ownerDocument.body;
7114
- let node = el.parentElement;
7115
- while (node && node !== body) {
7116
- const found = flatteningDeclaration(node, ctx);
7117
- if (found) {
7118
- ctx.warn(
7119
- `carousel: an ancestor <${node.localName}> has ${found.property}: ${found.value}, which flattens transform-style: preserve-3d \u2014 the ring will render as flat overlapping cards. Move the ring out of it, or drop that property on the ancestor.`
7120
- );
7121
- return;
7122
- }
7123
- node = node.parentElement;
7124
- }
7125
- }
7126
- function flatteningDeclaration(node, ctx) {
7127
- const style = ctx.win.getComputedStyle?.(node);
7128
- if (!style) return null;
7129
- for (const [property2, flattens] of FLATTENING) {
7130
- const value = style.getPropertyValue(property2);
7131
- if (flattens(value)) return { property: property2, value };
7132
- }
7133
- return null;
7134
- }
7135
- function ledgersFor(store, node) {
7136
- let attributes = store.attributes.get(node);
7137
- if (!attributes) {
7138
- attributes = createAttributeLedger(node);
7139
- store.attributes.set(node, attributes);
7140
- }
7141
- let styles = store.styles.get(node);
7142
- if (!styles) {
7143
- styles = createStyleLedger(node);
7144
- store.styles.set(node, styles);
7145
- }
7146
- return { attributes, styles };
7962
+ var SPINNING_ATTR = "data-kui-ring-spinning";
7963
+ var FACE_ATTR = "data-kui-ring-face";
7964
+ var WRAP_ATTR = "data-kui-ring-wrap";
7965
+ function commitStyle(win, node) {
7966
+ return win.getComputedStyle?.(node).transform;
7147
7967
  }
7148
- function faceAt(angleDeg) {
7149
- return Math.abs(normaliseDegrees(angleDeg)) >= QUARTER_TURN_DEG ? "back" : "front";
7968
+ function travelSign(liveX, nextX, previousX) {
7969
+ if (nextX !== null) return nextX < liveX ? -1 : 1;
7970
+ if (previousX !== null) return previousX > liveX ? -1 : 1;
7971
+ return 1;
7150
7972
  }
7151
- function normaliseDegrees(angleDeg) {
7152
- const wrapped = (angleDeg % 360 + 360) % 360;
7153
- return wrapped > 180 ? wrapped - 360 : wrapped;
7973
+ function offsetWrapped(previous, next, count) {
7974
+ return Math.abs(next - previous) > count / 2;
7154
7975
  }
7155
7976
  function snapPosition(position, total) {
7156
7977
  if (total <= 0) return { step: 0, drift: 0 };
7157
7978
  const nearest = Math.round(position);
7158
7979
  return { step: (nearest % total + total) % total, drift: position - nearest };
7159
7980
  }
7160
- var RING_PARAMETERS = {
7981
+ var DECK_PARAMETERS = {
7161
7982
  duration: { type: "time", default: "620ms", cssProperty: "--kui-duration" },
7162
7983
  ...TRIGGER_DELAY_PARAM,
7163
7984
  ease: { type: "easing", default: "cubic-bezier(0.22, 1, 0.36, 1)", cssProperty: "--kui-ease" },
7985
+ /** Whether a pointer can move the deck by hand. */
7986
+ grab: { type: "keyword", default: "true", cssProperty: "--kui-grab", keywords: ["true", "false"] },
7164
7987
  /*
7165
- * A real CSS `<angle>`, not a normalised scalar.
7166
- *
7167
- * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
7168
- * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
7169
- * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
7170
- * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
7171
- * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
7172
- * `clamp()` an author can see in devtools rather than a hidden rescale.
7988
+ * How far the pointer travels, in pixels, to move the deck one place.
7173
7989
  *
7174
- * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
7175
- * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
7176
- * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
7177
- * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
7990
+ * A pixels-per-step mapping rather than pixels-per-degree, because a step is the unit everything
7991
+ * else here is in — the index, the offset, the snap — and a degree is not: the same drag would
7992
+ * move a six-item ring one place and a sixty-item ring ten, purely because the spacing changed.
7178
7993
  */
7179
- tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
7994
+ travel: { type: "number", default: "220", cssProperty: "--kui-travel", finite: true, minimum: 1 },
7995
+ /** Which elements are the slides. Unset means this element's own children. */
7996
+ target: { type: "text", default: "", cssProperty: "--kui-target" },
7997
+ /** Optional controls, resolved exactly as `step-progress` resolves its own. */
7998
+ next: { type: "text", default: "", cssProperty: "--kui-next" },
7999
+ prev: { type: "text", default: "", cssProperty: "--kui-prev" },
8000
+ jump: { type: "text", default: "", cssProperty: "--kui-jump" },
8001
+ /** A control that pauses and resumes `spin:`/`autoplay:`. See `PAUSE_PARAM` (`auto-motion.ts`). */
8002
+ pause: PAUSE_PARAM,
8003
+ /** `hover:none`: a resting pointer no longer pauses `spin:`/`autoplay:`. See `HOVER_PARAM`. */
8004
+ hover: HOVER_PARAM,
7180
8005
  /*
7181
- * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
8006
+ * Continuous rotation: how long one full cycle of the deck takes. `0s` is off.
7182
8007
  *
7183
- * This is the parameter the concave name exists around. From the centre of a *full* ring most of
7184
- * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
7185
- * two thirds of the content — the inside name defaults to a slice for that reason.
8008
+ * A period rather than a speed because a period is what a designer can picture ("one turn every
8009
+ * forty seconds") and it is a `time`, which the attribute grammar already reads, clamps and
8010
+ * documents. A full cycle is every slide passing once — one revolution of a full ring — so adding a
8011
+ * card does not change how fast the ring turns. **Negative runs the other way**: `spin:-40s`. A
8012
+ * signed value rather than a `direction:` keyword so one row can say everything about its motion
8013
+ * in one token, which is what three rows at three speeds on one page want.
7186
8014
  */
7187
- arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
8015
+ spin: { type: "time", default: "0s", cssProperty: "--kui-spin" },
7188
8016
  /*
7189
- * The ring's radius, or unset for one derived from the content.
7190
- *
7191
- * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
7192
- * `readParams` fills every declared default in unconditionally, so an empty one is the only way
7193
- * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
7194
- * the circumradius of a regular polygon with this many sides and this much side length —
7195
- * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
7196
- * Every consumer solving that by hand was the alternative, and they would all have solved it
7197
- * differently.
7198
- *
7199
- * The width half of that has to come from JavaScript: CSS can compute with a measurement but
7200
- * cannot take one. See `measureItemWidth`.
8017
+ * Stepped motion: how long the deck rests on each slide. When both are set `spin:` wins, because a
8018
+ * deck cannot be both gliding and resting. See `AUTOPLAY_PARAM` (`auto-motion.ts`).
7201
8019
  */
7202
- radius: { type: "length", default: "", cssProperty: "--kui-radius" },
7203
- /** Breathing room between neighbours, spent by the derived radius above. */
7204
- gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
7205
- /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
7206
- perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
8020
+ autoplay: AUTOPLAY_PARAM,
7207
8021
  /*
7208
- * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
7209
- *
7210
- * `radial` is the ring's natural state and needs nothing: a slot placed by
7211
- * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
7212
- * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
7213
- * ring read as the far side.
7214
- *
7215
- * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
7216
- * and the billboard have to stay on separate boxes or every later change to one silently
7217
- * multiplies into the other — so text stays readable at every position, which is what a raised
7218
- * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
8022
+ * `lightbox:true`: a click on a card opens every card of this deck in the shared lightbox gallery,
8023
+ * at the card clicked. A drag still only moves the deck — its click is swallowed before it can
8024
+ * reach a card (`drag.ts`). The gallery itself lives in the showcase module; see
8025
+ * `core/deck-viewer.ts` for how a deck reaches it without importing it.
7219
8026
  */
7220
- facing: {
7221
- type: "keyword",
7222
- default: "radial",
7223
- cssProperty: "--kui-facing",
7224
- keywords: ["radial", "camera"]
7225
- },
7226
- /** Whether a pointer can spin the ring by hand. */
7227
- grab: { type: "keyword", default: "true", cssProperty: "--kui-grab", keywords: ["true", "false"] },
7228
- /*
7229
- * How far the pointer travels, in pixels, to move the ring one place.
7230
- *
7231
- * A pixels-per-step mapping rather than pixels-per-degree, because a step is the unit everything
7232
- * else here is in — the index, the offset, the snap — and a degree is not: the same drag would
7233
- * move a six-item ring one place and a sixty-item ring ten, purely because the spacing changed.
7234
- */
7235
- travel: { type: "number", default: "220", cssProperty: "--kui-travel", finite: true, minimum: 1 },
7236
- /** Which elements sit on the ring. Unset means this element's own children. */
7237
- target: { type: "text", default: "", cssProperty: "--kui-target" },
7238
- /** Optional controls, resolved exactly as `step-progress` resolves its own. */
7239
- next: { type: "text", default: "", cssProperty: "--kui-next" },
7240
- prev: { type: "text", default: "", cssProperty: "--kui-prev" },
7241
- jump: { type: "text", default: "", cssProperty: "--kui-jump" },
8027
+ lightbox: DECK_LIGHTBOX_PARAM,
7242
8028
  scope: SCOPE_PARAM
7243
8029
  };
7244
- function measureItemWidth(styles, slots) {
7245
- for (const slot of slots) {
7246
- const width = slot.offsetWidth;
7247
- if (width > 0) styles.set("--kui-item-width", `${width}px`);
7248
- return;
7249
- }
7250
- }
7251
- function prepareSpatialRing(el, params, ctx) {
7252
- warnFlatteningAncestor(el, ctx);
7253
- mirrorTimingToCss("spatial-ring", ALL_TIMING_TOKENS, params, ctx);
7254
- const selector = resolveTarget(params.text("target"), ctx, "carousel");
8030
+ function ledgerFor(store, node) {
8031
+ let ledger = store.attributes.get(node);
8032
+ if (!ledger) {
8033
+ ledger = createAttributeLedger(node);
8034
+ store.attributes.set(node, ledger);
8035
+ }
8036
+ return ledger;
8037
+ }
8038
+ function measureItem(styles, slots) {
8039
+ const first = slots[0];
8040
+ if (!first) return;
8041
+ if (first.offsetWidth > 0) styles.set("--kui-item-width", `${first.offsetWidth}px`);
8042
+ if (first.offsetHeight > 0) styles.set("--kui-item-height", `${first.offsetHeight}px`);
8043
+ }
8044
+ function prepareSpatialDeck(el, params, ctx, layout) {
8045
+ const { label } = layout;
8046
+ mirrorTimingToCss(layout.id, ALL_TIMING_TOKENS, params, ctx);
8047
+ const selector = resolveTarget(params.text("target"), ctx, label);
7255
8048
  const scope = scopeParam(params, "self");
7256
8049
  const resolveSlots = () => selector ? queryScoped(el, ctx, selector, scope) : el.children;
7257
- const marker = createStepMarker(resolveSlots, (message) => ctx.warn(`carousel ${message}`));
8050
+ const marker = createStepMarker(resolveSlots, (message) => ctx.warn(`${label} ${message}`));
7258
8051
  const total = () => countSteps(params, resolveSlots);
7259
8052
  const hostAttributes = createAttributeLedger(el);
7260
8053
  const hostStyles = createStyleLedger(el);
7261
- hostAttributes.set("data-kui-ring-facing", params.text("facing", "radial"));
7262
- const slots = { attributes: /* @__PURE__ */ new Map(), styles: /* @__PURE__ */ new Map() };
7263
- const derivesRadius = params.text("radius") === "";
8054
+ for (const [name, value] of Object.entries(layout.hostAttributes ?? {})) hostAttributes.set(name, value);
8055
+ const slots = { attributes: /* @__PURE__ */ new Map() };
8056
+ const attachment = layout.attach?.(hostStyles);
7264
8057
  let position = 0;
7265
- const arcDeg = degreesOf(params.text("arc", "360deg"), 360);
8058
+ let rendered = { step: -1, count: 1, nodes: [] };
8059
+ const lastOffsets = /* @__PURE__ */ new WeakMap();
8060
+ const settleWrapped = (nodes, count) => {
8061
+ for (const node of nodes) {
8062
+ const raw = Number(node.getAttribute("data-kui-step-offset"));
8063
+ const offset = layout.placeOf ? layout.placeOf(raw, count) : raw;
8064
+ const previous = lastOffsets.get(node);
8065
+ lastOffsets.set(node, offset);
8066
+ if (previous === void 0 || !offsetWrapped(previous, offset, count)) continue;
8067
+ const flag = createAttributeLedger(node);
8068
+ flag.set(WRAP_ATTR, "true");
8069
+ commitStyle(ctx.win, node);
8070
+ flag.restore();
8071
+ }
8072
+ };
8073
+ const markFaces = (nodes, count, drift2) => {
8074
+ for (const node of nodes) {
8075
+ const offset = Number(node.getAttribute("data-kui-step-offset"));
8076
+ const face = layout.faceOf(offset - drift2, offset, count);
8077
+ if (node.getAttribute(FACE_ATTR) !== face) ledgerFor(slots, node).set(FACE_ATTR, face);
8078
+ }
8079
+ };
8080
+ const publishPosition = (step, drift2) => {
8081
+ hostStyles.set("--kui-step-position", (step + drift2).toFixed(4));
8082
+ };
7266
8083
  const render = () => {
7267
8084
  const count = total();
7268
8085
  const { step, drift: drift2 } = snapPosition(position, count);
7269
8086
  hostAttributes.set("data-kui-step", String(step));
7270
8087
  hostStyles.set("--kui-step", String(step));
7271
- hostStyles.set("--kui-step-position", (step + drift2).toFixed(4));
8088
+ hostStyles.set("--kui-item-count", String(count));
8089
+ publishPosition(step, drift2);
7272
8090
  marker.mark(step);
7273
- const slotNodes = [...resolveSlots()];
7274
- if (derivesRadius) measureItemWidth(hostStyles, slotNodes);
7275
- const spacing = arcDeg / count;
7276
- for (const node of slotNodes) {
7277
- const offset = Number(node.getAttribute("data-kui-step-offset"));
7278
- const angle = (offset - drift2) * spacing;
7279
- ledgersFor(slots, node).attributes.set(FACE_ATTR, faceAt(angle));
8091
+ const nodes = [...resolveSlots()];
8092
+ if (layout.measure) measureItem(hostStyles, nodes);
8093
+ markFaces(nodes, count, drift2);
8094
+ settleWrapped(nodes, count);
8095
+ attachment?.render(nodes, count);
8096
+ rendered = { step, count, nodes };
8097
+ };
8098
+ const renderFrame = () => {
8099
+ const { step, drift: drift2 } = snapPosition(position, rendered.count);
8100
+ if (step !== rendered.step) {
8101
+ render();
8102
+ return;
7280
8103
  }
8104
+ publishPosition(step, drift2);
8105
+ markFaces(rendered.nodes, rendered.count, drift2);
7281
8106
  };
8107
+ const { motion, pause, release: releaseMotion } = createDeckMotion({
8108
+ el,
8109
+ ctx,
8110
+ params,
8111
+ label,
8112
+ settleMs: effectDurationMs(params, 620),
8113
+ step(direction) {
8114
+ const count = total();
8115
+ const { step } = snapPosition(position, count);
8116
+ position = direction > 0 ? nextStep(step, count) : prevStep(step, count);
8117
+ render();
8118
+ },
8119
+ spin: {
8120
+ advance(cycles) {
8121
+ position = wrapPlace(position + cycles * rendered.count, rendered.count);
8122
+ renderFrame();
8123
+ },
8124
+ setSpinning: (spinning) => hostAttributes.set(SPINNING_ATTR, String(spinning))
8125
+ }
8126
+ });
7282
8127
  const goTo = (next) => {
8128
+ motion.interrupt();
7283
8129
  position = next;
7284
8130
  render();
7285
8131
  };
7286
- const groups = [];
7287
- const bindControl = (param, run) => {
7288
- const control = resolveTarget(params.text(param), ctx, `carousel ${param}`);
7289
- if (!control) return false;
7290
- if (queryScoped(el, ctx, control, scope).length === 0) {
7291
- ctx.warn(`carousel ${param} "${control}" matched nothing`);
8132
+ const request = { el, ctx, params, label };
8133
+ const groups = [
8134
+ bindControl(request, "next", () => goTo(nextStep(snapPosition(position, total()).step, total()))),
8135
+ bindControl(request, "prev", () => goTo(prevStep(snapPosition(position, total()).step, total()))),
8136
+ bindControl(request, "jump", (_node, at) => {
8137
+ if (at >= 0) goTo(at);
8138
+ }),
8139
+ pause
8140
+ ].filter((group) => group !== null);
8141
+ const releaseControls = delegateControls({ el, ctx, groups });
8142
+ const screenTravel = () => {
8143
+ const centres = /* @__PURE__ */ new Map();
8144
+ for (const node of rendered.nodes) {
8145
+ const offset = Number(node.getAttribute("data-kui-step-offset"));
8146
+ if (Math.abs(offset) > 1) continue;
8147
+ const box = node.getBoundingClientRect();
8148
+ centres.set(offset, box.left + box.width / 2);
7292
8149
  }
7293
- groups.push({ selector: control, run });
7294
- return true;
8150
+ return travelSign(centres.get(0) ?? 0, centres.get(1) ?? null, centres.get(-1) ?? null);
7295
8151
  };
7296
- bindControl("next", () => goTo(nextStep(snapPosition(position, total()).step, total())));
7297
- bindControl("prev", () => goTo(prevStep(snapPosition(position, total()).step, total())));
7298
- bindControl("jump", (_node, at) => {
7299
- if (at >= 0) goTo(at);
7300
- });
7301
- const releaseControls = delegateControls({ el, ctx, scope, groups });
7302
8152
  const releaseDrag = createRingDrag({
7303
8153
  el,
7304
8154
  ctx,
@@ -7306,22 +8156,405 @@ function prepareSpatialRing(el, params, ctx) {
7306
8156
  travelPx: params.num("travel", 220),
7307
8157
  total,
7308
8158
  positionOf: () => position,
8159
+ travelSign: screenTravel,
7309
8160
  moveTo: goTo,
7310
- setDragging: (dragging) => hostAttributes.set(DRAGGING_ATTR, String(dragging))
8161
+ setDragging: (dragging) => {
8162
+ hostAttributes.set(DRAGGING_ATTR, String(dragging));
8163
+ motion.hold(dragging);
8164
+ }
7311
8165
  });
8166
+ const releaseViewer = deckLightbox(
8167
+ params.is("lightbox"),
8168
+ { host: el, cards: resolveSlots, doc: ctx.doc, reducedMotion: ctx.reducedMotion },
8169
+ (message) => ctx.warn(`${label} ${message}`)
8170
+ );
7312
8171
  render();
7313
8172
  return () => {
8173
+ releaseViewer();
8174
+ releaseMotion();
7314
8175
  releaseDrag();
7315
8176
  releaseControls();
7316
8177
  marker.restore();
7317
8178
  for (const ledger of slots.attributes.values()) ledger.restore();
7318
- for (const ledger of slots.styles.values()) ledger.restore();
7319
8179
  slots.attributes.clear();
7320
- slots.styles.clear();
8180
+ attachment?.release();
7321
8181
  hostAttributes.restore();
7322
8182
  hostStyles.restore();
7323
8183
  };
7324
8184
  }
8185
+
8186
+ // src/effects/carousel/fit.ts
8187
+ var RING_FIT_PROPERTY = "--kui-ring-fit";
8188
+ var SAMPLE_DEG = 0.25;
8189
+ var SEARCH_CEILING_PX = 1e5;
8190
+ var SEARCH_STEPS = 34;
8191
+ var RAD = Math.PI / 180;
8192
+ function reachableAngles(arcDeg, count) {
8193
+ if (Math.abs(arcDeg) >= 360) return { fromDeg: -180, toDeg: 180 };
8194
+ const places = Math.max(1, count);
8195
+ const spacing = arcDeg / places;
8196
+ const high = Math.floor(places / 2);
8197
+ const low = high - places + 1;
8198
+ const ends = [(low - 0.5) * spacing, (high + 0.5) * spacing];
8199
+ return { fromDeg: Math.min(...ends), toDeg: Math.max(...ends) };
8200
+ }
8201
+ function place(point, { sinA, cosA, sinT, cosT }) {
8202
+ const [x, y, z] = point;
8203
+ const x1 = x * cosA + z * sinA;
8204
+ const z1 = -x * sinA + z * cosA;
8205
+ return [x1, y * cosT - z1 * sinT, y * sinT + z1 * cosT];
8206
+ }
8207
+ function cardFits(g, r, rotation) {
8208
+ const z0 = (g.inside ? -1 : 1) * r;
8209
+ const centre = place([0, 0, z0], rotation);
8210
+ const hw = g.width / 2;
8211
+ const hh = g.height / 2;
8212
+ for (const [cx, cy] of [[-hw, -hh], [hw, -hh], [-hw, hh], [hw, hh]]) {
8213
+ const [x, , z] = g.facing === "camera" ? [centre[0] + cx, centre[1] + cy, centre[2]] : place([cx, cy, z0], rotation);
8214
+ if (z >= g.perspective - 1) return false;
8215
+ const scale = Number.isFinite(g.perspective) ? g.perspective / (g.perspective - z) : 1;
8216
+ const screen = (g.originOffset + x) * scale;
8217
+ if (screen < g.minX || screen > g.maxX) return false;
8218
+ }
8219
+ return true;
8220
+ }
8221
+ function fitsAt(g, r, angles) {
8222
+ const sinT = Math.sin(g.tiltDeg * RAD);
8223
+ const cosT = Math.cos(g.tiltDeg * RAD);
8224
+ return angles.every((angle) => cardFits(g, r, { sinA: Math.sin(angle * RAD), cosA: Math.cos(angle * RAD), sinT, cosT }));
8225
+ }
8226
+ function sampleAngles(g) {
8227
+ const from = g.inside ? Math.max(g.fromDeg, -90) : g.fromDeg;
8228
+ const to = g.inside ? Math.min(g.toDeg, 90) : g.toDeg;
8229
+ const steps = Math.max(1, Math.ceil((to - from) / SAMPLE_DEG));
8230
+ return Array.from({ length: steps + 1 }, (_, i) => from + (to - from) * i / steps);
8231
+ }
8232
+ function solveRingFit(g) {
8233
+ const angles = sampleAngles(g);
8234
+ if (!fitsAt(g, 0, angles)) return 0;
8235
+ if (fitsAt(g, SEARCH_CEILING_PX, angles)) return Infinity;
8236
+ let low = 0;
8237
+ let high = SEARCH_CEILING_PX;
8238
+ for (let i = 0; i < SEARCH_STEPS; i++) {
8239
+ const mid = (low + high) / 2;
8240
+ if (fitsAt(g, mid, angles)) low = mid;
8241
+ else high = mid;
8242
+ }
8243
+ return Math.floor(low * 100) / 100;
8244
+ }
8245
+ function fitBounds(el, win) {
8246
+ const doc = el.ownerDocument;
8247
+ for (let node = el.parentElement; node && node !== doc.body && node !== doc.documentElement; node = node.parentElement) {
8248
+ const overflow = win.getComputedStyle(node).overflowX;
8249
+ if (overflow === "clip" || overflow === "hidden") return null;
8250
+ if (overflow === "auto" || overflow === "scroll") {
8251
+ const left2 = node.getBoundingClientRect().left + node.clientLeft;
8252
+ return { left: left2, right: left2 + node.clientWidth };
8253
+ }
8254
+ }
8255
+ const left = -win.scrollX;
8256
+ return { left, right: left + doc.documentElement.clientWidth };
8257
+ }
8258
+ function pixels(value, fallback) {
8259
+ const parsed = Number.parseFloat(value);
8260
+ return Number.isFinite(parsed) ? parsed : fallback;
8261
+ }
8262
+ function createRingFit(options) {
8263
+ const { el, ctx, styles } = options;
8264
+ if (typeof ctx.win.getComputedStyle !== "function" || typeof ctx.win.addEventListener !== "function") {
8265
+ return { render() {
8266
+ }, release() {
8267
+ } };
8268
+ }
8269
+ const tiltDeg = Math.min(80, Math.max(-80, -options.tiltParamDeg));
8270
+ let card;
8271
+ let count = 1;
8272
+ const solve = () => {
8273
+ const bounds = fitBounds(el, ctx.win);
8274
+ if (!bounds || !card || card.offsetWidth === 0) {
8275
+ styles.set(RING_FIT_PROPERTY, "");
8276
+ return;
8277
+ }
8278
+ const style = ctx.win.getComputedStyle(el);
8279
+ const host = el.getBoundingClientRect();
8280
+ const originX = host.left + pixels(style.perspectiveOrigin, host.width / 2);
8281
+ const fit = solveRingFit({
8282
+ width: card.offsetWidth,
8283
+ height: card.offsetHeight,
8284
+ perspective: pixels(style.perspective, Infinity),
8285
+ tiltDeg,
8286
+ // The stylesheet's own selector for the concave placement, read rather than threaded through
8287
+ // as a parameter: the name is what `carousel.css` branches on, so it is what this must too.
8288
+ inside: el.matches("[data-kui-fx~='carousel-3d-inside']"),
8289
+ // `facing:camera` counter-rotates the slot's element *children* (`carousel.css`); a slot with
8290
+ // none — bare text — is drawn at its radial angle, and is fitted as the radial card it is.
8291
+ facing: card.firstElementChild ? options.facing : "radial",
8292
+ ...reachableAngles(options.arcDeg, count),
8293
+ originOffset: host.left + host.width / 2 - originX,
8294
+ minX: bounds.left - originX,
8295
+ maxX: bounds.right - originX
8296
+ });
8297
+ styles.set(RING_FIT_PROPERTY, Number.isFinite(fit) ? `${fit}px` : "");
8298
+ };
8299
+ const frame = frameScheduler(ctx.win, solve);
8300
+ const watch = watchElementSize(ctx.win, frame.request);
8301
+ watch.observe(el);
8302
+ watch.observe(el.ownerDocument.documentElement);
8303
+ return {
8304
+ render(nodes, total) {
8305
+ const first = nodes[0];
8306
+ if (first === card && total === count) return;
8307
+ card = first;
8308
+ count = total;
8309
+ solve();
8310
+ },
8311
+ release() {
8312
+ watch.disconnect();
8313
+ frame.cancel();
8314
+ }
8315
+ };
8316
+ }
8317
+
8318
+ // src/effects/carousel/stack.ts
8319
+ function stackFace(offset, drift2, count, depth) {
8320
+ const ahead = stackPlace(offset, count) - drift2;
8321
+ return ahead > -0.5 && ahead < depth + 1 ? "front" : "back";
8322
+ }
8323
+ function stackPlace(offset, count) {
8324
+ return wrapPlace(offset + 1, count) - 1;
8325
+ }
8326
+ var STACK_PARAMETERS = {
8327
+ ...DECK_PARAMETERS,
8328
+ /*
8329
+ * How far each card behind the front one steps sideways. Positive is to the right, negative to
8330
+ * the left. A length rather than a percentage because the host reserves `depth × shift` of room
8331
+ * on that side (cards moved by a transform take no layout space) and a padding percentage would
8332
+ * resolve against a different box than a translate percentage does.
8333
+ */
8334
+ shift: { type: "length", default: "48px", cssProperty: "--kui-stack-shift" },
8335
+ /** How far each card behind steps upward. Negative recedes downward instead. */
8336
+ rise: { type: "length", default: "36px", cssProperty: "--kui-stack-rise" },
8337
+ /** Scale lost per place behind the front: `0.08` makes the third card back 76% size. */
8338
+ shrink: { type: "number", default: "0.08", cssProperty: "--kui-stack-shrink", finite: true, minimum: 0, maximum: 0.3 },
8339
+ /** Blur added per place behind the front. The front card is always sharp. */
8340
+ blur: { type: "length", default: "2px", cssProperty: "--kui-stack-blur" },
8341
+ /** Opacity lost per place behind the front. */
8342
+ fade: { type: "number", default: "0.2", cssProperty: "--kui-stack-fade", finite: true, minimum: 0, maximum: 1 },
8343
+ /*
8344
+ * How many cards show behind the front one. The rest wait, hidden, at the back — `visibility`,
8345
+ * not only `opacity`, so a card nobody can see is also not a tab stop or a click target.
8346
+ */
8347
+ depth: { type: "number", default: "3", cssProperty: "--kui-stack-depth", integer: true, minimum: 1, maximum: 8 }
8348
+ };
8349
+ function prepareSpatialStack(el, params, ctx) {
8350
+ const depth = params.num("depth", 3);
8351
+ return prepareSpatialDeck(el, params, ctx, {
8352
+ id: "spatial-stack",
8353
+ label: "carousel-stack",
8354
+ faceOf: (place2, offset, count) => stackFace(offset, offset - place2, count, depth),
8355
+ placeOf: stackPlace,
8356
+ measure: false
8357
+ });
8358
+ }
8359
+ var SPATIAL_STACK_PRIMITIVE = {
8360
+ id: "spatial-stack",
8361
+ renderer: "javascript",
8362
+ // What `deck.ts` writes on the host: the cards (the subtree), the step index, the drag.
8363
+ channels: [CHANNEL.skew, "discrete", CHANNEL.opacity, CHANNEL.filter, ...SPATIAL_DECK_CHANNELS],
8364
+ parameters: STACK_PARAMETERS,
8365
+ supportedTimelines: ["time"],
8366
+ supportedActivations: ["load", "enter", "click", "manual"],
8367
+ defaultActivation: "load",
8368
+ perfClass: "compositor",
8369
+ reducedMotion: "shorten",
8370
+ prepare: deferPrepare(prepareSpatialStack)
8371
+ };
8372
+ var STACK_PRESETS = [
8373
+ {
8374
+ name: "carousel-stack",
8375
+ primitive: "spatial-stack",
8376
+ requiresOwnSubtree: true,
8377
+ phase: "idle"
8378
+ }
8379
+ ];
8380
+
8381
+ // src/effects/carousel/index.ts
8382
+ var QUARTER_TURN_DEG = 90;
8383
+ var FLATTENING = [
8384
+ ["overflow", (value) => value !== "" && value !== "visible"],
8385
+ ["clip-path", (value) => value !== "" && value !== "none"],
8386
+ ["opacity", (value) => value !== "" && Number(value) < 1],
8387
+ ["filter", (value) => value !== "" && value !== "none"],
8388
+ ["backdrop-filter", (value) => value !== "" && value !== "none"],
8389
+ ["isolation", (value) => value === "isolate"],
8390
+ ["mix-blend-mode", (value) => value !== "" && value !== "normal"],
8391
+ ["mask-image", (value) => value !== "" && value !== "none"]
8392
+ ];
8393
+ function warnFlattenedHost(el, ctx) {
8394
+ const found = flatteningDeclaration(el, ctx);
8395
+ if (!found) return;
8396
+ ctx.warn(
8397
+ `carousel: the ring's own <${el.localName}> has ${found.property}: ${found.value}, which flattens transform-style: preserve-3d \u2014 the ring will render as flat overlapping cards. Move that property to a wrapper around the ring: an ancestor does not flatten it.`
8398
+ );
8399
+ }
8400
+ function flatteningDeclaration(node, ctx) {
8401
+ const style = ctx.win.getComputedStyle?.(node);
8402
+ if (!style) return null;
8403
+ for (const [property2, flattens] of FLATTENING) {
8404
+ const value = style.getPropertyValue(property2);
8405
+ if (flattens(value)) return { property: property2, value };
8406
+ }
8407
+ return null;
8408
+ }
8409
+ function faceAt(angleDeg) {
8410
+ return Math.abs(normaliseDegrees(angleDeg)) >= QUARTER_TURN_DEG ? "back" : "front";
8411
+ }
8412
+ function normaliseDegrees(angleDeg) {
8413
+ const wrapped = (angleDeg % 360 + 360) % 360;
8414
+ return wrapped > 180 ? wrapped - 360 : wrapped;
8415
+ }
8416
+ var RING_PARAMETERS = {
8417
+ ...DECK_PARAMETERS,
8418
+ /*
8419
+ * A real CSS `<angle>`, not a normalised scalar.
8420
+ *
8421
+ * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
8422
+ * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
8423
+ * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
8424
+ * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
8425
+ * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
8426
+ * `clamp()` an author can see in devtools rather than a hidden rescale.
8427
+ *
8428
+ * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
8429
+ * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
8430
+ * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
8431
+ * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
8432
+ */
8433
+ tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
8434
+ /*
8435
+ * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
8436
+ *
8437
+ * This is the parameter the concave name exists around. From the centre of a *full* ring most of
8438
+ * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
8439
+ * two thirds of the content — the inside name defaults to a slice for that reason.
8440
+ */
8441
+ arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
8442
+ /*
8443
+ * The ring's radius, or unset for one derived from the content.
8444
+ *
8445
+ * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
8446
+ * `readParams` fills every declared default in unconditionally, so an empty one is the only way
8447
+ * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
8448
+ * the circumradius of a regular polygon with this many sides and this much side length —
8449
+ * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
8450
+ * Every consumer solving that by hand was the alternative, and they would all have solved it
8451
+ * differently.
8452
+ *
8453
+ * The width half of that has to come from JavaScript: CSS can compute with a measurement but
8454
+ * cannot take one. See `measureItem` in `deck.ts`.
8455
+ *
8456
+ * Authored or derived, a depth ring's radius is a ceiling: a ring that would push its page
8457
+ * sideways is pulled in exactly as far as it has to be (`fit.ts`, `--kui-ring-fit`), and one that
8458
+ * already fits — or sits inside an ancestor that clips it — keeps this radius to the pixel.
8459
+ */
8460
+ radius: { type: "length", default: "", cssProperty: "--kui-radius" },
8461
+ /** Breathing room between neighbours, spent by the derived radius above. */
8462
+ gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
8463
+ /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
8464
+ perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
8465
+ /*
8466
+ * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
8467
+ *
8468
+ * `radial` is the ring's natural state and needs nothing: a slot placed by
8469
+ * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
8470
+ * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
8471
+ * ring read as the far side.
8472
+ *
8473
+ * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
8474
+ * and the billboard have to stay on separate boxes or every later change to one silently
8475
+ * multiplies into the other — so text stays readable at every position, which is what a raised
8476
+ * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
8477
+ */
8478
+ facing: {
8479
+ type: "keyword",
8480
+ default: "radial",
8481
+ cssProperty: "--kui-facing",
8482
+ keywords: ["radial", "camera"]
8483
+ },
8484
+ /*
8485
+ * Which plane the ring lies in: `depth` (the default — a carousel you look *at*, turning about a
8486
+ * vertical pole) or `screen` (a clock face, turning about the axis that points at you).
8487
+ *
8488
+ * ## Why a parameter of this primitive, and not a new one
8489
+ *
8490
+ * A flat ring of cards around a centred word is the same object as the 3D ring seen from its
8491
+ * pole: N slots, evenly spaced by `arc / count`, placed from `--kui-offset` and the shared drift,
8492
+ * advanced by the same drag, keys, controls and `spin:`. Every number this primitive publishes
8493
+ * means the same thing in both planes; only the three transform functions that turn a place into
8494
+ * a position differ, and those live in `carousel.css` anyway. A second primitive would have
8495
+ * duplicated the whole index to change one stylesheet rule and one face test.
8496
+ *
8497
+ * It is a parameter rather than only a name (unlike convex/concave, which differ in every
8498
+ * default and so earned two names) because the runtime has to know it: a flat slot is never
8499
+ * turned away from the viewer, so the quarter-turn face test that stops the back of a 3D ring
8500
+ * swallowing clicks would, on a clock face, disable every card in the bottom half. The name that
8501
+ * ships it, `carousel-orbit`, sets it; the stylesheet gates on the published attribute rather
8502
+ * than the name, so `carousel-3d plane:screen` is equally honoured — same rule as `facing:`.
8503
+ *
8504
+ * `facing:` keeps its meaning here. `radial` turns each card with the ring, top edge outward like
8505
+ * the numerals on a watch bezel; `camera` keeps every card upright. In the screen plane upright
8506
+ * needs no child counter-rotation at all — the slot is *translated* to its place on the circle
8507
+ * instead of rotated there — so it costs no extra markup, unlike the 3D billboard.
8508
+ */
8509
+ plane: {
8510
+ type: "keyword",
8511
+ default: "depth",
8512
+ cssProperty: "--kui-ring-plane",
8513
+ keywords: ["depth", "screen"]
8514
+ }
8515
+ };
8516
+ function prepareSpatialRing(el, params, ctx) {
8517
+ const plane = params.text("plane", "depth");
8518
+ if (plane === "depth") warnFlattenedHost(el, ctx);
8519
+ const arcDeg = degreesOf(params.text("arc", "360deg"), 360);
8520
+ const faceOf = (place2, _offset, count) => plane === "screen" ? "front" : faceAt(place2 * (arcDeg / count));
8521
+ return prepareSpatialDeck(el, params, ctx, {
8522
+ id: "spatial-ring",
8523
+ label: "carousel",
8524
+ faceOf,
8525
+ /*
8526
+ * A derived radius needs a card width; a flat ring also needs a card's size to reserve its own
8527
+ * circle (the host is sized to it, since transformed slots take no layout space), so it measures
8528
+ * even when the author named a radius.
8529
+ */
8530
+ measure: params.text("radius") === "" || plane === "screen",
8531
+ hostAttributes: {
8532
+ /*
8533
+ * `facing:` and `plane:` reach CSS as attributes, not only as the custom properties their
8534
+ * specs declare. A stylesheet cannot select on the *value* of a custom property — `@container
8535
+ * style(--kui-facing: camera)` can, and is not supported widely enough to hang a documented
8536
+ * parameter on — so the one thing a selector needs is published in the one form a selector
8537
+ * can read. Same pairing, for the same reason, as the number/attribute pair `step-marking.ts`
8538
+ * publishes for the ring place.
8539
+ */
8540
+ "data-kui-ring-facing": params.text("facing", "radial"),
8541
+ "data-kui-ring-plane": plane
8542
+ },
8543
+ /*
8544
+ * The fit to the page (`fit.ts`). Depth plane only: a flat ring already reserves and clamps to
8545
+ * its own square (`carousel.css`, `[data-kui-ring-plane='screen']`), and its cards never leave
8546
+ * the plane that square is measured in.
8547
+ */
8548
+ attach: plane === "depth" ? (styles) => createRingFit({
8549
+ el,
8550
+ ctx,
8551
+ styles,
8552
+ arcDeg,
8553
+ tiltParamDeg: degreesOf(params.text("tilt", "0deg"), 0),
8554
+ facing: params.text("facing", "radial") === "camera" ? "camera" : "radial"
8555
+ }) : void 0
8556
+ });
8557
+ }
7325
8558
  function degreesOf(value, fallbackDeg) {
7326
8559
  const match = /^(-?[\d.]+)(deg|rad|turn|grad)?$/i.exec(value.trim());
7327
8560
  if (!match) return fallbackDeg;
@@ -7341,7 +8574,15 @@ function degreesOf(value, fallbackDeg) {
7341
8574
  var SPATIAL_RING_PRIMITIVE = {
7342
8575
  id: "spatial-ring",
7343
8576
  renderer: "javascript",
7344
- channels: [CHANNEL.skew, "discrete"],
8577
+ // What `deck.ts` writes on the host (the cards, the step index, the drag), then the two
8578
+ // attributes only the ring's own layout publishes.
8579
+ channels: [
8580
+ CHANNEL.skew,
8581
+ "discrete",
8582
+ ...SPATIAL_DECK_CHANNELS,
8583
+ attributeChannel("data-kui-ring-facing"),
8584
+ attributeChannel("data-kui-ring-plane")
8585
+ ],
7345
8586
  parameters: RING_PARAMETERS,
7346
8587
  supportedTimelines: ["time"],
7347
8588
  // `load`, not `enter`: a carousel that only wires its arrows once scrolled into view is broken,
@@ -7352,7 +8593,7 @@ var SPATIAL_RING_PRIMITIVE = {
7352
8593
  reducedMotion: "shorten",
7353
8594
  prepare: deferPrepare(prepareSpatialRing)
7354
8595
  };
7355
- var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE];
8596
+ var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE, SPATIAL_STACK_PRIMITIVE];
7356
8597
  var CAROUSEL_PRESETS = [
7357
8598
  {
7358
8599
  name: "carousel-3d",
@@ -7389,10 +8630,31 @@ var CAROUSEL_PRESETS = [
7389
8630
  params: { arc: "120deg", facing: "camera", perspective: "900px" },
7390
8631
  requiresOwnSubtree: true,
7391
8632
  phase: "idle"
8633
+ },
8634
+ /*
8635
+ * The flat ring: cards on a clock face around whatever sits in the middle of the host (a word, a
8636
+ * logo — anything the `target:` does not name stays centred and off the ring). `plane:screen` is
8637
+ * the whole difference; see the parameter's note for why that is a parameter of this primitive and
8638
+ * not a new one.
8639
+ *
8640
+ * Named for what it does rather than which way it goes — `orbit` is the motion, and it has no
8641
+ * direction to name (the directional-naming rule, "named by travel", is about entrances that
8642
+ * *have* one; a ring's direction is the sign of `spin:`). Not `carousel-3d-*`, because it is not
8643
+ * three-dimensional, and a name that promised depth would send people looking for a `tilt:`.
8644
+ *
8645
+ * `facing:camera` by default: upright cards read as a set of things; radially turned ones read as
8646
+ * a dial, which is the less common ask and one keyword away.
8647
+ */
8648
+ {
8649
+ name: "carousel-orbit",
8650
+ primitive: "spatial-ring",
8651
+ params: { plane: "screen", facing: "camera" },
8652
+ requiresOwnSubtree: true,
8653
+ phase: "idle"
7392
8654
  }
7393
8655
  ];
7394
8656
  function registerCarousel(registry) {
7395
- return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS);
8657
+ return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS).registerPresets(STACK_PRESETS);
7396
8658
  }
7397
8659
 
7398
8660
  // src/effects/tween/properties.ts
@@ -7948,7 +9210,10 @@ var deviceFrameParams = {
7948
9210
  keywords: [...DEVICE_FRAME_KINDS]
7949
9211
  },
7950
9212
  color: { type: "color", default: "#111", cssProperty: "--kui-device-color" },
7951
- radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" }
9213
+ radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" },
9214
+ // Its own knob rather than a reading of `radius:`: the frame's corner and the screen's are two
9215
+ // shapes, and the derived screen value already follows `radius:` when this is left unwritten.
9216
+ "screen-radius": { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-screen-radius" }
7952
9217
  };
7953
9218
  function prepareDeviceFrame(el, params) {
7954
9219
  const state = createAttributeLedger(el);
@@ -7961,8 +9226,9 @@ var DEVICE_FRAME_PRIMITIVE = widgetPrimitive(
7961
9226
  // Exclusive ownership of the frame chrome. Its static box-model declarations are outside
7962
9227
  // the channel-property audit, as they are for the existing catalog. The chrome also reaches
7963
9228
  // into the direct media child (`object-fit: cover` etc.); that is a `requiresOwnSubtree`
7964
- // question, as with the other showcase widgets.
7965
- channels: ["frame"],
9229
+ // question, as with the other showcase widgets. The frame kind it stamps on the host is an
9230
+ // attribute it writes, so it claims that too.
9231
+ channels: ["frame", attributeChannel("data-kui-device")],
7966
9232
  parameters: deviceFrameParams,
7967
9233
  // `'layout'`: the bezel's `border`+`padding` are box-model properties added once, at
7968
9234
  // activation, which is a real (if one-time) layout — not the "transform/opacity only" budget
@@ -7997,10 +9263,12 @@ function lock(state) {
7997
9263
  state.lock = ledger;
7998
9264
  }
7999
9265
  function clearContent(state) {
8000
- state.current?.onClose?.();
9266
+ const closing = state.current;
9267
+ closing?.onClose?.();
8001
9268
  state.current = void 0;
8002
9269
  state.currentOwner = void 0;
8003
9270
  state.slot?.replaceChildren();
9271
+ if (closing?.opener) announceViewer(state.doc, closing.opener, false);
8004
9272
  }
8005
9273
  function onClose(state) {
8006
9274
  state.dialog?.classList.remove("is-open");
@@ -8019,11 +9287,9 @@ function dismiss(state) {
8019
9287
  }, state.current.reducedMotion ? 0 : state.current.duration);
8020
9288
  }
8021
9289
  function clickedOutside(state, target) {
8022
- const content = state.current.node;
8023
- if (content.classList.contains("kui-lightbox-gallery")) {
8024
- return !content.contains(target) || !target.matches("img") && !target.closest("button");
8025
- }
8026
- return !content.contains(target) && !target.closest("button");
9290
+ const { node, inside } = state.current;
9291
+ if (inside) return !inside(target);
9292
+ return !node.contains(target) && !target.closest("button");
8027
9293
  }
8028
9294
  function forceReflow(element) {
8029
9295
  return element.offsetWidth;
@@ -8073,6 +9339,7 @@ function open(state, content, owner) {
8073
9339
  dialog.scrollTop = 0;
8074
9340
  forceReflow(dialog);
8075
9341
  dialog.classList.add("is-open");
9342
+ if (content.opener) announceViewer(state.doc, content.opener, true);
8076
9343
  }
8077
9344
  function closeNow(state) {
8078
9345
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -8152,25 +9419,31 @@ function resolveMediaSource(href) {
8152
9419
  }
8153
9420
 
8154
9421
  // src/showcase/lightbox.ts
9422
+ var DEFAULT_DURATION_MS = 280;
9423
+ var DEFAULT_SCALE = "0.965";
9424
+ var DEFAULT_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
8155
9425
  var paramsSchema = {
8156
- media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video"] },
9426
+ media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video", "mixed"] },
8157
9427
  target: { type: "text", default: "", cssProperty: "--kui-target" },
8158
9428
  scope: SCOPE_PARAM,
8159
- scale: { type: "number", default: "0.965", cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
8160
- duration: { type: "time", default: "280ms", cssProperty: "--kui-lightbox-duration" },
8161
- ease: { type: "easing", default: "cubic-bezier(0.22, 1, 0.36, 1)", cssProperty: "--kui-lightbox-ease" },
9429
+ scale: { type: "number", default: DEFAULT_SCALE, cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
9430
+ duration: { type: "time", default: `${DEFAULT_DURATION_MS}ms`, cssProperty: "--kui-lightbox-duration" },
9431
+ ease: { type: "easing", default: DEFAULT_EASE, cssProperty: "--kui-lightbox-ease" },
8162
9432
  loop: { type: "keyword", default: "true", cssProperty: "--kui-lightbox-loop", keywords: ["true", "false"] },
8163
9433
  aspect: { type: "keyword", default: "", cssProperty: "--kui-lightbox-aspect", keywords: ["wide", "tall", "square"] },
8164
9434
  caption: { type: "keyword", default: "figcaption", cssProperty: "--kui-lightbox-caption", keywords: ["figcaption", "alt", "title", "none"] }
8165
9435
  };
8166
9436
  function defaultTargets(el, media) {
8167
- if (el.matches("a[href]") || media === "image" && el.matches("img")) return [el];
9437
+ if (el.matches("a[href]") || media !== "video" && el.matches("img")) return [el];
8168
9438
  return [...el.querySelectorAll(media === "video" ? ":scope a[href]" : ":scope a[href], :scope img")];
8169
9439
  }
9440
+ function effectName(media) {
9441
+ return media === "video" ? "video-lightbox" : "lightbox";
9442
+ }
8170
9443
  function targets(el, params, ctx, media) {
8171
9444
  const authored = params.text("target");
8172
9445
  if (!authored) return defaultTargets(el, media);
8173
- const selector = resolveTarget(authored, ctx, media === "video" ? "video-lightbox" : "lightbox");
9446
+ const selector = resolveTarget(authored, ctx, effectName(media));
8174
9447
  return selector ? queryScoped(el, ctx, selector, scopeParam(params, "self")) : [];
8175
9448
  }
8176
9449
  function imageTrigger(target) {
@@ -8181,52 +9454,137 @@ function imageIn(trigger) {
8181
9454
  }
8182
9455
  function captionFor(trigger, image, source) {
8183
9456
  if (source === "none") return "";
8184
- if (source === "alt") return image.alt;
8185
- if (source === "title") return (trigger.getAttribute("title") || image.title).trim();
8186
- return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || image.alt;
9457
+ const alt = image?.alt ?? "";
9458
+ if (source === "alt") return alt;
9459
+ if (source === "title") return titleOf(trigger, image);
9460
+ return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || alt;
9461
+ }
9462
+ function titleOf(trigger, image) {
9463
+ return (trigger.getAttribute("title") || image?.title || "").trim();
8187
9464
  }
8188
9465
  function imageItem(trigger, captionSource) {
8189
9466
  const image = imageIn(trigger);
8190
9467
  if (!image) return null;
8191
9468
  const link = trigger instanceof HTMLAnchorElement ? trigger : null;
8192
9469
  return {
9470
+ kind: "image",
8193
9471
  trigger,
8194
9472
  image,
8195
9473
  src: link?.href ?? (image.currentSrc || image.src),
8196
9474
  caption: captionFor(trigger, image, captionSource)
8197
9475
  };
8198
9476
  }
8199
- function imageItems(targets2, captionSource) {
9477
+ function videoTitle(link, poster) {
9478
+ return link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
9479
+ }
9480
+ function videoItem(link, captionSource) {
9481
+ const source = resolveMediaSource(link.getAttribute("href") ?? "");
9482
+ if (!source) return null;
9483
+ const poster = link.querySelector("img");
9484
+ return { kind: "video", trigger: link, source, title: videoTitle(link, poster), caption: captionFor(link, poster, captionSource) };
9485
+ }
9486
+ function itemFor(trigger, media, captionSource) {
9487
+ if (media !== "image" && trigger instanceof HTMLAnchorElement) {
9488
+ const video = videoItem(trigger, captionSource);
9489
+ if (video || media === "video") return video;
9490
+ }
9491
+ return imageItem(trigger, captionSource);
9492
+ }
9493
+ function triggersFor(found, media) {
8200
9494
  const seen = /* @__PURE__ */ new Set();
8201
- const items = [];
8202
- for (const target of targets2) {
8203
- const trigger = imageTrigger(target);
9495
+ const triggers = [];
9496
+ for (const target of found) {
9497
+ const trigger = media === "video" ? target : imageTrigger(target);
8204
9498
  if (seen.has(trigger)) continue;
8205
- const item = imageItem(trigger, captionSource);
8206
- if (!item) continue;
9499
+ if (!usable(trigger, media)) continue;
8207
9500
  seen.add(trigger);
8208
- items.push(item);
9501
+ triggers.push(trigger);
8209
9502
  }
8210
- return items;
9503
+ return triggers;
9504
+ }
9505
+ function usable(trigger, media) {
9506
+ const link = trigger instanceof HTMLAnchorElement;
9507
+ if (media === "video") return link;
9508
+ const image = imageIn(trigger) !== null;
9509
+ return media === "image" ? image : link || image;
8211
9510
  }
8212
9511
  function modalOptions(params, ctx) {
8213
9512
  return {
8214
- duration: params.timing.durationMs ?? params.ms("duration", 280),
8215
- scale: params.text("scale", "0.965"),
8216
- ease: params.timing.easing ?? params.text("ease", "cubic-bezier(0.22, 1, 0.36, 1)"),
9513
+ duration: params.timing.durationMs ?? params.ms("duration", DEFAULT_DURATION_MS),
9514
+ scale: params.text("scale", DEFAULT_SCALE),
9515
+ ease: params.timing.easing ?? params.text("ease", DEFAULT_EASE),
8217
9516
  reducedMotion: ctx.reducedMotion
8218
9517
  };
8219
9518
  }
8220
- function showImage(item, view, caption) {
9519
+ function showImage(item, view) {
8221
9520
  view.src = item.src;
8222
9521
  view.alt = item.image.alt;
8223
- caption.textContent = item.caption;
8224
- caption.hidden = item.caption === "";
8225
9522
  const ratio = item.image.naturalWidth ? item.image.naturalHeight / item.image.naturalWidth : 1;
8226
9523
  view.className = ratio > 1.5 ? "is-tall" : "is-fit";
8227
9524
  }
9525
+ function videoFrame(item, aspect, doc) {
9526
+ const frame = doc.createElement("div");
9527
+ frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect || item.source.aspect}`;
9528
+ if (item.source.kind === "file") {
9529
+ const video = doc.createElement("video");
9530
+ video.src = item.source.embedUrl;
9531
+ video.controls = true;
9532
+ video.autoplay = true;
9533
+ video.playsInline = true;
9534
+ video.setAttribute("aria-label", item.title);
9535
+ frame.append(video);
9536
+ } else {
9537
+ const iframe = doc.createElement("iframe");
9538
+ iframe.src = item.source.embedUrl;
9539
+ iframe.title = item.title;
9540
+ iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
9541
+ iframe.allowFullscreen = true;
9542
+ frame.append(iframe);
9543
+ }
9544
+ return frame;
9545
+ }
9546
+ function pauseVideo(video) {
9547
+ if (video.readyState > 0) video.pause();
9548
+ }
9549
+ function stopMedia(frame) {
9550
+ const video = frame.querySelector("video");
9551
+ if (video) {
9552
+ pauseVideo(video);
9553
+ video.removeAttribute("src");
9554
+ }
9555
+ frame.replaceChildren();
9556
+ }
9557
+ function leave(state, node) {
9558
+ if (node === state.view) return;
9559
+ const video = node.querySelector("video");
9560
+ if (video) pauseVideo(video);
9561
+ else node.replaceChildren();
9562
+ }
9563
+ function land(state, item) {
9564
+ if (item.kind === "image") {
9565
+ showImage(item, state.view);
9566
+ return state.view;
9567
+ }
9568
+ const cached = state.players.get(item);
9569
+ if (cached) {
9570
+ void cached.querySelector("video").play().catch(() => {
9571
+ });
9572
+ return cached;
9573
+ }
9574
+ const frame = videoFrame(item, state.aspect, state.figure.ownerDocument);
9575
+ if (item.source.kind === "file") state.players.set(item, frame);
9576
+ return frame;
9577
+ }
8228
9578
  function renderGallery(state, focus) {
8229
- showImage(state.items[state.index], state.view, state.caption);
9579
+ const item = state.items[state.index];
9580
+ if (state.shown?.item !== item) {
9581
+ if (state.shown) leave(state, state.shown.node);
9582
+ const node = land(state, item);
9583
+ state.shown = { item, node };
9584
+ state.caption.textContent = item.caption;
9585
+ state.caption.hidden = item.caption === "";
9586
+ state.figure.replaceChildren(node, state.caption);
9587
+ }
8230
9588
  state.counter.textContent = `${state.index + 1} of ${state.items.length}`;
8231
9589
  state.previous.disabled = !state.loop && state.index === 0;
8232
9590
  state.next.disabled = !state.loop && state.index === state.items.length - 1;
@@ -8244,6 +9602,7 @@ function clickGallery(state, delta, button2) {
8244
9602
  }
8245
9603
  function galleryKey(state, event) {
8246
9604
  if (state.items.length < 2) return;
9605
+ if (event.target.closest("video")) return;
8247
9606
  if (event.key === "ArrowLeft") moveGallery(state, -1, true);
8248
9607
  else if (event.key === "ArrowRight") moveGallery(state, 1, true);
8249
9608
  else if (event.key === "Home") {
@@ -8255,8 +9614,40 @@ function galleryKey(state, event) {
8255
9614
  } else return;
8256
9615
  event.preventDefault();
8257
9616
  }
8258
- function galleryContent(items, initial, loop2, options) {
8259
- const doc = items[initial].image.ownerDocument;
9617
+ function swipeGallery(state, box) {
9618
+ const viewport = box.ownerDocument.defaultView?.visualViewport ?? null;
9619
+ const zoomed = () => (viewport?.scale ?? 1) > 1.01;
9620
+ const sync = () => {
9621
+ box.classList.toggle("is-swipeable", !zoomed());
9622
+ };
9623
+ sync();
9624
+ viewport?.addEventListener("resize", sync);
9625
+ const stop = recognise(box, {
9626
+ onSwipe(direction) {
9627
+ if (direction === "left" || direction === "right") moveGallery(state, swipeStep(direction), false);
9628
+ }
9629
+ }, {
9630
+ // A press on a button still clicks it: capture is taken only once the finger has moved.
9631
+ capturePointer: "drag",
9632
+ accept: (event) => event.pointerType !== "mouse" && !zoomed() && event.target.closest("video") === null
9633
+ });
9634
+ return () => {
9635
+ stop();
9636
+ viewport?.removeEventListener("resize", sync);
9637
+ };
9638
+ }
9639
+ function closeViewer(state) {
9640
+ state.view.removeAttribute("src");
9641
+ for (const frame of state.players.values()) stopMedia(frame);
9642
+ if (state.shown && state.shown.node !== state.view) stopMedia(state.shown.node);
9643
+ }
9644
+ function vocabulary(items) {
9645
+ const kinds = new Set(items.map((item) => item.kind));
9646
+ if (kinds.size > 1) return { noun: "item", label: "Media viewer" };
9647
+ return kinds.has("video") ? { noun: "video", label: "Video viewer" } : { noun: "image", label: "Image viewer" };
9648
+ }
9649
+ function galleryContent(items, initial, options) {
9650
+ const doc = items[initial].trigger.ownerDocument;
8260
9651
  const box = doc.createElement("div");
8261
9652
  box.className = "kui-lightbox-gallery";
8262
9653
  const figure = doc.createElement("figure");
@@ -8264,7 +9655,6 @@ function galleryContent(items, initial, loop2, options) {
8264
9655
  const view = doc.createElement("img");
8265
9656
  view.decoding = "async";
8266
9657
  const caption = doc.createElement("figcaption");
8267
- figure.append(view, caption);
8268
9658
  box.append(figure);
8269
9659
  const counter = doc.createElement("span");
8270
9660
  counter.className = "kui-lightbox-counter";
@@ -8274,149 +9664,244 @@ function galleryContent(items, initial, loop2, options) {
8274
9664
  previous.type = next.type = "button";
8275
9665
  previous.textContent = "\u2039";
8276
9666
  next.textContent = "\u203A";
8277
- previous.setAttribute("aria-label", "Previous image");
8278
- next.setAttribute("aria-label", "Next image");
9667
+ const { noun, label } = vocabulary(items);
9668
+ previous.setAttribute("aria-label", `Previous ${noun}`);
9669
+ next.setAttribute("aria-label", `Next ${noun}`);
8279
9670
  previous.className = "kui-lightbox-prev";
8280
9671
  next.className = "kui-lightbox-next";
8281
- const state = { items, index: initial, loop: loop2, figure, view, caption, counter, previous, next };
9672
+ const state = {
9673
+ items,
9674
+ index: initial,
9675
+ loop: options.loop,
9676
+ aspect: options.aspect,
9677
+ figure,
9678
+ view,
9679
+ caption,
9680
+ counter,
9681
+ previous,
9682
+ next,
9683
+ players: /* @__PURE__ */ new Map()
9684
+ };
8282
9685
  if (items.length > 1) {
8283
9686
  box.append(previous, next, counter);
8284
9687
  previous.addEventListener("click", () => clickGallery(state, -1, previous));
8285
9688
  next.addEventListener("click", () => clickGallery(state, 1, next));
8286
9689
  }
8287
9690
  renderGallery(state, false);
9691
+ const unswipe = items.length > 1 ? swipeGallery(state, box) : void 0;
9692
+ const { duration, scale, ease, reducedMotion } = options;
8288
9693
  return {
8289
- ...options,
9694
+ duration,
9695
+ scale,
9696
+ ease,
9697
+ reducedMotion,
8290
9698
  node: box,
8291
- label: "Image viewer",
8292
- onClose: () => view.removeAttribute("src"),
9699
+ label,
9700
+ // The media and the controls keep the viewer open; the empty figure around them closes it.
9701
+ inside: (target) => box.contains(target) && target.closest("img, video, iframe, button") !== null,
9702
+ onClose: () => {
9703
+ unswipe?.();
9704
+ closeViewer(state);
9705
+ },
8293
9706
  onKey: (event) => galleryKey(state, event)
8294
9707
  };
8295
9708
  }
8296
- function stopMedia(frame) {
8297
- const video = frame.querySelector("video");
8298
- if (video) {
8299
- if (video.readyState > 0) video.pause();
8300
- video.removeAttribute("src");
8301
- }
8302
- frame.replaceChildren();
8303
- }
8304
- function videoContent(link, source, options, aspect) {
8305
- const doc = link.ownerDocument;
8306
- const frame = doc.createElement("div");
8307
- frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect}`;
8308
- const poster = link.querySelector("img");
8309
- const title = link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
8310
- if (source.kind === "file") {
8311
- const video = doc.createElement("video");
8312
- video.src = source.embedUrl;
8313
- video.controls = true;
8314
- video.autoplay = true;
8315
- video.playsInline = true;
8316
- video.setAttribute("aria-label", title);
8317
- frame.append(video);
8318
- } else {
8319
- const iframe = doc.createElement("iframe");
8320
- iframe.src = source.embedUrl;
8321
- iframe.title = title;
8322
- iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
8323
- iframe.allowFullscreen = true;
8324
- frame.append(iframe);
8325
- }
8326
- return { ...options, node: frame, label: "Video viewer", onClose: () => stopMedia(frame) };
8327
- }
8328
9709
  function primaryClick(event) {
8329
9710
  return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
8330
9711
  }
8331
- function wireImages(items, wiring, loop2) {
9712
+ function openAt(triggers, trigger, wiring) {
9713
+ const items = [];
9714
+ let initial = -1;
9715
+ for (const candidate of triggers) {
9716
+ const item = itemFor(candidate, wiring.media, wiring.captionSource);
9717
+ if (!item) continue;
9718
+ if (candidate === trigger) initial = items.length;
9719
+ items.push(item);
9720
+ }
9721
+ if (initial < 0) {
9722
+ const href = trigger.getAttribute("href") ?? "";
9723
+ if (!wiring.warned.has(href)) wiring.ctx.warn(`${effectName(wiring.media)} cannot embed "${href}"; the link will navigate normally`);
9724
+ wiring.warned.add(href);
9725
+ return false;
9726
+ }
9727
+ wiring.shell.open({ ...galleryContent(items, initial, wiring.options), opener: trigger });
9728
+ return true;
9729
+ }
9730
+ function wireTriggers(triggers, wiring) {
8332
9731
  const restore = [];
8333
- items.forEach((item, index) => {
8334
- const bare = item.trigger === item.image;
9732
+ for (const trigger of triggers) {
9733
+ const bare = trigger instanceof HTMLImageElement;
8335
9734
  if (bare) {
8336
- const attrs = createAttributeLedger(item.image);
9735
+ const attrs = createAttributeLedger(trigger);
8337
9736
  attrs.set("role", "button");
8338
9737
  attrs.set("tabindex", "0");
8339
- attrs.set("aria-label", item.image.alt ? `Open larger image: ${item.image.alt}` : "Open larger image");
9738
+ attrs.set("aria-label", trigger.alt ? `Open larger image: ${trigger.alt}` : "Open larger image");
8340
9739
  restore.push(() => attrs.restore());
8341
- item.image.addEventListener("keydown", (event) => {
9740
+ trigger.addEventListener("keydown", (event) => {
8342
9741
  if (event.key !== "Enter" && event.key !== " ") return;
8343
9742
  event.preventDefault();
8344
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
9743
+ openAt(triggers, trigger, wiring);
8345
9744
  }, { signal: wiring.signal });
8346
9745
  }
8347
- item.trigger.addEventListener("click", (event) => {
9746
+ trigger.addEventListener("click", (event) => {
8348
9747
  if (!primaryClick(event)) return;
8349
- if (!bare) event.preventDefault();
8350
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
9748
+ if (openAt(triggers, trigger, wiring)) event.preventDefault();
8351
9749
  }, { signal: wiring.signal });
8352
- });
9750
+ }
8353
9751
  return restore;
8354
9752
  }
8355
- function wireVideos(targets2, wiring, aspect, ctx) {
8356
- const warned = /* @__PURE__ */ new Set();
8357
- for (const target of targets2) {
8358
- if (!(target instanceof HTMLAnchorElement)) continue;
8359
- target.addEventListener("click", (event) => {
8360
- if (!primaryClick(event)) return;
8361
- const href = target.getAttribute("href") ?? "";
8362
- const source = resolveMediaSource(href);
8363
- if (!source) {
8364
- if (!warned.has(href)) ctx.warn(`video-lightbox cannot embed "${href}"; the link will navigate normally`);
8365
- warned.add(href);
8366
- return;
8367
- }
8368
- event.preventDefault();
8369
- wiring.shell.open(videoContent(target, source, wiring.options, aspect || source.aspect));
8370
- }, { signal: wiring.signal });
8371
- }
9753
+ function cardItem(card) {
9754
+ const link = card.matches("a[href]") ? card : card.querySelector("a[href]");
9755
+ return link && itemFor(link, "mixed", "figcaption") || itemFor(card, "mixed", "figcaption");
9756
+ }
9757
+ var OWN_CLICK = "button, input, select, textarea, label, summary, [contenteditable]";
9758
+ function attachDeckLightbox(request) {
9759
+ const { host, cards, doc, reducedMotion } = request;
9760
+ const shell = acquireModalShell(doc);
9761
+ const options = {
9762
+ duration: DEFAULT_DURATION_MS,
9763
+ scale: DEFAULT_SCALE,
9764
+ ease: DEFAULT_EASE,
9765
+ reducedMotion,
9766
+ loop: true,
9767
+ aspect: ""
9768
+ };
9769
+ const turnedAway = (card) => card.getAttribute("data-kui-ring-face") === "back";
9770
+ const openAt2 = (pick, faceChecked = false) => {
9771
+ const all = [...cards()];
9772
+ const card = all.find(pick);
9773
+ if (!card || !faceChecked && turnedAway(card)) return false;
9774
+ const items = [];
9775
+ let initial = -1;
9776
+ for (const candidate of all) {
9777
+ const item = cardItem(candidate);
9778
+ if (!item) continue;
9779
+ if (candidate === card) initial = items.length;
9780
+ items.push(item);
9781
+ }
9782
+ if (initial < 0) return false;
9783
+ shell.open({ ...galleryContent(items, initial, options), opener: card });
9784
+ return true;
9785
+ };
9786
+ let pressed = null;
9787
+ const cardUnder = (event) => {
9788
+ const all = [...cards()];
9789
+ const hits = typeof doc.elementsFromPoint === "function" ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
9790
+ for (const node of [...hits, event.target]) {
9791
+ const card = all.find((candidate) => candidate.contains(node));
9792
+ if (card) return turnedAway(card) ? null : { card, node };
9793
+ }
9794
+ return null;
9795
+ };
9796
+ const onPress = (event) => {
9797
+ pressed = event.button === 0 ? cardUnder(event) : null;
9798
+ };
9799
+ const onClick = (event) => {
9800
+ const press = event.detail > 0 ? pressed : null;
9801
+ pressed = null;
9802
+ const target = event.target;
9803
+ if (event.defaultPrevented || !primaryClick(event) || target.closest(OWN_CLICK)) return;
9804
+ if (press?.node.closest(OWN_CLICK)) return;
9805
+ const opened = press ? openAt2((card) => card === press.card, true) : openAt2((card) => card.contains(target));
9806
+ if (opened) event.preventDefault();
9807
+ };
9808
+ const onKeyDown = (event) => {
9809
+ const key = event.key;
9810
+ if (event.target !== host || key !== "Enter" && key !== " ") return;
9811
+ if (openAt2((card) => card.getAttribute(STEP_STATE_ATTR) === "active")) event.preventDefault();
9812
+ };
9813
+ host.addEventListener("pointerdown", onPress);
9814
+ host.addEventListener("click", onClick);
9815
+ host.addEventListener("keydown", onKeyDown);
9816
+ return () => {
9817
+ host.removeEventListener("pointerdown", onPress);
9818
+ host.removeEventListener("click", onClick);
9819
+ host.removeEventListener("keydown", onKeyDown);
9820
+ shell.release();
9821
+ };
8372
9822
  }
8373
9823
  function prepareLightbox(el, params, ctx) {
8374
9824
  const media = params.text("media", "image");
8375
- const found = targets(el, params, ctx, media);
8376
- if (found.length === 0) return () => {
9825
+ const triggers = triggersFor(targets(el, params, ctx, media), media);
9826
+ if (triggers.length === 0) return () => {
8377
9827
  };
8378
9828
  const controller = new AbortController();
8379
9829
  const shell = acquireModalShell(ctx.doc);
8380
- const wiring = { shell, options: modalOptions(params, ctx), signal: controller.signal };
8381
- let restore = [];
8382
- if (media === "video") {
8383
- wireVideos(found, wiring, params.text("aspect"), ctx);
8384
- } else {
8385
- const items = imageItems(found, params.text("caption", "figcaption"));
8386
- restore = wireImages(items, wiring, params.is("loop"));
8387
- }
9830
+ const options = { ...modalOptions(params, ctx), loop: params.is("loop"), aspect: params.text("aspect") };
9831
+ const restore = wireTriggers(triggers, {
9832
+ shell,
9833
+ options,
9834
+ signal: controller.signal,
9835
+ media,
9836
+ captionSource: params.text("caption", "figcaption"),
9837
+ ctx,
9838
+ warned: /* @__PURE__ */ new Set()
9839
+ });
8388
9840
  return continuousSetup(() => {
8389
9841
  controller.abort();
8390
9842
  for (const undo of restore) undo();
8391
9843
  shell.release();
8392
9844
  });
8393
9845
  }
9846
+ var TRIGGERS_CHANNEL = "lightbox:triggers";
8394
9847
  var LIGHTBOX_PRIMITIVE = widgetPrimitive(
8395
9848
  "lightbox",
8396
- { channels: ["widget"], parameters: paramsSchema, perfClass: "paint" },
9849
+ { channels: [TRIGGERS_CHANNEL], parameters: paramsSchema, perfClass: "paint" },
8397
9850
  withTimingContract("lightbox", { honours: ["duration", "ease"], because: "the dialog opens immediately, so it has no delay phase" }, deferPrepare(prepareLightbox))
8398
9851
  );
8399
9852
  var LIGHTBOX_PRESETS = [
8400
9853
  { name: "lightbox", primitive: "lightbox", params: { media: "image" } },
8401
- { name: "video-lightbox", primitive: "lightbox", params: { media: "video" } }
9854
+ // A video row has never shown captions; `caption:figcaption` opts one in.
9855
+ { name: "video-lightbox", primitive: "lightbox", params: { media: "video", caption: "none" } }
8402
9856
  ];
8403
9857
 
8404
9858
  // src/showcase/compare.ts
8405
9859
  var COMPARE_AXES = ["x", "y"];
8406
9860
  var COMPARE_AXIS_ATTR = "data-kui-compare-axis";
9861
+ var COMPARE_DIVIDERS_ATTR = "data-kui-compare-dividers";
8407
9862
  var compareParams = {
8408
9863
  position: {
8409
9864
  type: "percentage",
8410
9865
  default: "50%",
8411
9866
  cssProperty: "--kui-compare"
8412
9867
  },
9868
+ /*
9869
+ * Where each divider of a three-or-more frame starts, as one quoted, space-separated list —
9870
+ * `positions:'20% 45% 80%'` — because a comma would start a new step, not a new value. Default
9871
+ * (empty) is an even split. `position:` stays the two-media spelling: it is a single percentage
9872
+ * and every existing page relies on it validating as one.
9873
+ */
9874
+ positions: { type: "text", default: "", cssProperty: "--kui-compare-positions" },
8413
9875
  axis: {
8414
9876
  type: "keyword",
8415
9877
  default: "x",
8416
9878
  cssProperty: "--kui-compare-axis",
8417
9879
  keywords: [...COMPARE_AXES]
8418
- }
9880
+ },
9881
+ /*
9882
+ * The auto-drag, in `background-media`'s words (`src/effects/catalog/media.ts`): `always` starts
9883
+ * at load, `in-view` the first time the slider is on screen, `never` (default) not at all.
9884
+ *
9885
+ * Not `on:load` / `on:enter`, because the activation grammar already has a job here: it is what
9886
+ * *builds* the slider, and a widget accepts only `on:load` (`src/showcase/shared.ts`) so the
9887
+ * control exists before a keyboard user tabs to it. `on:enter` would defer the widget, not the
9888
+ * motion — so the motion's start is its own parameter.
9889
+ */
9890
+ autoplay: {
9891
+ type: "keyword",
9892
+ default: "never",
9893
+ cssProperty: "--kui-compare-autoplay",
9894
+ keywords: ["never", "in-view", "always"]
9895
+ },
9896
+ /** One edge-to-edge sweep. The positional spelling (`compare 4s`) means the same thing. */
9897
+ duration: { type: "time", default: "3s", cssProperty: "--kui-compare-duration" },
9898
+ /** Head toward 0% first instead of 100%. */
9899
+ reverse: { type: "keyword", default: "false", cssProperty: "--kui-compare-reverse", keywords: ["true", "false"] },
9900
+ /** Sweep back and forth until someone takes the handle, instead of one pass. */
9901
+ loop: { type: "keyword", default: "false", cssProperty: "--kui-compare-loop", keywords: ["true", "false"] }
8419
9902
  };
9903
+ var MIN_SWEEP_MS = 250;
9904
+ var MAX_FRAME_MS2 = 100;
8420
9905
  var VERTICAL_KEY_DELTA = { ArrowUp: -1, ArrowDown: 1 };
8421
9906
  function getMediaName(item, fallback) {
8422
9907
  const alt = item.localName === "picture" ? item.querySelector("img")?.getAttribute("alt") : item.getAttribute("alt");
@@ -8432,76 +9917,402 @@ function resolveCompareLabel(el, media) {
8432
9917
  }
8433
9918
  return "Compare";
8434
9919
  }
8435
- function createCompareElements(doc, label, initialPercent, axis) {
9920
+ function createRange(doc, label, value, axis) {
8436
9921
  const range = doc.createElement("input");
8437
9922
  range.type = "range";
8438
9923
  range.className = "kui-compare-range";
8439
9924
  range.min = "0";
8440
9925
  range.max = "100";
8441
9926
  range.step = "1";
8442
- range.value = String(initialPercent);
8443
- range.setAttribute("aria-label", label);
8444
- range.setAttribute("aria-valuetext", `${initialPercent}% after`);
9927
+ range.value = String(value);
9928
+ range.setAttribute("aria-label", label.name);
9929
+ range.setAttribute("aria-valuetext", label.valueText);
8445
9930
  if (axis === "y") range.setAttribute("aria-orientation", "vertical");
9931
+ return range;
9932
+ }
9933
+ function createHandle(doc) {
8446
9934
  const handle = doc.createElement("span");
8447
9935
  handle.className = "kui-compare-handle";
8448
9936
  handle.setAttribute("aria-hidden", "true");
8449
- return { range, handle };
9937
+ return handle;
8450
9938
  }
8451
- function prepareCompare(el, params, ctx) {
8452
- const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
8453
- if (media.length < 2) {
8454
- ctx.warn("compare requires at least two media children (img, picture, or video)");
9939
+ function verticalKeys(range, axis, onInput) {
9940
+ return (event) => {
9941
+ const delta = VERTICAL_KEY_DELTA[event.key];
9942
+ if (axis !== "y" || delta === void 0) return;
9943
+ event.preventDefault();
9944
+ if (delta < 0) range.stepDown();
9945
+ else range.stepUp();
9946
+ onInput();
9947
+ };
9948
+ }
9949
+ function sweepPercent(phase) {
9950
+ return 50 - 50 * Math.cos(Math.PI * phase / 100);
9951
+ }
9952
+ function startPhase(percent, reverse) {
9953
+ const outbound = 100 / Math.PI * Math.acos(1 - percent / 50);
9954
+ return reverse ? 200 - outbound : outbound;
9955
+ }
9956
+ function sweepDuration(params, ctx) {
9957
+ const authored = params.timing.durationMs ?? params.ms("duration", 3e3);
9958
+ if (authored >= MIN_SWEEP_MS) return authored;
9959
+ ctx.warn(`compare duration: minimum is ${MIN_SWEEP_MS}ms; clamped to ${MIN_SWEEP_MS}ms`);
9960
+ return MIN_SWEEP_MS;
9961
+ }
9962
+ function bindAutoDrag(options) {
9963
+ const { host, grab, ctx, mode, loop: loop2 } = options;
9964
+ if (mode === "never" || ctx.reducedMotion) return () => {
9965
+ };
9966
+ const run = runAutoDrag(options, () => stop());
9967
+ let observer = null;
9968
+ if (mode === "in-view" || loop2) {
9969
+ observer = observeOnscreen(ctx.win, host, run.setOnscreen);
8455
9970
  }
8456
- const axis = params.text("axis", "x");
8457
- const attrs = createAttributeLedger(el);
8458
- attrs.set(COMPARE_AXIS_ATTR, axis);
9971
+ const stop = () => {
9972
+ run.stop();
9973
+ observer?.disconnect();
9974
+ observer = null;
9975
+ for (const target of grab) {
9976
+ target.removeEventListener("pointerdown", stop);
9977
+ target.removeEventListener("focus", stop);
9978
+ }
9979
+ };
9980
+ for (const target of grab) {
9981
+ target.addEventListener("pointerdown", stop);
9982
+ target.addEventListener("focus", stop);
9983
+ }
9984
+ run.setOnscreen(mode === "always" || observer === null);
9985
+ return stop;
9986
+ }
9987
+ function sweepPlan(options) {
9988
+ const { write, homes, bounds, durationMs, reverse, loop: loop2 } = options;
9989
+ const order = homes.map((_, index) => index);
9990
+ if (reverse) order.reverse();
9991
+ const cycleMs = 2 * durationMs;
9992
+ const totalMs = cycleMs * order.length;
9993
+ const phases = homes.map((home, index) => {
9994
+ const [lo, hi] = bounds[index];
9995
+ return hi > lo ? startPhase(100 * (home - lo) / (hi - lo), reverse) : 0;
9996
+ });
9997
+ const park = (slot2) => write(order[slot2], homes[order[slot2]]);
9998
+ let slot = 0;
9999
+ return {
10000
+ show(elapsed) {
10001
+ if (!loop2 && elapsed >= totalMs) {
10002
+ park(slot);
10003
+ return false;
10004
+ }
10005
+ const into = elapsed % totalMs;
10006
+ const next = Math.floor(into / cycleMs);
10007
+ if (next !== slot) park(slot);
10008
+ slot = next;
10009
+ const index = order[slot];
10010
+ const [lo, hi] = bounds[index];
10011
+ const phase = phases[index] + 100 * (into - slot * cycleMs) / durationMs;
10012
+ write(index, lo + (hi - lo) * sweepPercent(phase) / 100);
10013
+ return true;
10014
+ }
10015
+ };
10016
+ }
10017
+ function observeOnscreen(win, host, onChange) {
10018
+ const Observer = win.IntersectionObserver;
10019
+ if (!Observer) return null;
10020
+ const observer = new Observer((entries) => {
10021
+ for (const entry of entries) onChange(entry.isIntersecting);
10022
+ });
10023
+ observer.observe(host);
10024
+ return observer;
10025
+ }
10026
+ function runAutoDrag(options, finish) {
10027
+ const win = options.ctx.win;
10028
+ const plan = sweepPlan(options);
10029
+ let elapsed = 0;
10030
+ let lastFrameAt = null;
10031
+ let frame = 0;
10032
+ let done = false;
10033
+ let onscreen = false;
10034
+ const update = () => {
10035
+ const active = !done && onscreen;
10036
+ if (active && frame === 0) {
10037
+ lastFrameAt = null;
10038
+ frame = win.requestAnimationFrame(tick);
10039
+ } else if (!active && frame !== 0) {
10040
+ win.cancelAnimationFrame(frame);
10041
+ frame = 0;
10042
+ }
10043
+ };
10044
+ function tick(now) {
10045
+ frame = 0;
10046
+ if (lastFrameAt !== null) elapsed += Math.min(now - lastFrameAt, MAX_FRAME_MS2);
10047
+ lastFrameAt = now;
10048
+ if (plan.show(elapsed)) frame = win.requestAnimationFrame(tick);
10049
+ else finish();
10050
+ }
10051
+ return {
10052
+ setOnscreen(next) {
10053
+ onscreen = next;
10054
+ update();
10055
+ },
10056
+ stop() {
10057
+ done = true;
10058
+ update();
10059
+ }
10060
+ };
10061
+ }
10062
+ function buildPair({ el, media, params, ctx, axis, timing: timing3 }) {
8459
10063
  const initialPercent = Math.round(params.num("position", 0.5) * 100);
8460
10064
  const style = createStyleLedger(el);
8461
10065
  style.set("--kui-compare", params.text("position", "50%"));
8462
- const { range, handle } = createCompareElements(
8463
- el.ownerDocument,
8464
- resolveCompareLabel(el, media),
10066
+ const doc = el.ownerDocument;
10067
+ const range = createRange(
10068
+ doc,
10069
+ { name: resolveCompareLabel(el, media), valueText: `${initialPercent}% after` },
8465
10070
  initialPercent,
8466
10071
  axis
8467
10072
  );
10073
+ const handle = createHandle(doc);
8468
10074
  const onInput = () => {
8469
10075
  const val = range.value;
8470
10076
  style.set("--kui-compare", `${val}%`);
8471
10077
  range.setAttribute("aria-valuetext", `${val}% after`);
8472
10078
  };
8473
- const onKeyDown = (event) => {
8474
- const delta = VERTICAL_KEY_DELTA[event.key];
8475
- if (axis !== "y" || delta === void 0) return;
8476
- event.preventDefault();
8477
- if (delta < 0) range.stepDown();
8478
- else range.stepUp();
8479
- onInput();
10079
+ const writeAuto = (_index, percent) => {
10080
+ range.value = String(Math.round(percent));
10081
+ style.set("--kui-compare", `${Number(percent.toFixed(3))}%`);
10082
+ range.setAttribute("aria-valuetext", `${range.value}% after`);
8480
10083
  };
10084
+ const onKeyDown = verticalKeys(range, axis, onInput);
8481
10085
  range.addEventListener("input", onInput);
8482
10086
  range.addEventListener("keydown", onKeyDown);
8483
10087
  el.appendChild(range);
8484
10088
  el.appendChild(handle);
8485
- return continuousSetup(() => {
10089
+ const stopAutoDrag = bindAutoDrag({
10090
+ ...timing3,
10091
+ host: el,
10092
+ grab: [range],
10093
+ ctx,
10094
+ write: writeAuto,
10095
+ homes: [params.num("position", 0.5) * 100],
10096
+ bounds: [[0, 100]]
10097
+ });
10098
+ return () => {
10099
+ stopAutoDrag();
8486
10100
  range.removeEventListener("input", onInput);
8487
10101
  range.removeEventListener("keydown", onKeyDown);
8488
10102
  range.remove();
8489
10103
  handle.remove();
8490
- attrs.restore();
8491
10104
  style.restore();
10105
+ };
10106
+ }
10107
+ var PERCENT_TOKEN = /^(\d+(?:\.\d+)?)%$/;
10108
+ function resolvePositions(authored, count, warn) {
10109
+ const even = Array.from({ length: count }, (_, k) => Math.round(100 * (k + 1) / (count + 1)));
10110
+ if (authored.trim() === "") return even;
10111
+ const tokens = authored.trim().split(/\s+/);
10112
+ const values = tokens.map((token) => PERCENT_TOKEN.exec(token)?.[1]);
10113
+ const numbers = values.map(Number);
10114
+ const usable2 = tokens.length === count && values.every((value) => value !== void 0) && numbers.every((value, k) => value <= 100 && (k === 0 || value >= numbers[k - 1]));
10115
+ if (usable2) return numbers.map(Math.round);
10116
+ const example = even.map((value) => String(value) + "%").join(" ");
10117
+ warn(
10118
+ `compare positions: expected ${count} ascending percentages between 0% and 100% for ${count + 1} media (e.g. positions:'${example}'); using an even split`
10119
+ );
10120
+ return even;
10121
+ }
10122
+ function buildMulti({ el, media, params, ctx, axis, timing: timing3 }) {
10123
+ if (params.text("position", "50%") !== "50%") {
10124
+ ctx.warn(`compare position: sets the one divider of two media; with ${media.length}, use positions:`);
10125
+ }
10126
+ const positions = resolvePositions(params.text("positions", ""), media.length - 1, ctx.warn);
10127
+ const homes = [...positions];
10128
+ const attrs = createAttributeLedger(el);
10129
+ attrs.set(COMPARE_DIVIDERS_ATTR, String(homes.length));
10130
+ const dividers = createDividers(el, media, axis, positions);
10131
+ const surface = bindSurface(el, axis, dividers);
10132
+ const stopAutoDrag = bindAutoDrag({
10133
+ ...timing3,
10134
+ host: el,
10135
+ grab: [surface.node, ...dividers.ranges],
10136
+ ctx,
10137
+ write: dividers.render,
10138
+ homes,
10139
+ bounds: homes.map((_, k) => boundsOf(homes, k))
10140
+ });
10141
+ return () => {
10142
+ stopAutoDrag();
10143
+ surface.destroy();
10144
+ dividers.destroy();
10145
+ attrs.restore();
10146
+ };
10147
+ }
10148
+ function boundsOf(positions, k) {
10149
+ return [k > 0 ? positions[k - 1] : 0, k < positions.length - 1 ? positions[k + 1] : 100];
10150
+ }
10151
+ function dividerLabel(el, media, k) {
10152
+ const count = media.length - 1;
10153
+ const authored = el.getAttribute("aria-label")?.trim();
10154
+ if (authored) return `${authored}: divider ${k + 1} of ${count}`;
10155
+ const before = getMediaName(media[k], `Image ${k + 1}`);
10156
+ const after = getMediaName(media[k + 1], `Image ${k + 2}`);
10157
+ return `Divider ${k + 1} of ${count}: ${before} / ${after}`;
10158
+ }
10159
+ function createDividers(el, media, axis, positions) {
10160
+ const doc = el.ownerDocument;
10161
+ const count = positions.length;
10162
+ const clip = axis === "y" ? "inset(var(--kui-compare) 0 0 0)" : "inset(0 0 0 var(--kui-compare))";
10163
+ const mediaStyles = media.slice(1).map((item) => createStyleLedger(item));
10164
+ for (const ledger of mediaStyles) ledger.set("clip-path", clip);
10165
+ const ranges = [];
10166
+ const handles = [];
10167
+ const cleanups = [];
10168
+ const render = (k, shown) => {
10169
+ positions[k] = Math.round(shown);
10170
+ const css = `${Number(shown.toFixed(3))}%`;
10171
+ handles[k].style.setProperty("--kui-compare", css);
10172
+ mediaStyles[k].set("--kui-compare", css);
10173
+ ranges[k].value = String(positions[k]);
10174
+ ranges[k].setAttribute("aria-valuetext", `${positions[k]}%`);
10175
+ if (k > 0) ranges[k - 1].max = String(positions[k]);
10176
+ if (k < count - 1) ranges[k + 1].min = String(positions[k]);
10177
+ };
10178
+ for (let k = 0; k < count; k++) {
10179
+ const range = createRange(doc, { name: dividerLabel(el, media, k), valueText: `${positions[k]}%` }, positions[k], axis);
10180
+ const handle = createHandle(doc);
10181
+ ranges.push(range);
10182
+ handles.push(handle);
10183
+ el.append(range, handle);
10184
+ const onInput = () => render(k, Number(range.value));
10185
+ const onKeyDown = verticalKeys(range, axis, onInput);
10186
+ range.addEventListener("input", onInput);
10187
+ range.addEventListener("keydown", onKeyDown);
10188
+ cleanups.push(() => {
10189
+ range.removeEventListener("input", onInput);
10190
+ range.removeEventListener("keydown", onKeyDown);
10191
+ });
10192
+ }
10193
+ for (let k = 0; k < count; k++) {
10194
+ const [lo, hi] = boundsOf(positions, k);
10195
+ ranges[k].min = String(lo);
10196
+ ranges[k].max = String(hi);
10197
+ render(k, positions[k]);
10198
+ }
10199
+ return {
10200
+ ranges,
10201
+ render,
10202
+ moveTo(k, percent) {
10203
+ const [lo, hi] = boundsOf(positions, k);
10204
+ render(k, Math.min(hi, Math.max(lo, percent)));
10205
+ },
10206
+ nearest: (percent) => nearestDivider(positions, percent),
10207
+ destroy() {
10208
+ for (const cleanup of cleanups) cleanup();
10209
+ for (const node of [...ranges, ...handles]) node.remove();
10210
+ for (const ledger of mediaStyles) ledger.restore();
10211
+ }
10212
+ };
10213
+ }
10214
+ function nearestDivider(positions, percent) {
10215
+ let best = 0;
10216
+ for (let k = 1; k < positions.length; k++) {
10217
+ const distance2 = Math.abs(positions[k] - percent);
10218
+ const bestDistance = Math.abs(positions[best] - percent);
10219
+ if (distance2 < bestDistance || distance2 === bestDistance && percent >= positions[k]) best = k;
10220
+ }
10221
+ return best;
10222
+ }
10223
+ function bindSurface(el, axis, dividers) {
10224
+ const surface = el.ownerDocument.createElement("div");
10225
+ surface.className = "kui-compare-surface";
10226
+ surface.setAttribute("aria-hidden", "true");
10227
+ el.appendChild(surface);
10228
+ let dragging = -1;
10229
+ let pointerId = null;
10230
+ const percentAt = (event) => {
10231
+ const rect = el.getBoundingClientRect();
10232
+ const along = axis === "y" ? event.clientY - rect.top : event.clientX - rect.left;
10233
+ const size = axis === "y" ? rect.height : rect.width;
10234
+ return size > 0 ? 100 * along / size : 0;
10235
+ };
10236
+ const onPointerDown = (event) => {
10237
+ if (event.button > 0 || pointerId !== null) return;
10238
+ pointerId = event.pointerId;
10239
+ const percent = percentAt(event);
10240
+ dragging = dividers.nearest(percent);
10241
+ event.preventDefault();
10242
+ surface.setPointerCapture?.(event.pointerId);
10243
+ dividers.ranges[dragging].focus({ preventScroll: true });
10244
+ dividers.moveTo(dragging, percent);
10245
+ };
10246
+ const onPointerMove = (event) => {
10247
+ if (dragging >= 0 && event.pointerId === pointerId) dividers.moveTo(dragging, percentAt(event));
10248
+ };
10249
+ const onPointerUp = (event) => {
10250
+ if (event.pointerId !== pointerId) return;
10251
+ dragging = -1;
10252
+ pointerId = null;
10253
+ };
10254
+ const onLostCapture = (event) => {
10255
+ if (event.target !== surface) return;
10256
+ onPointerUp(event);
10257
+ };
10258
+ const listeners = [
10259
+ ["pointerdown", onPointerDown],
10260
+ ["pointermove", onPointerMove],
10261
+ ["pointerup", onPointerUp],
10262
+ ["pointercancel", onPointerUp],
10263
+ ["lostpointercapture", onLostCapture]
10264
+ ];
10265
+ for (const [type, listener] of listeners) surface.addEventListener(type, listener);
10266
+ return {
10267
+ node: surface,
10268
+ destroy() {
10269
+ for (const [type, listener] of listeners) surface.removeEventListener(type, listener);
10270
+ surface.remove();
10271
+ }
10272
+ };
10273
+ }
10274
+ function prepareCompare(el, params, ctx) {
10275
+ const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
10276
+ if (media.length < 2) {
10277
+ ctx.warn("compare requires at least two media children (img, picture, or video)");
10278
+ }
10279
+ const axis = params.text("axis", "x");
10280
+ const attrs = createAttributeLedger(el);
10281
+ attrs.set(COMPARE_AXIS_ATTR, axis);
10282
+ const build2 = media.length > 2 ? buildMulti : buildPair;
10283
+ const teardown = build2({
10284
+ el,
10285
+ media,
10286
+ params,
10287
+ ctx,
10288
+ axis,
10289
+ timing: {
10290
+ mode: params.text("autoplay", "never"),
10291
+ durationMs: sweepDuration(params, ctx),
10292
+ reverse: params.is("reverse"),
10293
+ loop: params.is("loop")
10294
+ }
10295
+ });
10296
+ return continuousSetup(() => {
10297
+ teardown();
10298
+ attrs.restore();
8492
10299
  });
8493
10300
  }
8494
10301
  var COMPARE_PRIMITIVE = widgetPrimitive(
8495
10302
  "compare",
8496
10303
  {
8497
- channels: ["clip", "widget"],
10304
+ // The layers it stacks and the range, handle and surface it appends; the axis and divider
10305
+ // markers it writes on the host. Not `clip`: its `clip-path` is on the after layer, never the
10306
+ // host, so a `wipe-up` revealing the whole slider is not a fight over one property.
10307
+ channels: [SUBTREE_CHANNEL, attributeChannel(COMPARE_AXIS_ATTR), attributeChannel(COMPARE_DIVIDERS_ATTR)],
8498
10308
  parameters: compareParams,
8499
10309
  perfClass: "paint"
8500
10310
  },
8501
10311
  withTimingContract(
8502
10312
  "compare",
8503
10313
  {
8504
- because: "it builds an interactive before/after slider; there is no motion to time"
10314
+ honours: ["duration"],
10315
+ because: "its only motion is the autoplay: sweep, which starts on autoplay: rather than after a delay and eases into each edge on its own curve"
8505
10316
  },
8506
10317
  deferPrepare(prepareCompare)
8507
10318
  )
@@ -8675,7 +10486,8 @@ function prepareHotspots(el, params, ctx) {
8675
10486
  var HOTSPOTS_PRIMITIVE = widgetPrimitive(
8676
10487
  "hotspots",
8677
10488
  {
8678
- channels: ["widget"],
10489
+ // The marker buttons it appends, placed in percent of the host, and the marker style it writes.
10490
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-hotspot-marker")],
8679
10491
  parameters: hotspotsParams,
8680
10492
  perfClass: "layout"
8681
10493
  },
@@ -8879,7 +10691,7 @@ function bindAutoplay(options) {
8879
10691
  hovered = true;
8880
10692
  update();
8881
10693
  };
8882
- const leave = () => {
10694
+ const leave2 = () => {
8883
10695
  hovered = false;
8884
10696
  update();
8885
10697
  };
@@ -8896,7 +10708,7 @@ function bindAutoplay(options) {
8896
10708
  update();
8897
10709
  };
8898
10710
  el.addEventListener("pointerenter", enter);
8899
- el.addEventListener("pointerleave", leave);
10711
+ el.addEventListener("pointerleave", leave2);
8900
10712
  el.addEventListener("focusin", focus);
8901
10713
  el.addEventListener("focusout", blur);
8902
10714
  ctx.doc.addEventListener("visibilitychange", update);
@@ -8905,7 +10717,7 @@ function bindAutoplay(options) {
8905
10717
  return () => {
8906
10718
  if (timer !== null) clearTimeout(timer);
8907
10719
  el.removeEventListener("pointerenter", enter);
8908
- el.removeEventListener("pointerleave", leave);
10720
+ el.removeEventListener("pointerleave", leave2);
8909
10721
  el.removeEventListener("focusin", focus);
8910
10722
  el.removeEventListener("focusout", blur);
8911
10723
  ctx.doc.removeEventListener("visibilitychange", update);
@@ -8937,7 +10749,7 @@ function bindSwipe(el, params, index) {
8937
10749
  return recognise(el, { onSwipe: (direction) => {
8938
10750
  if (direction === "left") index.next();
8939
10751
  if (direction === "right") index.prev();
8940
- } }, { axis: "x", capturePointer: false });
10752
+ } }, { axis: "x", capturePointer: "drag" });
8941
10753
  }
8942
10754
  function prepareSlideshow(el, params, ctx) {
8943
10755
  const selector = resolveTarget(
@@ -8971,7 +10783,6 @@ function prepareSlideshow(el, params, ctx) {
8971
10783
  el,
8972
10784
  params,
8973
10785
  ctx,
8974
- scope,
8975
10786
  resolveSteps,
8976
10787
  name: "slideshow",
8977
10788
  clickFallback: false,
@@ -8999,7 +10810,19 @@ function prepareSlideshow(el, params, ctx) {
8999
10810
  }
9000
10811
  var SLIDESHOW_PRIMITIVE = widgetPrimitive(
9001
10812
  "slideshow",
9002
- { channels: ["widget"], parameters: slideshowParams, perfClass: "layout" },
10813
+ // The controls it inserts, then what it writes on the host: `createStepIndex`'s `data-kui-step`,
10814
+ // as every step deck does, and `decorateHost`'s mode and carousel roles.
10815
+ {
10816
+ channels: [
10817
+ SUBTREE_CHANNEL,
10818
+ attributeChannel("data-kui-step"),
10819
+ attributeChannel(SLIDE_MODE_ATTR),
10820
+ attributeChannel("role"),
10821
+ attributeChannel("aria-roledescription")
10822
+ ],
10823
+ parameters: slideshowParams,
10824
+ perfClass: "layout"
10825
+ },
9003
10826
  withTimingContract(
9004
10827
  "slideshow",
9005
10828
  { honours: ["duration", "ease"], because: "the slideshow has no delayed start" },
@@ -9161,7 +10984,14 @@ function prepareScrollStory(el, params, ctx) {
9161
10984
  }
9162
10985
  var SCROLL_STORY_PRIMITIVE = widgetPrimitive(
9163
10986
  "scroll-story",
9164
- { channels: ["widget"], parameters: scrollStoryParams, perfClass: "layout" },
10987
+ // Its stylesheet lays out the host's first and last child, so nothing else may insert one. The
10988
+ // step index on the host (`applyStep`) is claimed so a `carousel` or `scroll-progress` beside it
10989
+ // is refused rather than left to overwrite it.
10990
+ {
10991
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-step"), attributeChannel(STORY_SIDE_ATTR)],
10992
+ parameters: scrollStoryParams,
10993
+ perfClass: "layout"
10994
+ },
9165
10995
  withTimingContract(
9166
10996
  "scroll-story",
9167
10997
  { because: "scroll position" },
@@ -9264,7 +11094,8 @@ function prepareSlowMo(el, params) {
9264
11094
  }
9265
11095
  var SLOW_MO_PRIMITIVE = widgetPrimitive(
9266
11096
  "slow-mo",
9267
- { channels: ["widget"], parameters: slowMoParams, perfClass: "compositor" },
11097
+ // The toggle it prepends into the host, and the time scale it writes there.
11098
+ { channels: [SUBTREE_CHANNEL, attributeChannel(TIME_SCALE_ATTR)], parameters: slowMoParams, perfClass: "compositor" },
9268
11099
  withTimingContract(
9269
11100
  "slow-mo",
9270
11101
  { because: "it changes playback rate rather than defining an animation timeline" },
@@ -9293,6 +11124,7 @@ var SHOWCASE_PRESETS = [
9293
11124
  ...SLOW_MO_PRESETS
9294
11125
  ];
9295
11126
  function registerShowcase(target) {
11127
+ provideDeckViewer(attachDeckLightbox);
9296
11128
  return registerInto(target, SHOWCASE_PRIMITIVES, SHOWCASE_PRESETS, "Showcase");
9297
11129
  }
9298
11130