kuinetic 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/dist/esm/{chunk-XDDJVANU.mjs → chunk-EHS45PIK.mjs} +2346 -536
  3. package/dist/esm/{chunk-K2M7O6W5.mjs → chunk-FASAEQDR.mjs} +32 -1
  4. package/dist/esm/{chunk-BBQ22D7A.mjs → chunk-MZQG2VMG.mjs} +202 -2
  5. package/dist/esm/core/index.mjs +21 -3
  6. package/dist/esm/effects/index.mjs +2 -2
  7. package/dist/esm/index.mjs +35 -4
  8. package/dist/esm/notes/index.mjs +1083 -0
  9. package/dist/kuinetic.all.js +2547 -509
  10. package/dist/kuinetic.all.min.js +2 -2
  11. package/dist/kuinetic.css +338 -76
  12. package/dist/kuinetic.js +2546 -508
  13. package/dist/kuinetic.min.css +1 -1
  14. package/dist/kuinetic.min.js +1 -1
  15. package/dist/kuinetic.notes.js +1 -0
  16. package/dist/types/3d/flattening.d.ts +8 -7
  17. package/dist/types/core/deck-viewer.d.ts +87 -0
  18. package/dist/types/core/describe.d.ts +174 -0
  19. package/dist/types/core/gesture.d.ts +10 -0
  20. package/dist/types/core/index.d.ts +6 -2
  21. package/dist/types/core/target.d.ts +28 -0
  22. package/dist/types/core/timing-contract.d.ts +61 -0
  23. package/dist/types/core/types.d.ts +59 -0
  24. package/dist/types/effects/auto-motion.d.ts +169 -0
  25. package/dist/types/effects/carousel/deck.d.ts +170 -0
  26. package/dist/types/effects/carousel/drag.d.ts +6 -0
  27. package/dist/types/effects/carousel/fit.d.ts +130 -0
  28. package/dist/types/effects/carousel/index.d.ts +13 -23
  29. package/dist/types/effects/carousel/stack.d.ts +77 -0
  30. package/dist/types/effects/catalog/interaction-reveal.d.ts +69 -9
  31. package/dist/types/effects/forms/primitives.d.ts +1 -2
  32. package/dist/types/effects/gestures/index.d.ts +3 -4
  33. package/dist/types/effects/shared.d.ts +3 -30
  34. package/dist/types/effects/step-index.d.ts +48 -10
  35. package/dist/types/effects/swipe-event.d.ts +75 -0
  36. package/dist/types/index.d.ts +26 -0
  37. package/dist/types/notes/catalog-core.d.ts +3 -0
  38. package/dist/types/notes/catalog.d.ts +3 -0
  39. package/dist/types/notes/index.d.ts +13 -0
  40. package/dist/types/notes/interaction-reveal.d.ts +3 -0
  41. package/dist/types/notes/interaction.d.ts +6 -0
  42. package/dist/types/notes/keys.d.ts +7 -0
  43. package/dist/types/notes/mechanics.d.ts +7 -0
  44. package/dist/types/notes/scroll-nav-forms.d.ts +6 -0
  45. package/dist/types/notes/shared.d.ts +6 -0
  46. package/dist/types/notes/showcase.d.ts +7 -0
  47. package/dist/types/notes/text.d.ts +3 -0
  48. package/dist/types/showcase/compare.d.ts +9 -0
  49. package/dist/types/showcase/index.d.ts +3 -0
  50. package/dist/types/showcase/lightbox.d.ts +24 -1
  51. package/dist/types/showcase/modal-shell.d.ts +11 -0
  52. package/package.json +7 -3
@@ -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-K2M7O6W5.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
@@ -4660,6 +5354,17 @@ function recognise(el, handlers, options = {}, deps = defaultGestureDeps()) {
4660
5354
  longPressTimer = null;
4661
5355
  }
4662
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
+ }
4663
5368
  origin = sampleOf(event);
4664
5369
  samples = [origin];
4665
5370
  active = false;
@@ -4743,7 +5448,9 @@ function recognise(el, handlers, options = {}, deps = defaultGestureDeps()) {
4743
5448
  stopListeningOnDocument();
4744
5449
  releaseCapture(event.pointerId);
4745
5450
  if (!origin) return;
4746
- const sample = sampleOf(event);
5451
+ finish(sampleOf(event));
5452
+ }
5453
+ function finish(sample) {
4747
5454
  samples.push(sample);
4748
5455
  reportEnd(vectorNow(sample), sample);
4749
5456
  origin = null;
@@ -4752,6 +5459,7 @@ function recognise(el, handlers, options = {}, deps = defaultGestureDeps()) {
4752
5459
  samples = [];
4753
5460
  }
4754
5461
  function onLostCapture(event) {
5462
+ if (event.target !== el) return;
4755
5463
  if (!captured || event.pointerId !== pointerId) return;
4756
5464
  captured = false;
4757
5465
  onUp(event);
@@ -4906,16 +5614,19 @@ function settle(runners, offset, vector, mode) {
4906
5614
  runners.x.to(offset.x + vector.vx * carry);
4907
5615
  runners.y.to(offset.y + vector.vy * carry);
4908
5616
  }
4909
- 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`);
4910
5620
  const stop = recognise(
4911
5621
  el,
4912
5622
  {
4913
5623
  onSwipe(direction) {
4914
5624
  el.setAttribute("data-kui-swipe", direction);
5625
+ announceSwipe(ctx.win, el, direction);
4915
5626
  }
4916
5627
  },
4917
5628
  {
4918
- axis: params.text("axis", "both"),
5629
+ axis,
4919
5630
  swipeVelocity: params.num("velocity", 300),
4920
5631
  // This primitive moves nothing, so it must not hold the pointer from `pointerdown` — that
4921
5632
  // retargets the following `click` at this element and kills every interactive child (a
@@ -4981,7 +5692,7 @@ function prepareMagnetic(el, params, ctx) {
4981
5692
  var GESTURE_PRIMITIVES = [
4982
5693
  gesturePrimitive(
4983
5694
  "draggable",
4984
- ["translate"],
5695
+ ["translate", attributeChannel("data-kui-dragging")],
4985
5696
  {
4986
5697
  ...springParams2,
4987
5698
  axis: { type: "keyword", default: "both", cssProperty: "--kui-axis", keywords: ["x", "y", "both"] },
@@ -5012,7 +5723,7 @@ var GESTURE_PRIMITIVES = [
5012
5723
  ),
5013
5724
  gesturePrimitive(
5014
5725
  "swipeable",
5015
- ["state"],
5726
+ [attributeChannel("data-kui-swipe")],
5016
5727
  {
5017
5728
  axis: { type: "keyword", default: "both", cssProperty: "--kui-axis", keywords: ["x", "y", "both"] },
5018
5729
  velocity: { type: "number", default: "300", cssProperty: "--kui-velocity" }
@@ -5021,7 +5732,7 @@ var GESTURE_PRIMITIVES = [
5021
5732
  ),
5022
5733
  gesturePrimitive(
5023
5734
  "pressable",
5024
- ["state"],
5735
+ [attributeChannel("data-kui-pressed")],
5025
5736
  { duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" } },
5026
5737
  // `duration` here is the hold threshold, not a span of motion — `long-press 800ms` means
5027
5738
  // "count it once the finger has been down for 800ms". It is read (see `preparePressable`), so
@@ -5072,6 +5783,7 @@ var GESTURE_PRESETS = [
5072
5783
  },
5073
5784
  { name: "swipe", primitive: "swipeable", phase: "state" },
5074
5785
  { name: "swipe-x", primitive: "swipeable", params: { axis: "x" }, phase: "state" },
5786
+ { name: "swipe-y", primitive: "swipeable", params: { axis: "y" }, phase: "state" },
5075
5787
  { name: "long-press", primitive: "pressable", phase: "state" },
5076
5788
  { name: "magnetic", primitive: "magnetic", phase: "state" },
5077
5789
  {
@@ -5367,13 +6079,15 @@ var LAYOUT_PRIMITIVES = [
5367
6079
  ),
5368
6080
  layoutPrimitive(
5369
6081
  "auto-height",
5370
- ["layout"],
6082
+ // The two properties it writes on the panel: `overflow: hidden`, and the `height` it animates.
6083
+ ["overflow", "height"],
5371
6084
  { attribute: { type: "text", default: "data-open", cssProperty: "--kui-attribute" } },
5372
6085
  deferPrepare(prepareAutoHeight)
5373
6086
  ),
5374
6087
  layoutPrimitive(
5375
6088
  "flip-indicator",
5376
- ["translate", "layout"],
6089
+ // The bar's landing spot (`translate`) and its `width`, matched to the selected tab.
6090
+ ["translate", "width"],
5377
6091
  {
5378
6092
  follow: { type: "text", default: "", cssProperty: "--kui-follow" },
5379
6093
  attribute: { type: "text", default: "aria-selected", cssProperty: "--kui-attribute" }
@@ -5622,26 +6336,25 @@ function prepareBackToTop(el, params, ctx) {
5622
6336
  var NAV_JS_PRIMITIVES = [
5623
6337
  navPrimitive(
5624
6338
  "header-shrink",
5625
- // `'shadow'` alongside `'layout'`: `header-shrink`'s host rule also transitions `box-shadow`
5626
- // (navigation.css), which `layout` alone does not cover — see `header-shrink`'s own
5627
- // `Preset.transitions` below and `channel-properties.ts`'s `layout` entry for why the two
5628
- // interpolated properties (`padding-block`/`font-size`) stay on `layout` while this one moves
5629
- // to the channel that already owns every other box-shadow writer (`lift-shadow`,
5630
- // `border-glow`). Composing `header-shrink` with either of those is now a refused conflict
5631
- // instead of a silent clobber on the same property — the self-consistency bug this closes.
5632
- ["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")],
5633
6346
  { offset: { type: "number", default: "120", cssProperty: "--kui-offset" } },
5634
6347
  deferPrepare(prepareHeaderShrink)
5635
6348
  ),
5636
6349
  navPrimitive(
5637
6350
  "header-hide-on-scroll",
5638
- [CHANNEL.translate],
6351
+ [CHANNEL.translate, attributeChannel("data-kui-hidden")],
5639
6352
  { offset: { type: "number", default: "8", cssProperty: "--kui-offset" } },
5640
6353
  deferPrepare(prepareHeaderHide)
5641
6354
  ),
5642
6355
  navPrimitive(
5643
6356
  "back-to-top-fade",
5644
- [CHANNEL.opacity, CHANNEL.translate],
6357
+ [CHANNEL.opacity, CHANNEL.translate, attributeChannel("data-kui-visible")],
5645
6358
  { offset: { type: "number", default: "400", cssProperty: "--kui-offset" } },
5646
6359
  deferPrepare(prepareBackToTop)
5647
6360
  )
@@ -6204,7 +6917,10 @@ function installSnapContainer(axis, ctx) {
6204
6917
  var SCROLL_PRIMITIVES = [
6205
6918
  scrollPrimitive({
6206
6919
  id: "pin",
6207
- 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")],
6208
6924
  parameters: {
6209
6925
  ...distanceParam2,
6210
6926
  ...stickyParams
@@ -6214,7 +6930,13 @@ var SCROLL_PRIMITIVES = [
6214
6930
  }),
6215
6931
  scrollPrimitive({
6216
6932
  id: "scroll-progress",
6217
- 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")],
6218
6940
  parameters: {
6219
6941
  ...distanceParam2,
6220
6942
  steps: { type: "number", default: "0", cssProperty: "--kui-steps" },
@@ -6250,7 +6972,8 @@ var SCROLL_PRIMITIVES = [
6250
6972
  }),
6251
6973
  scrollPrimitive({
6252
6974
  id: "media-scrub",
6253
- channels: ["media", "progress"],
6975
+ // `position`: it holds its host the way a pin does (`position: sticky` and its `top`).
6976
+ channels: ["media", "progress", "position"],
6254
6977
  parameters: {
6255
6978
  ...distanceParam2,
6256
6979
  // A scrub is a hold, so it needs the same two knobs a pin does. Declaring them here is what
@@ -6269,7 +6992,7 @@ var SCROLL_PRIMITIVES = [
6269
6992
  }),
6270
6993
  scrollPrimitive({
6271
6994
  id: "scroll-spy",
6272
- channels: ["state"],
6995
+ channels: [attributeChannel("data-kui-active")],
6273
6996
  parameters: {
6274
6997
  // `distance`: the per-section form only. `offset-top`: the container form only. Each is a
6275
6998
  // no-op — warned, not silent — in the other; see `prepareScrollSpySingle` and
@@ -6295,8 +7018,8 @@ var SCROLL_PRIMITIVES = [
6295
7018
  scrollPrimitive({
6296
7019
  id: "smooth-scroll",
6297
7020
  /*
6298
- * Its own channel, not the `'layout'` it used to share with `pin`, `stacking-cards` and
6299
- * `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
6300
7023
  * property, and this one writes exactly `scroll-behavior` — a property that describes how a
6301
7024
  * *user-or-script-initiated* scroll is performed, and that no other primitive touches.
6302
7025
  *
@@ -6317,7 +7040,11 @@ var SCROLL_PRIMITIVES = [
6317
7040
  }),
6318
7041
  scrollPrimitive({
6319
7042
  id: "scroll-snap",
6320
- 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],
6321
7048
  parameters: {
6322
7049
  axis: { type: "keyword", default: "y", cssProperty: "--kui-axis", keywords: ["x", "y"] },
6323
7050
  strictness: {
@@ -6659,7 +7386,7 @@ function findBars(el) {
6659
7386
  return children.filter((child) => child.textContent.trim() === "");
6660
7387
  }
6661
7388
  function withIconParts(inner) {
6662
- return (el, params, ctx) => {
7389
+ return inheritTimingContract(inner, (el, params, ctx) => {
6663
7390
  if (!el.querySelector(".kui-bar")) {
6664
7391
  const bars = findBars(el);
6665
7392
  if (bars.length > 0) {
@@ -6678,7 +7405,7 @@ function withIconParts(inner) {
6678
7405
  }
6679
7406
  }
6680
7407
  return inner(el, params, ctx);
6681
- };
7408
+ });
6682
7409
  }
6683
7410
 
6684
7411
  // src/effects/svg/index.ts
@@ -6717,18 +7444,18 @@ function prepareMorph(el, params, ctx) {
6717
7444
  };
6718
7445
  let current = 0;
6719
7446
  const enter = () => drive(1);
6720
- const leave = () => drive(0);
7447
+ const leave2 = () => drive(0);
6721
7448
  el.addEventListener("pointerenter", enter, { passive: true });
6722
7449
  el.addEventListener("focusin", enter, { passive: true });
6723
- el.addEventListener("pointerleave", leave, { passive: true });
6724
- el.addEventListener("focusout", leave, { passive: true });
7450
+ el.addEventListener("pointerleave", leave2, { passive: true });
7451
+ el.addEventListener("focusout", leave2, { passive: true });
6725
7452
  return () => {
6726
7453
  cancelled = true;
6727
7454
  cancelAnimationFrame(frame);
6728
7455
  el.removeEventListener("pointerenter", enter);
6729
7456
  el.removeEventListener("focusin", enter);
6730
- el.removeEventListener("pointerleave", leave);
6731
- el.removeEventListener("focusout", leave);
7457
+ el.removeEventListener("pointerleave", leave2);
7458
+ el.removeEventListener("focusout", leave2);
6732
7459
  path2.setAttribute("d", startPath);
6733
7460
  };
6734
7461
  }
@@ -6749,7 +7476,16 @@ var LOGO_BUILD_PRIMITIVE = cssPrimitive("logo-assemble", [
6749
7476
  var ICON_TOGGLE_PRIMITIVE = {
6750
7477
  id: "icon-toggle",
6751
7478
  renderer: "javascript",
6752
- 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
+ ],
6753
7489
  parameters: {
6754
7490
  duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
6755
7491
  // The state flip is the start moment: `aria-expanded` goes true and the bars begin to move.
@@ -6947,8 +7683,10 @@ var CARD_TOGGLE_PRIMITIVE = {
6947
7683
  // `'discrete'` alongside `rotate`: the unconditional `[data-kui-fx~='flip-card']` rule pins
6948
7684
  // `display: grid` so the two faces stack in one cell instead of flowing as normal block
6949
7685
  // siblings — unrelated to `catalog/discrete.ts`'s show/hide use of the same physical property,
6950
- // but `display` is tracked as one channel regardless of the value written into it.
6951
- 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],
6952
7690
  parameters: {
6953
7691
  duration: { type: "time", default: "700ms", cssProperty: "--kui-duration" },
6954
7692
  // The turn has a start moment — the click, or the pointer arriving — so a delay before it is
@@ -7086,8 +7824,8 @@ function projectRelease(position, velocityPxPerSec, travelPx) {
7086
7824
  function snapTo(projected) {
7087
7825
  return Math.round(projected) + 0;
7088
7826
  }
7089
- function wrapPlace(place, total) {
7090
- return total > 0 ? (place % total + total) % total : 0;
7827
+ function wrapPlace(place2, total) {
7828
+ return total > 0 ? (place2 % total + total) % total : 0;
7091
7829
  }
7092
7830
  var KEY_DELTAS = {
7093
7831
  ArrowRight: 1,
@@ -7099,8 +7837,26 @@ var KEY_DELTAS = {
7099
7837
  Home: null,
7100
7838
  End: null
7101
7839
  };
7102
- function createRingDrag(request) {
7103
- 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) {
7104
7860
  let suppressClick = false;
7105
7861
  const onClickCapture = (event) => {
7106
7862
  if (!suppressClick) return;
@@ -7108,19 +7864,49 @@ function createRingDrag(request) {
7108
7864
  event.stopPropagation();
7109
7865
  event.preventDefault();
7110
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);
7111
7893
  const stopRecognising = enabled ? recognise(
7112
7894
  el,
7113
7895
  {
7114
7896
  onStart() {
7897
+ dragFrom = positionOf();
7898
+ sign = request.travelSign();
7899
+ selection.hold();
7115
7900
  setDragging(true);
7116
7901
  },
7117
7902
  onMove(vector) {
7118
- moveTo(positionOf() - vector.dx / travelPx);
7903
+ moveTo(dragFrom - sign * vector.dx / travelPx);
7119
7904
  },
7120
7905
  onEnd(vector) {
7906
+ selection.release();
7121
7907
  setDragging(false);
7122
- suppressClick = true;
7123
- const settled = snapTo(projectRelease(positionOf(), vector.vx, travelPx));
7908
+ clicks.arm();
7909
+ const settled = snapTo(projectRelease(positionOf(), sign * vector.vx, travelPx));
7124
7910
  moveTo(wrapPlace(settled, total()));
7125
7911
  }
7126
7912
  },
@@ -7129,7 +7915,15 @@ function createRingDrag(request) {
7129
7915
  // threshold than `recognise`'s own default of 4px is what buys that — a ring occupies a
7130
7916
  // large slab of a phone screen, and a scroll that begins with a two-pixel horizontal wobble
7131
7917
  // should not capture the pointer away from the page.
7132
- { 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" }
7133
7927
  ) : () => {
7134
7928
  };
7135
7929
  if (enabled) ctx.style.set("touch-action", "pan-y");
@@ -7148,216 +7942,213 @@ function createRingDrag(request) {
7148
7942
  const grantedFocus = el.getAttribute("tabindex") === null;
7149
7943
  if (grantedFocus) el.setAttribute("tabindex", "0");
7150
7944
  el.addEventListener("keydown", onKeyDown);
7151
- el.addEventListener("click", onClickCapture, true);
7152
7945
  return () => {
7153
7946
  stopRecognising();
7947
+ selection.release();
7154
7948
  el.removeEventListener("keydown", onKeyDown);
7155
- el.removeEventListener("click", onClickCapture, true);
7949
+ clicks.release();
7156
7950
  if (grantedFocus) el.removeAttribute("tabindex");
7157
7951
  };
7158
7952
  }
7159
7953
 
7160
- // src/effects/carousel/index.ts
7954
+ // src/effects/carousel/deck.ts
7161
7955
  var DRAGGING_ATTR = "data-kui-ring-dragging";
7162
- var FACE_ATTR = "data-kui-ring-face";
7163
- var QUARTER_TURN_DEG = 90;
7164
- var FLATTENING = [
7165
- ["overflow", (value) => value !== "" && value !== "visible"],
7166
- ["clip-path", (value) => value !== "" && value !== "none"],
7167
- ["opacity", (value) => value !== "" && Number(value) < 1],
7168
- ["filter", (value) => value !== "" && value !== "none"],
7169
- ["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")
7170
7961
  ];
7171
- function warnFlatteningAncestor(el, ctx) {
7172
- const body = el.ownerDocument.body;
7173
- let node = el.parentElement;
7174
- while (node && node !== body) {
7175
- const found = flatteningDeclaration(node, ctx);
7176
- if (found) {
7177
- ctx.warn(
7178
- `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.`
7179
- );
7180
- return;
7181
- }
7182
- node = node.parentElement;
7183
- }
7184
- }
7185
- function flatteningDeclaration(node, ctx) {
7186
- const style = ctx.win.getComputedStyle?.(node);
7187
- if (!style) return null;
7188
- for (const [property2, flattens] of FLATTENING) {
7189
- const value = style.getPropertyValue(property2);
7190
- if (flattens(value)) return { property: property2, value };
7191
- }
7192
- return null;
7193
- }
7194
- function ledgersFor(store, node) {
7195
- let attributes = store.attributes.get(node);
7196
- if (!attributes) {
7197
- attributes = createAttributeLedger(node);
7198
- store.attributes.set(node, attributes);
7199
- }
7200
- let styles = store.styles.get(node);
7201
- if (!styles) {
7202
- styles = createStyleLedger(node);
7203
- store.styles.set(node, styles);
7204
- }
7205
- 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;
7206
7967
  }
7207
- function faceAt(angleDeg) {
7208
- 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;
7209
7972
  }
7210
- function normaliseDegrees(angleDeg) {
7211
- const wrapped = (angleDeg % 360 + 360) % 360;
7212
- return wrapped > 180 ? wrapped - 360 : wrapped;
7973
+ function offsetWrapped(previous, next, count) {
7974
+ return Math.abs(next - previous) > count / 2;
7213
7975
  }
7214
7976
  function snapPosition(position, total) {
7215
7977
  if (total <= 0) return { step: 0, drift: 0 };
7216
7978
  const nearest = Math.round(position);
7217
7979
  return { step: (nearest % total + total) % total, drift: position - nearest };
7218
7980
  }
7219
- var RING_PARAMETERS = {
7981
+ var DECK_PARAMETERS = {
7220
7982
  duration: { type: "time", default: "620ms", cssProperty: "--kui-duration" },
7221
7983
  ...TRIGGER_DELAY_PARAM,
7222
7984
  ease: { type: "easing", default: "cubic-bezier(0.22, 1, 0.36, 1)", cssProperty: "--kui-ease" },
7223
- /*
7224
- * A real CSS `<angle>`, not a normalised scalar.
7225
- *
7226
- * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
7227
- * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
7228
- * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
7229
- * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
7230
- * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
7231
- * `clamp()` an author can see in devtools rather than a hidden rescale.
7232
- *
7233
- * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
7234
- * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
7235
- * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
7236
- * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
7237
- */
7238
- tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
7239
- /*
7240
- * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
7241
- *
7242
- * This is the parameter the concave name exists around. From the centre of a *full* ring most of
7243
- * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
7244
- * two thirds of the content — the inside name defaults to a slice for that reason.
7245
- */
7246
- arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
7247
- /*
7248
- * The ring's radius, or unset for one derived from the content.
7249
- *
7250
- * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
7251
- * `readParams` fills every declared default in unconditionally, so an empty one is the only way
7252
- * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
7253
- * the circumradius of a regular polygon with this many sides and this much side length —
7254
- * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
7255
- * Every consumer solving that by hand was the alternative, and they would all have solved it
7256
- * differently.
7257
- *
7258
- * The width half of that has to come from JavaScript: CSS can compute with a measurement but
7259
- * cannot take one. See `measureItemWidth`.
7260
- */
7261
- radius: { type: "length", default: "", cssProperty: "--kui-radius" },
7262
- /** Breathing room between neighbours, spent by the derived radius above. */
7263
- gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
7264
- /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
7265
- perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
7266
- /*
7267
- * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
7268
- *
7269
- * `radial` is the ring's natural state and needs nothing: a slot placed by
7270
- * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
7271
- * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
7272
- * ring read as the far side.
7273
- *
7274
- * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
7275
- * and the billboard have to stay on separate boxes or every later change to one silently
7276
- * multiplies into the other — so text stays readable at every position, which is what a raised
7277
- * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
7278
- */
7279
- facing: {
7280
- type: "keyword",
7281
- default: "radial",
7282
- cssProperty: "--kui-facing",
7283
- keywords: ["radial", "camera"]
7284
- },
7285
- /** Whether a pointer can spin the ring by hand. */
7985
+ /** Whether a pointer can move the deck by hand. */
7286
7986
  grab: { type: "keyword", default: "true", cssProperty: "--kui-grab", keywords: ["true", "false"] },
7287
7987
  /*
7288
- * How far the pointer travels, in pixels, to move the ring one place.
7988
+ * How far the pointer travels, in pixels, to move the deck one place.
7289
7989
  *
7290
7990
  * A pixels-per-step mapping rather than pixels-per-degree, because a step is the unit everything
7291
7991
  * else here is in — the index, the offset, the snap — and a degree is not: the same drag would
7292
7992
  * move a six-item ring one place and a sixty-item ring ten, purely because the spacing changed.
7293
7993
  */
7294
7994
  travel: { type: "number", default: "220", cssProperty: "--kui-travel", finite: true, minimum: 1 },
7295
- /** Which elements sit on the ring. Unset means this element's own children. */
7995
+ /** Which elements are the slides. Unset means this element's own children. */
7296
7996
  target: { type: "text", default: "", cssProperty: "--kui-target" },
7297
7997
  /** Optional controls, resolved exactly as `step-progress` resolves its own. */
7298
7998
  next: { type: "text", default: "", cssProperty: "--kui-next" },
7299
7999
  prev: { type: "text", default: "", cssProperty: "--kui-prev" },
7300
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,
8005
+ /*
8006
+ * Continuous rotation: how long one full cycle of the deck takes. `0s` is off.
8007
+ *
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.
8014
+ */
8015
+ spin: { type: "time", default: "0s", cssProperty: "--kui-spin" },
8016
+ /*
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`).
8019
+ */
8020
+ autoplay: AUTOPLAY_PARAM,
8021
+ /*
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.
8026
+ */
8027
+ lightbox: DECK_LIGHTBOX_PARAM,
7301
8028
  scope: SCOPE_PARAM
7302
8029
  };
7303
- function measureItemWidth(styles, slots) {
7304
- for (const slot of slots) {
7305
- const width = slot.offsetWidth;
7306
- if (width > 0) styles.set("--kui-item-width", `${width}px`);
7307
- return;
7308
- }
7309
- }
7310
- function prepareSpatialRing(el, params, ctx) {
7311
- warnFlatteningAncestor(el, ctx);
7312
- mirrorTimingToCss("spatial-ring", ALL_TIMING_TOKENS, params, ctx);
7313
- 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);
7314
8048
  const scope = scopeParam(params, "self");
7315
8049
  const resolveSlots = () => selector ? queryScoped(el, ctx, selector, scope) : el.children;
7316
- const marker = createStepMarker(resolveSlots, (message) => ctx.warn(`carousel ${message}`));
8050
+ const marker = createStepMarker(resolveSlots, (message) => ctx.warn(`${label} ${message}`));
7317
8051
  const total = () => countSteps(params, resolveSlots);
7318
8052
  const hostAttributes = createAttributeLedger(el);
7319
8053
  const hostStyles = createStyleLedger(el);
7320
- hostAttributes.set("data-kui-ring-facing", params.text("facing", "radial"));
7321
- const slots = { attributes: /* @__PURE__ */ new Map(), styles: /* @__PURE__ */ new Map() };
7322
- 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);
7323
8057
  let position = 0;
7324
- const arcDeg = degreesOf(params.text("arc", "360deg"), 360);
7325
- const render = () => {
7326
- const count = total();
7327
- const { step, drift: drift2 } = snapPosition(position, count);
7328
- hostAttributes.set("data-kui-step", String(step));
7329
- hostStyles.set("--kui-step", String(step));
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) => {
7330
8081
  hostStyles.set("--kui-step-position", (step + drift2).toFixed(4));
8082
+ };
8083
+ const render = () => {
8084
+ const count = total();
8085
+ const { step, drift: drift2 } = snapPosition(position, count);
8086
+ hostAttributes.set("data-kui-step", String(step));
8087
+ hostStyles.set("--kui-step", String(step));
8088
+ hostStyles.set("--kui-item-count", String(count));
8089
+ publishPosition(step, drift2);
7331
8090
  marker.mark(step);
7332
- const slotNodes = [...resolveSlots()];
7333
- if (derivesRadius) measureItemWidth(hostStyles, slotNodes);
7334
- const spacing = arcDeg / count;
7335
- for (const node of slotNodes) {
7336
- const offset = Number(node.getAttribute("data-kui-step-offset"));
7337
- const angle = (offset - drift2) * spacing;
7338
- 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;
7339
8103
  }
8104
+ publishPosition(step, drift2);
8105
+ markFaces(rendered.nodes, rendered.count, drift2);
7340
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
+ });
7341
8127
  const goTo = (next) => {
8128
+ motion.interrupt();
7342
8129
  position = next;
7343
8130
  render();
7344
8131
  };
7345
- const groups = [];
7346
- const bindControl = (param, run) => {
7347
- const control = resolveTarget(params.text(param), ctx, `carousel ${param}`);
7348
- if (!control) return false;
7349
- if (queryScoped(el, ctx, control, scope).length === 0) {
7350
- 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);
7351
8149
  }
7352
- groups.push({ selector: control, run });
7353
- return true;
8150
+ return travelSign(centres.get(0) ?? 0, centres.get(1) ?? null, centres.get(-1) ?? null);
7354
8151
  };
7355
- bindControl("next", () => goTo(nextStep(snapPosition(position, total()).step, total())));
7356
- bindControl("prev", () => goTo(prevStep(snapPosition(position, total()).step, total())));
7357
- bindControl("jump", (_node, at) => {
7358
- if (at >= 0) goTo(at);
7359
- });
7360
- const releaseControls = delegateControls({ el, ctx, scope, groups });
7361
8152
  const releaseDrag = createRingDrag({
7362
8153
  el,
7363
8154
  ctx,
@@ -7365,22 +8156,406 @@ function prepareSpatialRing(el, params, ctx) {
7365
8156
  travelPx: params.num("travel", 220),
7366
8157
  total,
7367
8158
  positionOf: () => position,
8159
+ travelSign: screenTravel,
7368
8160
  moveTo: goTo,
7369
- setDragging: (dragging) => hostAttributes.set(DRAGGING_ATTR, String(dragging))
8161
+ setDragging: (dragging) => {
8162
+ hostAttributes.set(DRAGGING_ATTR, String(dragging));
8163
+ motion.hold(dragging);
8164
+ }
7370
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
+ );
7371
8171
  render();
7372
8172
  return () => {
8173
+ releaseViewer();
8174
+ releaseMotion();
7373
8175
  releaseDrag();
7374
8176
  releaseControls();
7375
8177
  marker.restore();
7376
8178
  for (const ledger of slots.attributes.values()) ledger.restore();
7377
- for (const ledger of slots.styles.values()) ledger.restore();
7378
8179
  slots.attributes.clear();
7379
- slots.styles.clear();
8180
+ attachment?.release();
7380
8181
  hostAttributes.restore();
7381
8182
  hostStyles.restore();
7382
8183
  };
7383
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
+ cloak: true
8379
+ }
8380
+ ];
8381
+
8382
+ // src/effects/carousel/index.ts
8383
+ var QUARTER_TURN_DEG = 90;
8384
+ var FLATTENING = [
8385
+ ["overflow", (value) => value !== "" && value !== "visible"],
8386
+ ["clip-path", (value) => value !== "" && value !== "none"],
8387
+ ["opacity", (value) => value !== "" && Number(value) < 1],
8388
+ ["filter", (value) => value !== "" && value !== "none"],
8389
+ ["backdrop-filter", (value) => value !== "" && value !== "none"],
8390
+ ["isolation", (value) => value === "isolate"],
8391
+ ["mix-blend-mode", (value) => value !== "" && value !== "normal"],
8392
+ ["mask-image", (value) => value !== "" && value !== "none"]
8393
+ ];
8394
+ function warnFlattenedHost(el, ctx) {
8395
+ const found = flatteningDeclaration(el, ctx);
8396
+ if (!found) return;
8397
+ ctx.warn(
8398
+ `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.`
8399
+ );
8400
+ }
8401
+ function flatteningDeclaration(node, ctx) {
8402
+ const style = ctx.win.getComputedStyle?.(node);
8403
+ if (!style) return null;
8404
+ for (const [property2, flattens] of FLATTENING) {
8405
+ const value = style.getPropertyValue(property2);
8406
+ if (flattens(value)) return { property: property2, value };
8407
+ }
8408
+ return null;
8409
+ }
8410
+ function faceAt(angleDeg) {
8411
+ return Math.abs(normaliseDegrees(angleDeg)) >= QUARTER_TURN_DEG ? "back" : "front";
8412
+ }
8413
+ function normaliseDegrees(angleDeg) {
8414
+ const wrapped = (angleDeg % 360 + 360) % 360;
8415
+ return wrapped > 180 ? wrapped - 360 : wrapped;
8416
+ }
8417
+ var RING_PARAMETERS = {
8418
+ ...DECK_PARAMETERS,
8419
+ /*
8420
+ * A real CSS `<angle>`, not a normalised scalar.
8421
+ *
8422
+ * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
8423
+ * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
8424
+ * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
8425
+ * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
8426
+ * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
8427
+ * `clamp()` an author can see in devtools rather than a hidden rescale.
8428
+ *
8429
+ * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
8430
+ * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
8431
+ * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
8432
+ * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
8433
+ */
8434
+ tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
8435
+ /*
8436
+ * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
8437
+ *
8438
+ * This is the parameter the concave name exists around. From the centre of a *full* ring most of
8439
+ * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
8440
+ * two thirds of the content — the inside name defaults to a slice for that reason.
8441
+ */
8442
+ arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
8443
+ /*
8444
+ * The ring's radius, or unset for one derived from the content.
8445
+ *
8446
+ * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
8447
+ * `readParams` fills every declared default in unconditionally, so an empty one is the only way
8448
+ * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
8449
+ * the circumradius of a regular polygon with this many sides and this much side length —
8450
+ * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
8451
+ * Every consumer solving that by hand was the alternative, and they would all have solved it
8452
+ * differently.
8453
+ *
8454
+ * The width half of that has to come from JavaScript: CSS can compute with a measurement but
8455
+ * cannot take one. See `measureItem` in `deck.ts`.
8456
+ *
8457
+ * Authored or derived, a depth ring's radius is a ceiling: a ring that would push its page
8458
+ * sideways is pulled in exactly as far as it has to be (`fit.ts`, `--kui-ring-fit`), and one that
8459
+ * already fits — or sits inside an ancestor that clips it — keeps this radius to the pixel.
8460
+ */
8461
+ radius: { type: "length", default: "", cssProperty: "--kui-radius" },
8462
+ /** Breathing room between neighbours, spent by the derived radius above. */
8463
+ gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
8464
+ /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
8465
+ perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
8466
+ /*
8467
+ * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
8468
+ *
8469
+ * `radial` is the ring's natural state and needs nothing: a slot placed by
8470
+ * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
8471
+ * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
8472
+ * ring read as the far side.
8473
+ *
8474
+ * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
8475
+ * and the billboard have to stay on separate boxes or every later change to one silently
8476
+ * multiplies into the other — so text stays readable at every position, which is what a raised
8477
+ * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
8478
+ */
8479
+ facing: {
8480
+ type: "keyword",
8481
+ default: "radial",
8482
+ cssProperty: "--kui-facing",
8483
+ keywords: ["radial", "camera"]
8484
+ },
8485
+ /*
8486
+ * Which plane the ring lies in: `depth` (the default — a carousel you look *at*, turning about a
8487
+ * vertical pole) or `screen` (a clock face, turning about the axis that points at you).
8488
+ *
8489
+ * ## Why a parameter of this primitive, and not a new one
8490
+ *
8491
+ * A flat ring of cards around a centred word is the same object as the 3D ring seen from its
8492
+ * pole: N slots, evenly spaced by `arc / count`, placed from `--kui-offset` and the shared drift,
8493
+ * advanced by the same drag, keys, controls and `spin:`. Every number this primitive publishes
8494
+ * means the same thing in both planes; only the three transform functions that turn a place into
8495
+ * a position differ, and those live in `carousel.css` anyway. A second primitive would have
8496
+ * duplicated the whole index to change one stylesheet rule and one face test.
8497
+ *
8498
+ * It is a parameter rather than only a name (unlike convex/concave, which differ in every
8499
+ * default and so earned two names) because the runtime has to know it: a flat slot is never
8500
+ * turned away from the viewer, so the quarter-turn face test that stops the back of a 3D ring
8501
+ * swallowing clicks would, on a clock face, disable every card in the bottom half. The name that
8502
+ * ships it, `carousel-orbit`, sets it; the stylesheet gates on the published attribute rather
8503
+ * than the name, so `carousel-3d plane:screen` is equally honoured — same rule as `facing:`.
8504
+ *
8505
+ * `facing:` keeps its meaning here. `radial` turns each card with the ring, top edge outward like
8506
+ * the numerals on a watch bezel; `camera` keeps every card upright. In the screen plane upright
8507
+ * needs no child counter-rotation at all — the slot is *translated* to its place on the circle
8508
+ * instead of rotated there — so it costs no extra markup, unlike the 3D billboard.
8509
+ */
8510
+ plane: {
8511
+ type: "keyword",
8512
+ default: "depth",
8513
+ cssProperty: "--kui-ring-plane",
8514
+ keywords: ["depth", "screen"]
8515
+ }
8516
+ };
8517
+ function prepareSpatialRing(el, params, ctx) {
8518
+ const plane = params.text("plane", "depth");
8519
+ if (plane === "depth") warnFlattenedHost(el, ctx);
8520
+ const arcDeg = degreesOf(params.text("arc", "360deg"), 360);
8521
+ const faceOf = (place2, _offset, count) => plane === "screen" ? "front" : faceAt(place2 * (arcDeg / count));
8522
+ return prepareSpatialDeck(el, params, ctx, {
8523
+ id: "spatial-ring",
8524
+ label: "carousel",
8525
+ faceOf,
8526
+ /*
8527
+ * A derived radius needs a card width; a flat ring also needs a card's size to reserve its own
8528
+ * circle (the host is sized to it, since transformed slots take no layout space), so it measures
8529
+ * even when the author named a radius.
8530
+ */
8531
+ measure: params.text("radius") === "" || plane === "screen",
8532
+ hostAttributes: {
8533
+ /*
8534
+ * `facing:` and `plane:` reach CSS as attributes, not only as the custom properties their
8535
+ * specs declare. A stylesheet cannot select on the *value* of a custom property — `@container
8536
+ * style(--kui-facing: camera)` can, and is not supported widely enough to hang a documented
8537
+ * parameter on — so the one thing a selector needs is published in the one form a selector
8538
+ * can read. Same pairing, for the same reason, as the number/attribute pair `step-marking.ts`
8539
+ * publishes for the ring place.
8540
+ */
8541
+ "data-kui-ring-facing": params.text("facing", "radial"),
8542
+ "data-kui-ring-plane": plane
8543
+ },
8544
+ /*
8545
+ * The fit to the page (`fit.ts`). Depth plane only: a flat ring already reserves and clamps to
8546
+ * its own square (`carousel.css`, `[data-kui-ring-plane='screen']`), and its cards never leave
8547
+ * the plane that square is measured in.
8548
+ */
8549
+ attach: plane === "depth" ? (styles) => createRingFit({
8550
+ el,
8551
+ ctx,
8552
+ styles,
8553
+ arcDeg,
8554
+ tiltParamDeg: degreesOf(params.text("tilt", "0deg"), 0),
8555
+ facing: params.text("facing", "radial") === "camera" ? "camera" : "radial"
8556
+ }) : void 0
8557
+ });
8558
+ }
7384
8559
  function degreesOf(value, fallbackDeg) {
7385
8560
  const match = /^(-?[\d.]+)(deg|rad|turn|grad)?$/i.exec(value.trim());
7386
8561
  if (!match) return fallbackDeg;
@@ -7400,7 +8575,15 @@ function degreesOf(value, fallbackDeg) {
7400
8575
  var SPATIAL_RING_PRIMITIVE = {
7401
8576
  id: "spatial-ring",
7402
8577
  renderer: "javascript",
7403
- channels: [CHANNEL.skew, "discrete"],
8578
+ // What `deck.ts` writes on the host (the cards, the step index, the drag), then the two
8579
+ // attributes only the ring's own layout publishes.
8580
+ channels: [
8581
+ CHANNEL.skew,
8582
+ "discrete",
8583
+ ...SPATIAL_DECK_CHANNELS,
8584
+ attributeChannel("data-kui-ring-facing"),
8585
+ attributeChannel("data-kui-ring-plane")
8586
+ ],
7404
8587
  parameters: RING_PARAMETERS,
7405
8588
  supportedTimelines: ["time"],
7406
8589
  // `load`, not `enter`: a carousel that only wires its arrows once scrolled into view is broken,
@@ -7411,28 +8594,31 @@ var SPATIAL_RING_PRIMITIVE = {
7411
8594
  reducedMotion: "shorten",
7412
8595
  prepare: deferPrepare(prepareSpatialRing)
7413
8596
  };
7414
- var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE];
8597
+ var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE, SPATIAL_STACK_PRIMITIVE];
7415
8598
  var CAROUSEL_PRESETS = [
7416
8599
  {
7417
8600
  name: "carousel-3d",
7418
8601
  primitive: "spatial-ring",
7419
8602
  params: { tilt: "12deg" },
7420
8603
  requiresOwnSubtree: true,
7421
- phase: "idle"
8604
+ phase: "idle",
8605
+ cloak: true
7422
8606
  },
7423
8607
  {
7424
8608
  name: "carousel-3d-high",
7425
8609
  primitive: "spatial-ring",
7426
8610
  params: { tilt: "30deg", perspective: "1200px" },
7427
8611
  requiresOwnSubtree: true,
7428
- phase: "idle"
8612
+ phase: "idle",
8613
+ cloak: true
7429
8614
  },
7430
8615
  {
7431
8616
  name: "carousel-3d-low",
7432
8617
  primitive: "spatial-ring",
7433
8618
  params: { tilt: "-18deg", perspective: "1800px" },
7434
8619
  requiresOwnSubtree: true,
7435
- phase: "idle"
8620
+ phase: "idle",
8621
+ cloak: true
7436
8622
  },
7437
8623
  /*
7438
8624
  * The concave name. `arc:120deg` is the default the outside names cannot want and this one cannot
@@ -7447,11 +8633,34 @@ var CAROUSEL_PRESETS = [
7447
8633
  primitive: "spatial-ring",
7448
8634
  params: { arc: "120deg", facing: "camera", perspective: "900px" },
7449
8635
  requiresOwnSubtree: true,
7450
- phase: "idle"
8636
+ phase: "idle",
8637
+ cloak: true
8638
+ },
8639
+ /*
8640
+ * The flat ring: cards on a clock face around whatever sits in the middle of the host (a word, a
8641
+ * logo — anything the `target:` does not name stays centred and off the ring). `plane:screen` is
8642
+ * the whole difference; see the parameter's note for why that is a parameter of this primitive and
8643
+ * not a new one.
8644
+ *
8645
+ * Named for what it does rather than which way it goes — `orbit` is the motion, and it has no
8646
+ * direction to name (the directional-naming rule, "named by travel", is about entrances that
8647
+ * *have* one; a ring's direction is the sign of `spin:`). Not `carousel-3d-*`, because it is not
8648
+ * three-dimensional, and a name that promised depth would send people looking for a `tilt:`.
8649
+ *
8650
+ * `facing:camera` by default: upright cards read as a set of things; radially turned ones read as
8651
+ * a dial, which is the less common ask and one keyword away.
8652
+ */
8653
+ {
8654
+ name: "carousel-orbit",
8655
+ primitive: "spatial-ring",
8656
+ params: { plane: "screen", facing: "camera" },
8657
+ requiresOwnSubtree: true,
8658
+ phase: "idle",
8659
+ cloak: true
7451
8660
  }
7452
8661
  ];
7453
8662
  function registerCarousel(registry) {
7454
- return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS);
8663
+ return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS).registerPresets(STACK_PRESETS);
7455
8664
  }
7456
8665
 
7457
8666
  // src/effects/tween/properties.ts
@@ -8007,7 +9216,10 @@ var deviceFrameParams = {
8007
9216
  keywords: [...DEVICE_FRAME_KINDS]
8008
9217
  },
8009
9218
  color: { type: "color", default: "#111", cssProperty: "--kui-device-color" },
8010
- radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" }
9219
+ radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" },
9220
+ // Its own knob rather than a reading of `radius:`: the frame's corner and the screen's are two
9221
+ // shapes, and the derived screen value already follows `radius:` when this is left unwritten.
9222
+ "screen-radius": { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-screen-radius" }
8011
9223
  };
8012
9224
  function prepareDeviceFrame(el, params) {
8013
9225
  const state = createAttributeLedger(el);
@@ -8020,8 +9232,9 @@ var DEVICE_FRAME_PRIMITIVE = widgetPrimitive(
8020
9232
  // Exclusive ownership of the frame chrome. Its static box-model declarations are outside
8021
9233
  // the channel-property audit, as they are for the existing catalog. The chrome also reaches
8022
9234
  // into the direct media child (`object-fit: cover` etc.); that is a `requiresOwnSubtree`
8023
- // question, as with the other showcase widgets.
8024
- channels: ["frame"],
9235
+ // question, as with the other showcase widgets. The frame kind it stamps on the host is an
9236
+ // attribute it writes, so it claims that too.
9237
+ channels: ["frame", attributeChannel("data-kui-device")],
8025
9238
  parameters: deviceFrameParams,
8026
9239
  // `'layout'`: the bezel's `border`+`padding` are box-model properties added once, at
8027
9240
  // activation, which is a real (if one-time) layout — not the "transform/opacity only" budget
@@ -8056,10 +9269,12 @@ function lock(state) {
8056
9269
  state.lock = ledger;
8057
9270
  }
8058
9271
  function clearContent(state) {
8059
- state.current?.onClose?.();
9272
+ const closing = state.current;
9273
+ closing?.onClose?.();
8060
9274
  state.current = void 0;
8061
9275
  state.currentOwner = void 0;
8062
9276
  state.slot?.replaceChildren();
9277
+ if (closing?.opener) announceViewer(state.doc, closing.opener, false);
8063
9278
  }
8064
9279
  function onClose(state) {
8065
9280
  state.dialog?.classList.remove("is-open");
@@ -8078,11 +9293,9 @@ function dismiss(state) {
8078
9293
  }, state.current.reducedMotion ? 0 : state.current.duration);
8079
9294
  }
8080
9295
  function clickedOutside(state, target) {
8081
- const content = state.current.node;
8082
- if (content.classList.contains("kui-lightbox-gallery")) {
8083
- return !content.contains(target) || !target.matches("img") && !target.closest("button");
8084
- }
8085
- return !content.contains(target) && !target.closest("button");
9296
+ const { node, inside } = state.current;
9297
+ if (inside) return !inside(target);
9298
+ return !node.contains(target) && !target.closest("button");
8086
9299
  }
8087
9300
  function forceReflow(element) {
8088
9301
  return element.offsetWidth;
@@ -8132,6 +9345,7 @@ function open(state, content, owner) {
8132
9345
  dialog.scrollTop = 0;
8133
9346
  forceReflow(dialog);
8134
9347
  dialog.classList.add("is-open");
9348
+ if (content.opener) announceViewer(state.doc, content.opener, true);
8135
9349
  }
8136
9350
  function closeNow(state) {
8137
9351
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -8211,25 +9425,38 @@ function resolveMediaSource(href) {
8211
9425
  }
8212
9426
 
8213
9427
  // src/showcase/lightbox.ts
9428
+ var DEFAULT_DURATION_MS = 280;
9429
+ var DEFAULT_SCALE = "0.965";
9430
+ var DEFAULT_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
8214
9431
  var paramsSchema = {
8215
- media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video"] },
9432
+ media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video", "mixed"] },
8216
9433
  target: { type: "text", default: "", cssProperty: "--kui-target" },
8217
9434
  scope: SCOPE_PARAM,
8218
- scale: { type: "number", default: "0.965", cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
8219
- duration: { type: "time", default: "280ms", cssProperty: "--kui-lightbox-duration" },
8220
- ease: { type: "easing", default: "cubic-bezier(0.22, 1, 0.36, 1)", cssProperty: "--kui-lightbox-ease" },
9435
+ scale: { type: "number", default: DEFAULT_SCALE, cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
9436
+ duration: { type: "time", default: `${DEFAULT_DURATION_MS}ms`, cssProperty: "--kui-lightbox-duration" },
9437
+ ease: { type: "easing", default: DEFAULT_EASE, cssProperty: "--kui-lightbox-ease" },
8221
9438
  loop: { type: "keyword", default: "true", cssProperty: "--kui-lightbox-loop", keywords: ["true", "false"] },
8222
9439
  aspect: { type: "keyword", default: "", cssProperty: "--kui-lightbox-aspect", keywords: ["wide", "tall", "square"] },
8223
- caption: { type: "keyword", default: "figcaption", cssProperty: "--kui-lightbox-caption", keywords: ["figcaption", "alt", "title", "none"] }
9440
+ caption: { type: "keyword", default: "figcaption", cssProperty: "--kui-lightbox-caption", keywords: ["figcaption", "alt", "title", "none"] },
9441
+ arrows: {
9442
+ type: "keyword",
9443
+ default: "bottom-right",
9444
+ cssProperty: "--kui-lightbox-arrows",
9445
+ keywords: ["bottom-right", "bottom-left", "bottom", "bottom-apart", "top-right", "top-left", "sides"]
9446
+ },
9447
+ "arrow-gap": { type: "length", default: "", cssProperty: "--kui-lightbox-arrow-gap" }
8224
9448
  };
8225
9449
  function defaultTargets(el, media) {
8226
- if (el.matches("a[href]") || media === "image" && el.matches("img")) return [el];
9450
+ if (el.matches("a[href]") || media !== "video" && el.matches("img")) return [el];
8227
9451
  return [...el.querySelectorAll(media === "video" ? ":scope a[href]" : ":scope a[href], :scope img")];
8228
9452
  }
9453
+ function effectName(media) {
9454
+ return media === "video" ? "video-lightbox" : "lightbox";
9455
+ }
8229
9456
  function targets(el, params, ctx, media) {
8230
9457
  const authored = params.text("target");
8231
9458
  if (!authored) return defaultTargets(el, media);
8232
- const selector = resolveTarget(authored, ctx, media === "video" ? "video-lightbox" : "lightbox");
9459
+ const selector = resolveTarget(authored, ctx, effectName(media));
8233
9460
  return selector ? queryScoped(el, ctx, selector, scopeParam(params, "self")) : [];
8234
9461
  }
8235
9462
  function imageTrigger(target) {
@@ -8240,52 +9467,137 @@ function imageIn(trigger) {
8240
9467
  }
8241
9468
  function captionFor(trigger, image, source) {
8242
9469
  if (source === "none") return "";
8243
- if (source === "alt") return image.alt;
8244
- if (source === "title") return (trigger.getAttribute("title") || image.title).trim();
8245
- return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || image.alt;
9470
+ const alt = image?.alt ?? "";
9471
+ if (source === "alt") return alt;
9472
+ if (source === "title") return titleOf(trigger, image);
9473
+ return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || alt;
9474
+ }
9475
+ function titleOf(trigger, image) {
9476
+ return (trigger.getAttribute("title") || image?.title || "").trim();
8246
9477
  }
8247
9478
  function imageItem(trigger, captionSource) {
8248
9479
  const image = imageIn(trigger);
8249
9480
  if (!image) return null;
8250
9481
  const link = trigger instanceof HTMLAnchorElement ? trigger : null;
8251
9482
  return {
9483
+ kind: "image",
8252
9484
  trigger,
8253
9485
  image,
8254
9486
  src: link?.href ?? (image.currentSrc || image.src),
8255
9487
  caption: captionFor(trigger, image, captionSource)
8256
9488
  };
8257
9489
  }
8258
- function imageItems(targets2, captionSource) {
9490
+ function videoTitle(link, poster) {
9491
+ return link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
9492
+ }
9493
+ function videoItem(link, captionSource) {
9494
+ const source = resolveMediaSource(link.getAttribute("href") ?? "");
9495
+ if (!source) return null;
9496
+ const poster = link.querySelector("img");
9497
+ return { kind: "video", trigger: link, source, title: videoTitle(link, poster), caption: captionFor(link, poster, captionSource) };
9498
+ }
9499
+ function itemFor(trigger, media, captionSource) {
9500
+ if (media !== "image" && trigger instanceof HTMLAnchorElement) {
9501
+ const video = videoItem(trigger, captionSource);
9502
+ if (video || media === "video") return video;
9503
+ }
9504
+ return imageItem(trigger, captionSource);
9505
+ }
9506
+ function triggersFor(found, media) {
8259
9507
  const seen = /* @__PURE__ */ new Set();
8260
- const items = [];
8261
- for (const target of targets2) {
8262
- const trigger = imageTrigger(target);
9508
+ const triggers = [];
9509
+ for (const target of found) {
9510
+ const trigger = media === "video" ? target : imageTrigger(target);
8263
9511
  if (seen.has(trigger)) continue;
8264
- const item = imageItem(trigger, captionSource);
8265
- if (!item) continue;
9512
+ if (!usable(trigger, media)) continue;
8266
9513
  seen.add(trigger);
8267
- items.push(item);
9514
+ triggers.push(trigger);
8268
9515
  }
8269
- return items;
9516
+ return triggers;
9517
+ }
9518
+ function usable(trigger, media) {
9519
+ const link = trigger instanceof HTMLAnchorElement;
9520
+ if (media === "video") return link;
9521
+ const image = imageIn(trigger) !== null;
9522
+ return media === "image" ? image : link || image;
8270
9523
  }
8271
9524
  function modalOptions(params, ctx) {
8272
9525
  return {
8273
- duration: params.timing.durationMs ?? params.ms("duration", 280),
8274
- scale: params.text("scale", "0.965"),
8275
- ease: params.timing.easing ?? params.text("ease", "cubic-bezier(0.22, 1, 0.36, 1)"),
9526
+ duration: params.timing.durationMs ?? params.ms("duration", DEFAULT_DURATION_MS),
9527
+ scale: params.text("scale", DEFAULT_SCALE),
9528
+ ease: params.timing.easing ?? params.text("ease", DEFAULT_EASE),
8276
9529
  reducedMotion: ctx.reducedMotion
8277
9530
  };
8278
9531
  }
8279
- function showImage(item, view, caption) {
9532
+ function showImage(item, view) {
8280
9533
  view.src = item.src;
8281
9534
  view.alt = item.image.alt;
8282
- caption.textContent = item.caption;
8283
- caption.hidden = item.caption === "";
8284
9535
  const ratio = item.image.naturalWidth ? item.image.naturalHeight / item.image.naturalWidth : 1;
8285
9536
  view.className = ratio > 1.5 ? "is-tall" : "is-fit";
8286
9537
  }
9538
+ function videoFrame(item, aspect, doc) {
9539
+ const frame = doc.createElement("div");
9540
+ frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect || item.source.aspect}`;
9541
+ if (item.source.kind === "file") {
9542
+ const video = doc.createElement("video");
9543
+ video.src = item.source.embedUrl;
9544
+ video.controls = true;
9545
+ video.autoplay = true;
9546
+ video.playsInline = true;
9547
+ video.setAttribute("aria-label", item.title);
9548
+ frame.append(video);
9549
+ } else {
9550
+ const iframe = doc.createElement("iframe");
9551
+ iframe.src = item.source.embedUrl;
9552
+ iframe.title = item.title;
9553
+ iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
9554
+ iframe.allowFullscreen = true;
9555
+ frame.append(iframe);
9556
+ }
9557
+ return frame;
9558
+ }
9559
+ function pauseVideo(video) {
9560
+ if (video.readyState > 0) video.pause();
9561
+ }
9562
+ function stopMedia(frame) {
9563
+ const video = frame.querySelector("video");
9564
+ if (video) {
9565
+ pauseVideo(video);
9566
+ video.removeAttribute("src");
9567
+ }
9568
+ frame.replaceChildren();
9569
+ }
9570
+ function leave(state, node) {
9571
+ if (node === state.view) return;
9572
+ const video = node.querySelector("video");
9573
+ if (video) pauseVideo(video);
9574
+ else node.replaceChildren();
9575
+ }
9576
+ function land(state, item) {
9577
+ if (item.kind === "image") {
9578
+ showImage(item, state.view);
9579
+ return state.view;
9580
+ }
9581
+ const cached = state.players.get(item);
9582
+ if (cached) {
9583
+ void cached.querySelector("video").play().catch(() => {
9584
+ });
9585
+ return cached;
9586
+ }
9587
+ const frame = videoFrame(item, state.aspect, state.figure.ownerDocument);
9588
+ if (item.source.kind === "file") state.players.set(item, frame);
9589
+ return frame;
9590
+ }
8287
9591
  function renderGallery(state, focus) {
8288
- showImage(state.items[state.index], state.view, state.caption);
9592
+ const item = state.items[state.index];
9593
+ if (state.shown?.item !== item) {
9594
+ if (state.shown) leave(state, state.shown.node);
9595
+ const node = land(state, item);
9596
+ state.shown = { item, node };
9597
+ state.caption.textContent = item.caption;
9598
+ state.caption.hidden = item.caption === "";
9599
+ state.figure.replaceChildren(node, state.caption);
9600
+ }
8289
9601
  state.counter.textContent = `${state.index + 1} of ${state.items.length}`;
8290
9602
  state.previous.disabled = !state.loop && state.index === 0;
8291
9603
  state.next.disabled = !state.loop && state.index === state.items.length - 1;
@@ -8303,6 +9615,7 @@ function clickGallery(state, delta, button2) {
8303
9615
  }
8304
9616
  function galleryKey(state, event) {
8305
9617
  if (state.items.length < 2) return;
9618
+ if (event.target.closest("video")) return;
8306
9619
  if (event.key === "ArrowLeft") moveGallery(state, -1, true);
8307
9620
  else if (event.key === "ArrowRight") moveGallery(state, 1, true);
8308
9621
  else if (event.key === "Home") {
@@ -8314,8 +9627,51 @@ function galleryKey(state, event) {
8314
9627
  } else return;
8315
9628
  event.preventDefault();
8316
9629
  }
8317
- function galleryContent(items, initial, loop2, options) {
8318
- const doc = items[initial].image.ownerDocument;
9630
+ function swipeGallery(state, box) {
9631
+ const viewport = box.ownerDocument.defaultView?.visualViewport ?? null;
9632
+ const zoomed = () => (viewport?.scale ?? 1) > 1.01;
9633
+ const sync = () => {
9634
+ box.classList.toggle("is-swipeable", !zoomed());
9635
+ };
9636
+ sync();
9637
+ viewport?.addEventListener("resize", sync);
9638
+ const stop = recognise(box, {
9639
+ onSwipe(direction) {
9640
+ if (direction === "left" || direction === "right") moveGallery(state, swipeStep(direction), false);
9641
+ }
9642
+ }, {
9643
+ // A press on a button still clicks it: capture is taken only once the finger has moved.
9644
+ capturePointer: "drag",
9645
+ accept: (event) => event.pointerType !== "mouse" && !zoomed() && event.target.closest("video") === null
9646
+ });
9647
+ return () => {
9648
+ stop();
9649
+ viewport?.removeEventListener("resize", sync);
9650
+ };
9651
+ }
9652
+ function closeViewer(state) {
9653
+ state.view.removeAttribute("src");
9654
+ for (const frame of state.players.values()) stopMedia(frame);
9655
+ if (state.shown && state.shown.node !== state.view) stopMedia(state.shown.node);
9656
+ }
9657
+ function vocabulary(items) {
9658
+ const kinds = new Set(items.map((item) => item.kind));
9659
+ if (kinds.size > 1) return { noun: "item", label: "Media viewer" };
9660
+ return kinds.has("video") ? { noun: "video", label: "Video viewer" } : { noun: "image", label: "Image viewer" };
9661
+ }
9662
+ var SVG_NS = "http://www.w3.org/2000/svg";
9663
+ function chevron(doc, path2) {
9664
+ const svg = doc.createElementNS(SVG_NS, "svg");
9665
+ svg.setAttribute("viewBox", "0 0 24 24");
9666
+ svg.setAttribute("aria-hidden", "true");
9667
+ svg.setAttribute("focusable", "false");
9668
+ const stroke = doc.createElementNS(SVG_NS, "path");
9669
+ stroke.setAttribute("d", path2);
9670
+ svg.append(stroke);
9671
+ return svg;
9672
+ }
9673
+ function galleryContent(items, initial, options) {
9674
+ const doc = items[initial].trigger.ownerDocument;
8319
9675
  const box = doc.createElement("div");
8320
9676
  box.className = "kui-lightbox-gallery";
8321
9677
  const figure = doc.createElement("figure");
@@ -8323,7 +9679,6 @@ function galleryContent(items, initial, loop2, options) {
8323
9679
  const view = doc.createElement("img");
8324
9680
  view.decoding = "async";
8325
9681
  const caption = doc.createElement("figcaption");
8326
- figure.append(view, caption);
8327
9682
  box.append(figure);
8328
9683
  const counter = doc.createElement("span");
8329
9684
  counter.className = "kui-lightbox-counter";
@@ -8331,151 +9686,259 @@ function galleryContent(items, initial, loop2, options) {
8331
9686
  const previous = doc.createElement("button");
8332
9687
  const next = doc.createElement("button");
8333
9688
  previous.type = next.type = "button";
8334
- previous.textContent = "\u2039";
8335
- next.textContent = "\u203A";
8336
- previous.setAttribute("aria-label", "Previous image");
8337
- next.setAttribute("aria-label", "Next image");
9689
+ previous.append(chevron(doc, "M15 5l-7 7 7 7"));
9690
+ next.append(chevron(doc, "M9 5l7 7-7 7"));
9691
+ const { noun, label } = vocabulary(items);
9692
+ previous.setAttribute("aria-label", `Previous ${noun}`);
9693
+ next.setAttribute("aria-label", `Next ${noun}`);
8338
9694
  previous.className = "kui-lightbox-prev";
8339
9695
  next.className = "kui-lightbox-next";
8340
- const state = { items, index: initial, loop: loop2, figure, view, caption, counter, previous, next };
9696
+ const state = {
9697
+ items,
9698
+ index: initial,
9699
+ loop: options.loop,
9700
+ aspect: options.aspect,
9701
+ figure,
9702
+ view,
9703
+ caption,
9704
+ counter,
9705
+ previous,
9706
+ next,
9707
+ players: /* @__PURE__ */ new Map()
9708
+ };
8341
9709
  if (items.length > 1) {
8342
- box.append(previous, next, counter);
9710
+ const nav = doc.createElement("div");
9711
+ nav.className = `kui-lightbox-nav kui-lightbox-nav--${options.arrows}`;
9712
+ if (options.arrowGap) nav.style.setProperty("--kui-lightbox-arrow-gap", options.arrowGap);
9713
+ nav.append(previous, next);
9714
+ box.append(nav, counter);
8343
9715
  previous.addEventListener("click", () => clickGallery(state, -1, previous));
8344
9716
  next.addEventListener("click", () => clickGallery(state, 1, next));
8345
9717
  }
8346
9718
  renderGallery(state, false);
9719
+ const unswipe = items.length > 1 ? swipeGallery(state, box) : void 0;
9720
+ const { duration, scale, ease, reducedMotion } = options;
8347
9721
  return {
8348
- ...options,
9722
+ duration,
9723
+ scale,
9724
+ ease,
9725
+ reducedMotion,
8349
9726
  node: box,
8350
- label: "Image viewer",
8351
- onClose: () => view.removeAttribute("src"),
9727
+ label,
9728
+ // The media and the controls (the arrow group included, so a click in the gap between the
9729
+ // arrows is not a close) keep the viewer open; the empty figure around them closes it.
9730
+ inside: (target) => box.contains(target) && target.closest("img, video, iframe, button, .kui-lightbox-nav") !== null,
9731
+ onClose: () => {
9732
+ unswipe?.();
9733
+ closeViewer(state);
9734
+ },
8352
9735
  onKey: (event) => galleryKey(state, event)
8353
9736
  };
8354
9737
  }
8355
- function stopMedia(frame) {
8356
- const video = frame.querySelector("video");
8357
- if (video) {
8358
- if (video.readyState > 0) video.pause();
8359
- video.removeAttribute("src");
8360
- }
8361
- frame.replaceChildren();
8362
- }
8363
- function videoContent(link, source, options, aspect) {
8364
- const doc = link.ownerDocument;
8365
- const frame = doc.createElement("div");
8366
- frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect}`;
8367
- const poster = link.querySelector("img");
8368
- const title = link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
8369
- if (source.kind === "file") {
8370
- const video = doc.createElement("video");
8371
- video.src = source.embedUrl;
8372
- video.controls = true;
8373
- video.autoplay = true;
8374
- video.playsInline = true;
8375
- video.setAttribute("aria-label", title);
8376
- frame.append(video);
8377
- } else {
8378
- const iframe = doc.createElement("iframe");
8379
- iframe.src = source.embedUrl;
8380
- iframe.title = title;
8381
- iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
8382
- iframe.allowFullscreen = true;
8383
- frame.append(iframe);
8384
- }
8385
- return { ...options, node: frame, label: "Video viewer", onClose: () => stopMedia(frame) };
8386
- }
8387
9738
  function primaryClick(event) {
8388
9739
  return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
8389
9740
  }
8390
- function wireImages(items, wiring, loop2) {
9741
+ function openAt(triggers, trigger, wiring) {
9742
+ const items = [];
9743
+ let initial = -1;
9744
+ for (const candidate of triggers) {
9745
+ const item = itemFor(candidate, wiring.media, wiring.captionSource);
9746
+ if (!item) continue;
9747
+ if (candidate === trigger) initial = items.length;
9748
+ items.push(item);
9749
+ }
9750
+ if (initial < 0) {
9751
+ const href = trigger.getAttribute("href") ?? "";
9752
+ if (!wiring.warned.has(href)) wiring.ctx.warn(`${effectName(wiring.media)} cannot embed "${href}"; the link will navigate normally`);
9753
+ wiring.warned.add(href);
9754
+ return false;
9755
+ }
9756
+ wiring.shell.open({ ...galleryContent(items, initial, wiring.options), opener: trigger });
9757
+ return true;
9758
+ }
9759
+ function wireTriggers(triggers, wiring) {
8391
9760
  const restore = [];
8392
- items.forEach((item, index) => {
8393
- const bare = item.trigger === item.image;
9761
+ for (const trigger of triggers) {
9762
+ const bare = trigger instanceof HTMLImageElement;
8394
9763
  if (bare) {
8395
- const attrs = createAttributeLedger(item.image);
9764
+ const attrs = createAttributeLedger(trigger);
8396
9765
  attrs.set("role", "button");
8397
9766
  attrs.set("tabindex", "0");
8398
- attrs.set("aria-label", item.image.alt ? `Open larger image: ${item.image.alt}` : "Open larger image");
9767
+ attrs.set("aria-label", trigger.alt ? `Open larger image: ${trigger.alt}` : "Open larger image");
8399
9768
  restore.push(() => attrs.restore());
8400
- item.image.addEventListener("keydown", (event) => {
9769
+ trigger.addEventListener("keydown", (event) => {
8401
9770
  if (event.key !== "Enter" && event.key !== " ") return;
8402
9771
  event.preventDefault();
8403
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
9772
+ openAt(triggers, trigger, wiring);
8404
9773
  }, { signal: wiring.signal });
8405
9774
  }
8406
- item.trigger.addEventListener("click", (event) => {
9775
+ trigger.addEventListener("click", (event) => {
8407
9776
  if (!primaryClick(event)) return;
8408
- if (!bare) event.preventDefault();
8409
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
9777
+ if (openAt(triggers, trigger, wiring)) event.preventDefault();
8410
9778
  }, { signal: wiring.signal });
8411
- });
9779
+ }
8412
9780
  return restore;
8413
9781
  }
8414
- function wireVideos(targets2, wiring, aspect, ctx) {
8415
- const warned = /* @__PURE__ */ new Set();
8416
- for (const target of targets2) {
8417
- if (!(target instanceof HTMLAnchorElement)) continue;
8418
- target.addEventListener("click", (event) => {
8419
- if (!primaryClick(event)) return;
8420
- const href = target.getAttribute("href") ?? "";
8421
- const source = resolveMediaSource(href);
8422
- if (!source) {
8423
- if (!warned.has(href)) ctx.warn(`video-lightbox cannot embed "${href}"; the link will navigate normally`);
8424
- warned.add(href);
8425
- return;
8426
- }
8427
- event.preventDefault();
8428
- wiring.shell.open(videoContent(target, source, wiring.options, aspect || source.aspect));
8429
- }, { signal: wiring.signal });
8430
- }
9782
+ function cardItem(card) {
9783
+ const link = card.matches("a[href]") ? card : card.querySelector("a[href]");
9784
+ return link && itemFor(link, "mixed", "figcaption") || itemFor(card, "mixed", "figcaption");
9785
+ }
9786
+ var OWN_CLICK = "button, input, select, textarea, label, summary, [contenteditable]";
9787
+ function attachDeckLightbox(request) {
9788
+ const { host, cards, doc, reducedMotion } = request;
9789
+ const shell = acquireModalShell(doc);
9790
+ const options = {
9791
+ duration: DEFAULT_DURATION_MS,
9792
+ scale: DEFAULT_SCALE,
9793
+ ease: DEFAULT_EASE,
9794
+ reducedMotion,
9795
+ loop: true,
9796
+ aspect: "",
9797
+ arrows: "bottom-right",
9798
+ arrowGap: ""
9799
+ };
9800
+ const turnedAway = (card) => card.getAttribute("data-kui-ring-face") === "back";
9801
+ const openAt2 = (pick, faceChecked = false) => {
9802
+ const all = [...cards()];
9803
+ const card = all.find(pick);
9804
+ if (!card || !faceChecked && turnedAway(card)) return false;
9805
+ const items = [];
9806
+ let initial = -1;
9807
+ for (const candidate of all) {
9808
+ const item = cardItem(candidate);
9809
+ if (!item) continue;
9810
+ if (candidate === card) initial = items.length;
9811
+ items.push(item);
9812
+ }
9813
+ if (initial < 0) return false;
9814
+ shell.open({ ...galleryContent(items, initial, options), opener: card });
9815
+ return true;
9816
+ };
9817
+ let pressed = null;
9818
+ const cardUnder = (event) => {
9819
+ const all = [...cards()];
9820
+ const hits = typeof doc.elementsFromPoint === "function" ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
9821
+ for (const node of [...hits, event.target]) {
9822
+ const card = all.find((candidate) => candidate.contains(node));
9823
+ if (card) return turnedAway(card) ? null : { card, node };
9824
+ }
9825
+ return null;
9826
+ };
9827
+ const onPress = (event) => {
9828
+ pressed = event.button === 0 ? cardUnder(event) : null;
9829
+ };
9830
+ const onClick = (event) => {
9831
+ const press = event.detail > 0 ? pressed : null;
9832
+ pressed = null;
9833
+ const target = event.target;
9834
+ if (event.defaultPrevented || !primaryClick(event) || target.closest(OWN_CLICK)) return;
9835
+ if (press?.node.closest(OWN_CLICK)) return;
9836
+ const opened = press ? openAt2((card) => card === press.card, true) : openAt2((card) => card.contains(target));
9837
+ if (opened) event.preventDefault();
9838
+ };
9839
+ const onKeyDown = (event) => {
9840
+ const key = event.key;
9841
+ if (event.target !== host || key !== "Enter" && key !== " ") return;
9842
+ if (openAt2((card) => card.getAttribute(STEP_STATE_ATTR) === "active")) event.preventDefault();
9843
+ };
9844
+ host.addEventListener("pointerdown", onPress);
9845
+ host.addEventListener("click", onClick);
9846
+ host.addEventListener("keydown", onKeyDown);
9847
+ return () => {
9848
+ host.removeEventListener("pointerdown", onPress);
9849
+ host.removeEventListener("click", onClick);
9850
+ host.removeEventListener("keydown", onKeyDown);
9851
+ shell.release();
9852
+ };
8431
9853
  }
8432
9854
  function prepareLightbox(el, params, ctx) {
8433
9855
  const media = params.text("media", "image");
8434
- const found = targets(el, params, ctx, media);
8435
- if (found.length === 0) return () => {
9856
+ const triggers = triggersFor(targets(el, params, ctx, media), media);
9857
+ if (triggers.length === 0) return () => {
8436
9858
  };
8437
9859
  const controller = new AbortController();
8438
9860
  const shell = acquireModalShell(ctx.doc);
8439
- const wiring = { shell, options: modalOptions(params, ctx), signal: controller.signal };
8440
- let restore = [];
8441
- if (media === "video") {
8442
- wireVideos(found, wiring, params.text("aspect"), ctx);
8443
- } else {
8444
- const items = imageItems(found, params.text("caption", "figcaption"));
8445
- restore = wireImages(items, wiring, params.is("loop"));
8446
- }
9861
+ const options = {
9862
+ ...modalOptions(params, ctx),
9863
+ loop: params.is("loop"),
9864
+ aspect: params.text("aspect"),
9865
+ arrows: params.text("arrows", "bottom-right"),
9866
+ arrowGap: params.text("arrow-gap")
9867
+ };
9868
+ const restore = wireTriggers(triggers, {
9869
+ shell,
9870
+ options,
9871
+ signal: controller.signal,
9872
+ media,
9873
+ captionSource: params.text("caption", "figcaption"),
9874
+ ctx,
9875
+ warned: /* @__PURE__ */ new Set()
9876
+ });
8447
9877
  return continuousSetup(() => {
8448
9878
  controller.abort();
8449
9879
  for (const undo of restore) undo();
8450
9880
  shell.release();
8451
9881
  });
8452
9882
  }
9883
+ var TRIGGERS_CHANNEL = "lightbox:triggers";
8453
9884
  var LIGHTBOX_PRIMITIVE = widgetPrimitive(
8454
9885
  "lightbox",
8455
- { channels: ["widget"], parameters: paramsSchema, perfClass: "paint" },
9886
+ { channels: [TRIGGERS_CHANNEL], parameters: paramsSchema, perfClass: "paint" },
8456
9887
  withTimingContract("lightbox", { honours: ["duration", "ease"], because: "the dialog opens immediately, so it has no delay phase" }, deferPrepare(prepareLightbox))
8457
9888
  );
8458
9889
  var LIGHTBOX_PRESETS = [
8459
9890
  { name: "lightbox", primitive: "lightbox", params: { media: "image" } },
8460
- { name: "video-lightbox", primitive: "lightbox", params: { media: "video" } }
9891
+ // A video row has never shown captions; `caption:figcaption` opts one in.
9892
+ { name: "video-lightbox", primitive: "lightbox", params: { media: "video", caption: "none" } }
8461
9893
  ];
8462
9894
 
8463
9895
  // src/showcase/compare.ts
8464
9896
  var COMPARE_AXES = ["x", "y"];
8465
9897
  var COMPARE_AXIS_ATTR = "data-kui-compare-axis";
9898
+ var COMPARE_DIVIDERS_ATTR = "data-kui-compare-dividers";
8466
9899
  var compareParams = {
8467
9900
  position: {
8468
9901
  type: "percentage",
8469
9902
  default: "50%",
8470
9903
  cssProperty: "--kui-compare"
8471
9904
  },
9905
+ /*
9906
+ * Where each divider of a three-or-more frame starts, as one quoted, space-separated list —
9907
+ * `positions:'20% 45% 80%'` — because a comma would start a new step, not a new value. Default
9908
+ * (empty) is an even split. `position:` stays the two-media spelling: it is a single percentage
9909
+ * and every existing page relies on it validating as one.
9910
+ */
9911
+ positions: { type: "text", default: "", cssProperty: "--kui-compare-positions" },
8472
9912
  axis: {
8473
9913
  type: "keyword",
8474
9914
  default: "x",
8475
9915
  cssProperty: "--kui-compare-axis",
8476
9916
  keywords: [...COMPARE_AXES]
8477
- }
9917
+ },
9918
+ /*
9919
+ * The auto-drag, in `background-media`'s words (`src/effects/catalog/media.ts`): `always` starts
9920
+ * at load, `in-view` the first time the slider is on screen, `never` (default) not at all.
9921
+ *
9922
+ * Not `on:load` / `on:enter`, because the activation grammar already has a job here: it is what
9923
+ * *builds* the slider, and a widget accepts only `on:load` (`src/showcase/shared.ts`) so the
9924
+ * control exists before a keyboard user tabs to it. `on:enter` would defer the widget, not the
9925
+ * motion — so the motion's start is its own parameter.
9926
+ */
9927
+ autoplay: {
9928
+ type: "keyword",
9929
+ default: "never",
9930
+ cssProperty: "--kui-compare-autoplay",
9931
+ keywords: ["never", "in-view", "always"]
9932
+ },
9933
+ /** One edge-to-edge sweep. The positional spelling (`compare 4s`) means the same thing. */
9934
+ duration: { type: "time", default: "3s", cssProperty: "--kui-compare-duration" },
9935
+ /** Head toward 0% first instead of 100%. */
9936
+ reverse: { type: "keyword", default: "false", cssProperty: "--kui-compare-reverse", keywords: ["true", "false"] },
9937
+ /** Sweep back and forth until someone takes the handle, instead of one pass. */
9938
+ loop: { type: "keyword", default: "false", cssProperty: "--kui-compare-loop", keywords: ["true", "false"] }
8478
9939
  };
9940
+ var MIN_SWEEP_MS = 250;
9941
+ var MAX_FRAME_MS2 = 100;
8479
9942
  var VERTICAL_KEY_DELTA = { ArrowUp: -1, ArrowDown: 1 };
8480
9943
  function getMediaName(item, fallback) {
8481
9944
  const alt = item.localName === "picture" ? item.querySelector("img")?.getAttribute("alt") : item.getAttribute("alt");
@@ -8491,76 +9954,402 @@ function resolveCompareLabel(el, media) {
8491
9954
  }
8492
9955
  return "Compare";
8493
9956
  }
8494
- function createCompareElements(doc, label, initialPercent, axis) {
9957
+ function createRange(doc, label, value, axis) {
8495
9958
  const range = doc.createElement("input");
8496
9959
  range.type = "range";
8497
9960
  range.className = "kui-compare-range";
8498
9961
  range.min = "0";
8499
9962
  range.max = "100";
8500
9963
  range.step = "1";
8501
- range.value = String(initialPercent);
8502
- range.setAttribute("aria-label", label);
8503
- range.setAttribute("aria-valuetext", `${initialPercent}% after`);
9964
+ range.value = String(value);
9965
+ range.setAttribute("aria-label", label.name);
9966
+ range.setAttribute("aria-valuetext", label.valueText);
8504
9967
  if (axis === "y") range.setAttribute("aria-orientation", "vertical");
9968
+ return range;
9969
+ }
9970
+ function createHandle(doc) {
8505
9971
  const handle = doc.createElement("span");
8506
9972
  handle.className = "kui-compare-handle";
8507
9973
  handle.setAttribute("aria-hidden", "true");
8508
- return { range, handle };
9974
+ return handle;
8509
9975
  }
8510
- function prepareCompare(el, params, ctx) {
8511
- const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
8512
- if (media.length < 2) {
8513
- ctx.warn("compare requires at least two media children (img, picture, or video)");
9976
+ function verticalKeys(range, axis, onInput) {
9977
+ return (event) => {
9978
+ const delta = VERTICAL_KEY_DELTA[event.key];
9979
+ if (axis !== "y" || delta === void 0) return;
9980
+ event.preventDefault();
9981
+ if (delta < 0) range.stepDown();
9982
+ else range.stepUp();
9983
+ onInput();
9984
+ };
9985
+ }
9986
+ function sweepPercent(phase) {
9987
+ return 50 - 50 * Math.cos(Math.PI * phase / 100);
9988
+ }
9989
+ function startPhase(percent, reverse) {
9990
+ const outbound = 100 / Math.PI * Math.acos(1 - percent / 50);
9991
+ return reverse ? 200 - outbound : outbound;
9992
+ }
9993
+ function sweepDuration(params, ctx) {
9994
+ const authored = params.timing.durationMs ?? params.ms("duration", 3e3);
9995
+ if (authored >= MIN_SWEEP_MS) return authored;
9996
+ ctx.warn(`compare duration: minimum is ${MIN_SWEEP_MS}ms; clamped to ${MIN_SWEEP_MS}ms`);
9997
+ return MIN_SWEEP_MS;
9998
+ }
9999
+ function bindAutoDrag(options) {
10000
+ const { host, grab, ctx, mode, loop: loop2 } = options;
10001
+ if (mode === "never" || ctx.reducedMotion) return () => {
10002
+ };
10003
+ const run = runAutoDrag(options, () => stop());
10004
+ let observer = null;
10005
+ if (mode === "in-view" || loop2) {
10006
+ observer = observeOnscreen(ctx.win, host, run.setOnscreen);
8514
10007
  }
8515
- const axis = params.text("axis", "x");
8516
- const attrs = createAttributeLedger(el);
8517
- attrs.set(COMPARE_AXIS_ATTR, axis);
10008
+ const stop = () => {
10009
+ run.stop();
10010
+ observer?.disconnect();
10011
+ observer = null;
10012
+ for (const target of grab) {
10013
+ target.removeEventListener("pointerdown", stop);
10014
+ target.removeEventListener("focus", stop);
10015
+ }
10016
+ };
10017
+ for (const target of grab) {
10018
+ target.addEventListener("pointerdown", stop);
10019
+ target.addEventListener("focus", stop);
10020
+ }
10021
+ run.setOnscreen(mode === "always" || observer === null);
10022
+ return stop;
10023
+ }
10024
+ function sweepPlan(options) {
10025
+ const { write, homes, bounds, durationMs, reverse, loop: loop2 } = options;
10026
+ const order = homes.map((_, index) => index);
10027
+ if (reverse) order.reverse();
10028
+ const cycleMs = 2 * durationMs;
10029
+ const totalMs = cycleMs * order.length;
10030
+ const phases = homes.map((home, index) => {
10031
+ const [lo, hi] = bounds[index];
10032
+ return hi > lo ? startPhase(100 * (home - lo) / (hi - lo), reverse) : 0;
10033
+ });
10034
+ const park = (slot2) => write(order[slot2], homes[order[slot2]]);
10035
+ let slot = 0;
10036
+ return {
10037
+ show(elapsed) {
10038
+ if (!loop2 && elapsed >= totalMs) {
10039
+ park(slot);
10040
+ return false;
10041
+ }
10042
+ const into = elapsed % totalMs;
10043
+ const next = Math.floor(into / cycleMs);
10044
+ if (next !== slot) park(slot);
10045
+ slot = next;
10046
+ const index = order[slot];
10047
+ const [lo, hi] = bounds[index];
10048
+ const phase = phases[index] + 100 * (into - slot * cycleMs) / durationMs;
10049
+ write(index, lo + (hi - lo) * sweepPercent(phase) / 100);
10050
+ return true;
10051
+ }
10052
+ };
10053
+ }
10054
+ function observeOnscreen(win, host, onChange) {
10055
+ const Observer = win.IntersectionObserver;
10056
+ if (!Observer) return null;
10057
+ const observer = new Observer((entries) => {
10058
+ for (const entry of entries) onChange(entry.isIntersecting);
10059
+ });
10060
+ observer.observe(host);
10061
+ return observer;
10062
+ }
10063
+ function runAutoDrag(options, finish) {
10064
+ const win = options.ctx.win;
10065
+ const plan = sweepPlan(options);
10066
+ let elapsed = 0;
10067
+ let lastFrameAt = null;
10068
+ let frame = 0;
10069
+ let done = false;
10070
+ let onscreen = false;
10071
+ const update = () => {
10072
+ const active = !done && onscreen;
10073
+ if (active && frame === 0) {
10074
+ lastFrameAt = null;
10075
+ frame = win.requestAnimationFrame(tick);
10076
+ } else if (!active && frame !== 0) {
10077
+ win.cancelAnimationFrame(frame);
10078
+ frame = 0;
10079
+ }
10080
+ };
10081
+ function tick(now) {
10082
+ frame = 0;
10083
+ if (lastFrameAt !== null) elapsed += Math.min(now - lastFrameAt, MAX_FRAME_MS2);
10084
+ lastFrameAt = now;
10085
+ if (plan.show(elapsed)) frame = win.requestAnimationFrame(tick);
10086
+ else finish();
10087
+ }
10088
+ return {
10089
+ setOnscreen(next) {
10090
+ onscreen = next;
10091
+ update();
10092
+ },
10093
+ stop() {
10094
+ done = true;
10095
+ update();
10096
+ }
10097
+ };
10098
+ }
10099
+ function buildPair({ el, media, params, ctx, axis, timing: timing3 }) {
8518
10100
  const initialPercent = Math.round(params.num("position", 0.5) * 100);
8519
10101
  const style = createStyleLedger(el);
8520
10102
  style.set("--kui-compare", params.text("position", "50%"));
8521
- const { range, handle } = createCompareElements(
8522
- el.ownerDocument,
8523
- resolveCompareLabel(el, media),
10103
+ const doc = el.ownerDocument;
10104
+ const range = createRange(
10105
+ doc,
10106
+ { name: resolveCompareLabel(el, media), valueText: `${initialPercent}% after` },
8524
10107
  initialPercent,
8525
10108
  axis
8526
10109
  );
10110
+ const handle = createHandle(doc);
8527
10111
  const onInput = () => {
8528
10112
  const val = range.value;
8529
10113
  style.set("--kui-compare", `${val}%`);
8530
10114
  range.setAttribute("aria-valuetext", `${val}% after`);
8531
10115
  };
8532
- const onKeyDown = (event) => {
8533
- const delta = VERTICAL_KEY_DELTA[event.key];
8534
- if (axis !== "y" || delta === void 0) return;
8535
- event.preventDefault();
8536
- if (delta < 0) range.stepDown();
8537
- else range.stepUp();
8538
- onInput();
10116
+ const writeAuto = (_index, percent) => {
10117
+ range.value = String(Math.round(percent));
10118
+ style.set("--kui-compare", `${Number(percent.toFixed(3))}%`);
10119
+ range.setAttribute("aria-valuetext", `${range.value}% after`);
8539
10120
  };
10121
+ const onKeyDown = verticalKeys(range, axis, onInput);
8540
10122
  range.addEventListener("input", onInput);
8541
10123
  range.addEventListener("keydown", onKeyDown);
8542
10124
  el.appendChild(range);
8543
10125
  el.appendChild(handle);
8544
- return continuousSetup(() => {
10126
+ const stopAutoDrag = bindAutoDrag({
10127
+ ...timing3,
10128
+ host: el,
10129
+ grab: [range],
10130
+ ctx,
10131
+ write: writeAuto,
10132
+ homes: [params.num("position", 0.5) * 100],
10133
+ bounds: [[0, 100]]
10134
+ });
10135
+ return () => {
10136
+ stopAutoDrag();
8545
10137
  range.removeEventListener("input", onInput);
8546
10138
  range.removeEventListener("keydown", onKeyDown);
8547
10139
  range.remove();
8548
10140
  handle.remove();
8549
- attrs.restore();
8550
10141
  style.restore();
10142
+ };
10143
+ }
10144
+ var PERCENT_TOKEN = /^(\d+(?:\.\d+)?)%$/;
10145
+ function resolvePositions(authored, count, warn) {
10146
+ const even = Array.from({ length: count }, (_, k) => Math.round(100 * (k + 1) / (count + 1)));
10147
+ if (authored.trim() === "") return even;
10148
+ const tokens = authored.trim().split(/\s+/);
10149
+ const values = tokens.map((token) => PERCENT_TOKEN.exec(token)?.[1]);
10150
+ const numbers = values.map(Number);
10151
+ const usable2 = tokens.length === count && values.every((value) => value !== void 0) && numbers.every((value, k) => value <= 100 && (k === 0 || value >= numbers[k - 1]));
10152
+ if (usable2) return numbers.map(Math.round);
10153
+ const example = even.map((value) => String(value) + "%").join(" ");
10154
+ warn(
10155
+ `compare positions: expected ${count} ascending percentages between 0% and 100% for ${count + 1} media (e.g. positions:'${example}'); using an even split`
10156
+ );
10157
+ return even;
10158
+ }
10159
+ function buildMulti({ el, media, params, ctx, axis, timing: timing3 }) {
10160
+ if (params.text("position", "50%") !== "50%") {
10161
+ ctx.warn(`compare position: sets the one divider of two media; with ${media.length}, use positions:`);
10162
+ }
10163
+ const positions = resolvePositions(params.text("positions", ""), media.length - 1, ctx.warn);
10164
+ const homes = [...positions];
10165
+ const attrs = createAttributeLedger(el);
10166
+ attrs.set(COMPARE_DIVIDERS_ATTR, String(homes.length));
10167
+ const dividers = createDividers(el, media, axis, positions);
10168
+ const surface = bindSurface(el, axis, dividers);
10169
+ const stopAutoDrag = bindAutoDrag({
10170
+ ...timing3,
10171
+ host: el,
10172
+ grab: [surface.node, ...dividers.ranges],
10173
+ ctx,
10174
+ write: dividers.render,
10175
+ homes,
10176
+ bounds: homes.map((_, k) => boundsOf(homes, k))
10177
+ });
10178
+ return () => {
10179
+ stopAutoDrag();
10180
+ surface.destroy();
10181
+ dividers.destroy();
10182
+ attrs.restore();
10183
+ };
10184
+ }
10185
+ function boundsOf(positions, k) {
10186
+ return [k > 0 ? positions[k - 1] : 0, k < positions.length - 1 ? positions[k + 1] : 100];
10187
+ }
10188
+ function dividerLabel(el, media, k) {
10189
+ const count = media.length - 1;
10190
+ const authored = el.getAttribute("aria-label")?.trim();
10191
+ if (authored) return `${authored}: divider ${k + 1} of ${count}`;
10192
+ const before = getMediaName(media[k], `Image ${k + 1}`);
10193
+ const after = getMediaName(media[k + 1], `Image ${k + 2}`);
10194
+ return `Divider ${k + 1} of ${count}: ${before} / ${after}`;
10195
+ }
10196
+ function createDividers(el, media, axis, positions) {
10197
+ const doc = el.ownerDocument;
10198
+ const count = positions.length;
10199
+ const clip = axis === "y" ? "inset(var(--kui-compare) 0 0 0)" : "inset(0 0 0 var(--kui-compare))";
10200
+ const mediaStyles = media.slice(1).map((item) => createStyleLedger(item));
10201
+ for (const ledger of mediaStyles) ledger.set("clip-path", clip);
10202
+ const ranges = [];
10203
+ const handles = [];
10204
+ const cleanups = [];
10205
+ const render = (k, shown) => {
10206
+ positions[k] = Math.round(shown);
10207
+ const css = `${Number(shown.toFixed(3))}%`;
10208
+ handles[k].style.setProperty("--kui-compare", css);
10209
+ mediaStyles[k].set("--kui-compare", css);
10210
+ ranges[k].value = String(positions[k]);
10211
+ ranges[k].setAttribute("aria-valuetext", `${positions[k]}%`);
10212
+ if (k > 0) ranges[k - 1].max = String(positions[k]);
10213
+ if (k < count - 1) ranges[k + 1].min = String(positions[k]);
10214
+ };
10215
+ for (let k = 0; k < count; k++) {
10216
+ const range = createRange(doc, { name: dividerLabel(el, media, k), valueText: `${positions[k]}%` }, positions[k], axis);
10217
+ const handle = createHandle(doc);
10218
+ ranges.push(range);
10219
+ handles.push(handle);
10220
+ el.append(range, handle);
10221
+ const onInput = () => render(k, Number(range.value));
10222
+ const onKeyDown = verticalKeys(range, axis, onInput);
10223
+ range.addEventListener("input", onInput);
10224
+ range.addEventListener("keydown", onKeyDown);
10225
+ cleanups.push(() => {
10226
+ range.removeEventListener("input", onInput);
10227
+ range.removeEventListener("keydown", onKeyDown);
10228
+ });
10229
+ }
10230
+ for (let k = 0; k < count; k++) {
10231
+ const [lo, hi] = boundsOf(positions, k);
10232
+ ranges[k].min = String(lo);
10233
+ ranges[k].max = String(hi);
10234
+ render(k, positions[k]);
10235
+ }
10236
+ return {
10237
+ ranges,
10238
+ render,
10239
+ moveTo(k, percent) {
10240
+ const [lo, hi] = boundsOf(positions, k);
10241
+ render(k, Math.min(hi, Math.max(lo, percent)));
10242
+ },
10243
+ nearest: (percent) => nearestDivider(positions, percent),
10244
+ destroy() {
10245
+ for (const cleanup of cleanups) cleanup();
10246
+ for (const node of [...ranges, ...handles]) node.remove();
10247
+ for (const ledger of mediaStyles) ledger.restore();
10248
+ }
10249
+ };
10250
+ }
10251
+ function nearestDivider(positions, percent) {
10252
+ let best = 0;
10253
+ for (let k = 1; k < positions.length; k++) {
10254
+ const distance2 = Math.abs(positions[k] - percent);
10255
+ const bestDistance = Math.abs(positions[best] - percent);
10256
+ if (distance2 < bestDistance || distance2 === bestDistance && percent >= positions[k]) best = k;
10257
+ }
10258
+ return best;
10259
+ }
10260
+ function bindSurface(el, axis, dividers) {
10261
+ const surface = el.ownerDocument.createElement("div");
10262
+ surface.className = "kui-compare-surface";
10263
+ surface.setAttribute("aria-hidden", "true");
10264
+ el.appendChild(surface);
10265
+ let dragging = -1;
10266
+ let pointerId = null;
10267
+ const percentAt = (event) => {
10268
+ const rect = el.getBoundingClientRect();
10269
+ const along = axis === "y" ? event.clientY - rect.top : event.clientX - rect.left;
10270
+ const size = axis === "y" ? rect.height : rect.width;
10271
+ return size > 0 ? 100 * along / size : 0;
10272
+ };
10273
+ const onPointerDown = (event) => {
10274
+ if (event.button > 0 || pointerId !== null) return;
10275
+ pointerId = event.pointerId;
10276
+ const percent = percentAt(event);
10277
+ dragging = dividers.nearest(percent);
10278
+ event.preventDefault();
10279
+ surface.setPointerCapture?.(event.pointerId);
10280
+ dividers.ranges[dragging].focus({ preventScroll: true });
10281
+ dividers.moveTo(dragging, percent);
10282
+ };
10283
+ const onPointerMove = (event) => {
10284
+ if (dragging >= 0 && event.pointerId === pointerId) dividers.moveTo(dragging, percentAt(event));
10285
+ };
10286
+ const onPointerUp = (event) => {
10287
+ if (event.pointerId !== pointerId) return;
10288
+ dragging = -1;
10289
+ pointerId = null;
10290
+ };
10291
+ const onLostCapture = (event) => {
10292
+ if (event.target !== surface) return;
10293
+ onPointerUp(event);
10294
+ };
10295
+ const listeners = [
10296
+ ["pointerdown", onPointerDown],
10297
+ ["pointermove", onPointerMove],
10298
+ ["pointerup", onPointerUp],
10299
+ ["pointercancel", onPointerUp],
10300
+ ["lostpointercapture", onLostCapture]
10301
+ ];
10302
+ for (const [type, listener] of listeners) surface.addEventListener(type, listener);
10303
+ return {
10304
+ node: surface,
10305
+ destroy() {
10306
+ for (const [type, listener] of listeners) surface.removeEventListener(type, listener);
10307
+ surface.remove();
10308
+ }
10309
+ };
10310
+ }
10311
+ function prepareCompare(el, params, ctx) {
10312
+ const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
10313
+ if (media.length < 2) {
10314
+ ctx.warn("compare requires at least two media children (img, picture, or video)");
10315
+ }
10316
+ const axis = params.text("axis", "x");
10317
+ const attrs = createAttributeLedger(el);
10318
+ attrs.set(COMPARE_AXIS_ATTR, axis);
10319
+ const build2 = media.length > 2 ? buildMulti : buildPair;
10320
+ const teardown = build2({
10321
+ el,
10322
+ media,
10323
+ params,
10324
+ ctx,
10325
+ axis,
10326
+ timing: {
10327
+ mode: params.text("autoplay", "never"),
10328
+ durationMs: sweepDuration(params, ctx),
10329
+ reverse: params.is("reverse"),
10330
+ loop: params.is("loop")
10331
+ }
10332
+ });
10333
+ return continuousSetup(() => {
10334
+ teardown();
10335
+ attrs.restore();
8551
10336
  });
8552
10337
  }
8553
10338
  var COMPARE_PRIMITIVE = widgetPrimitive(
8554
10339
  "compare",
8555
10340
  {
8556
- channels: ["clip", "widget"],
10341
+ // The layers it stacks and the range, handle and surface it appends; the axis and divider
10342
+ // markers it writes on the host. Not `clip`: its `clip-path` is on the after layer, never the
10343
+ // host, so a `wipe-up` revealing the whole slider is not a fight over one property.
10344
+ channels: [SUBTREE_CHANNEL, attributeChannel(COMPARE_AXIS_ATTR), attributeChannel(COMPARE_DIVIDERS_ATTR)],
8557
10345
  parameters: compareParams,
8558
10346
  perfClass: "paint"
8559
10347
  },
8560
10348
  withTimingContract(
8561
10349
  "compare",
8562
10350
  {
8563
- because: "it builds an interactive before/after slider; there is no motion to time"
10351
+ honours: ["duration"],
10352
+ 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"
8564
10353
  },
8565
10354
  deferPrepare(prepareCompare)
8566
10355
  )
@@ -8734,7 +10523,8 @@ function prepareHotspots(el, params, ctx) {
8734
10523
  var HOTSPOTS_PRIMITIVE = widgetPrimitive(
8735
10524
  "hotspots",
8736
10525
  {
8737
- channels: ["widget"],
10526
+ // The marker buttons it appends, placed in percent of the host, and the marker style it writes.
10527
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-hotspot-marker")],
8738
10528
  parameters: hotspotsParams,
8739
10529
  perfClass: "layout"
8740
10530
  },
@@ -8938,7 +10728,7 @@ function bindAutoplay(options) {
8938
10728
  hovered = true;
8939
10729
  update();
8940
10730
  };
8941
- const leave = () => {
10731
+ const leave2 = () => {
8942
10732
  hovered = false;
8943
10733
  update();
8944
10734
  };
@@ -8955,7 +10745,7 @@ function bindAutoplay(options) {
8955
10745
  update();
8956
10746
  };
8957
10747
  el.addEventListener("pointerenter", enter);
8958
- el.addEventListener("pointerleave", leave);
10748
+ el.addEventListener("pointerleave", leave2);
8959
10749
  el.addEventListener("focusin", focus);
8960
10750
  el.addEventListener("focusout", blur);
8961
10751
  ctx.doc.addEventListener("visibilitychange", update);
@@ -8964,7 +10754,7 @@ function bindAutoplay(options) {
8964
10754
  return () => {
8965
10755
  if (timer !== null) clearTimeout(timer);
8966
10756
  el.removeEventListener("pointerenter", enter);
8967
- el.removeEventListener("pointerleave", leave);
10757
+ el.removeEventListener("pointerleave", leave2);
8968
10758
  el.removeEventListener("focusin", focus);
8969
10759
  el.removeEventListener("focusout", blur);
8970
10760
  ctx.doc.removeEventListener("visibilitychange", update);
@@ -9030,7 +10820,6 @@ function prepareSlideshow(el, params, ctx) {
9030
10820
  el,
9031
10821
  params,
9032
10822
  ctx,
9033
- scope,
9034
10823
  resolveSteps,
9035
10824
  name: "slideshow",
9036
10825
  clickFallback: false,
@@ -9058,7 +10847,19 @@ function prepareSlideshow(el, params, ctx) {
9058
10847
  }
9059
10848
  var SLIDESHOW_PRIMITIVE = widgetPrimitive(
9060
10849
  "slideshow",
9061
- { channels: ["widget"], parameters: slideshowParams, perfClass: "layout" },
10850
+ // The controls it inserts, then what it writes on the host: `createStepIndex`'s `data-kui-step`,
10851
+ // as every step deck does, and `decorateHost`'s mode and carousel roles.
10852
+ {
10853
+ channels: [
10854
+ SUBTREE_CHANNEL,
10855
+ attributeChannel("data-kui-step"),
10856
+ attributeChannel(SLIDE_MODE_ATTR),
10857
+ attributeChannel("role"),
10858
+ attributeChannel("aria-roledescription")
10859
+ ],
10860
+ parameters: slideshowParams,
10861
+ perfClass: "layout"
10862
+ },
9062
10863
  withTimingContract(
9063
10864
  "slideshow",
9064
10865
  { honours: ["duration", "ease"], because: "the slideshow has no delayed start" },
@@ -9220,7 +11021,14 @@ function prepareScrollStory(el, params, ctx) {
9220
11021
  }
9221
11022
  var SCROLL_STORY_PRIMITIVE = widgetPrimitive(
9222
11023
  "scroll-story",
9223
- { channels: ["widget"], parameters: scrollStoryParams, perfClass: "layout" },
11024
+ // Its stylesheet lays out the host's first and last child, so nothing else may insert one. The
11025
+ // step index on the host (`applyStep`) is claimed so a `carousel` or `scroll-progress` beside it
11026
+ // is refused rather than left to overwrite it.
11027
+ {
11028
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-step"), attributeChannel(STORY_SIDE_ATTR)],
11029
+ parameters: scrollStoryParams,
11030
+ perfClass: "layout"
11031
+ },
9224
11032
  withTimingContract(
9225
11033
  "scroll-story",
9226
11034
  { because: "scroll position" },
@@ -9323,7 +11131,8 @@ function prepareSlowMo(el, params) {
9323
11131
  }
9324
11132
  var SLOW_MO_PRIMITIVE = widgetPrimitive(
9325
11133
  "slow-mo",
9326
- { channels: ["widget"], parameters: slowMoParams, perfClass: "compositor" },
11134
+ // The toggle it prepends into the host, and the time scale it writes there.
11135
+ { channels: [SUBTREE_CHANNEL, attributeChannel(TIME_SCALE_ATTR)], parameters: slowMoParams, perfClass: "compositor" },
9327
11136
  withTimingContract(
9328
11137
  "slow-mo",
9329
11138
  { because: "it changes playback rate rather than defining an animation timeline" },
@@ -9352,6 +11161,7 @@ var SHOWCASE_PRESETS = [
9352
11161
  ...SLOW_MO_PRESETS
9353
11162
  ];
9354
11163
  function registerShowcase(target) {
11164
+ provideDeckViewer(attachDeckLightbox);
9355
11165
  return registerInto(target, SHOWCASE_PRIMITIVES, SHOWCASE_PRESETS, "Showcase");
9356
11166
  }
9357
11167