kuinetic 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/dist/esm/{chunk-K2M7O6W5.mjs → chunk-FASAEQDR.mjs} +32 -1
  3. package/dist/esm/{chunk-XDDJVANU.mjs → chunk-FYMZW5N2.mjs} +2301 -528
  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 +1075 -0
  9. package/dist/kuinetic.all.js +2502 -501
  10. package/dist/kuinetic.all.min.js +2 -2
  11. package/dist/kuinetic.css +238 -65
  12. package/dist/kuinetic.js +2501 -500
  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 +6 -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 +23 -1
  51. package/dist/types/showcase/modal-shell.d.ts +11 -0
  52. package/package.json +7 -3
package/dist/kuinetic.js CHANGED
@@ -25,10 +25,13 @@ var kuinetic = (() => {
25
25
  Animator: () => Animator,
26
26
  CHANNEL: () => CHANNEL,
27
27
  COMBOS: () => COMBOS,
28
+ KEY_NOTE_OWNER: () => KEY_NOTE_OWNER,
28
29
  KUI_EVENT: () => KUI_EVENT,
29
30
  PRESETS: () => PRESETS,
30
31
  PRIMITIVES: () => PRIMITIVES,
31
32
  Registry: () => Registry,
33
+ SUBTREE_CHANNEL: () => SUBTREE_CHANNEL,
34
+ attributeChannel: () => attributeChannel,
32
35
  collectingReporter: () => collectingReporter,
33
36
  consoleReporter: () => consoleReporter,
34
37
  control: () => control,
@@ -36,6 +39,13 @@ var kuinetic = (() => {
36
39
  createAnimator: () => createAnimator,
37
40
  createRegistry: () => createRegistry,
38
41
  default: () => src_default,
42
+ describe: () => describe2,
43
+ describeAttribute: () => describeAttribute,
44
+ describeEffect: () => describeEffect,
45
+ describeElement: () => describeElement,
46
+ describeElementAttribute: () => describeElementAttribute,
47
+ describeKeys: () => describeKeys,
48
+ describeSteps: () => describeSteps,
39
49
  detect: () => detect,
40
50
  inertInstance: () => inertInstance,
41
51
  kuinetic: () => kuinetic,
@@ -43,6 +53,8 @@ var kuinetic = (() => {
43
53
  resolveActivationSpec: () => resolveActivationSpec,
44
54
  resolveTargets: () => resolveTargets,
45
55
  silentReporter: () => silentReporter,
56
+ spellingsFor: () => spellingsFor,
57
+ timingContractOf: () => timingContractOf,
46
58
  toAttributeValue: () => toAttributeValue
47
59
  });
48
60
 
@@ -84,6 +96,10 @@ var kuinetic = (() => {
84
96
  function queryScoped(el, ctx, selector, scope) {
85
97
  return [...(scope === "page" ? ctx.doc : el).querySelectorAll(selector)];
86
98
  }
99
+ function queryControls(el, ctx, selector) {
100
+ const inside = [...el.querySelectorAll(selector)];
101
+ return inside.length > 0 ? inside : [...ctx.doc.querySelectorAll(selector)];
102
+ }
87
103
 
88
104
  // src/core/event-sources.ts
89
105
  function createEventSourceBindings() {
@@ -1255,6 +1271,10 @@ var kuinetic = (() => {
1255
1271
  */
1256
1272
  skew: "skew"
1257
1273
  };
1274
+ function attributeChannel(attribute) {
1275
+ return `attr:${attribute}`;
1276
+ }
1277
+ var SUBTREE_CHANNEL = "subtree";
1258
1278
  function inertInstance(destroy = () => {
1259
1279
  }) {
1260
1280
  return {
@@ -6539,8 +6559,8 @@ var kuinetic = (() => {
6539
6559
  shouldObserve: options.observe ?? false
6540
6560
  };
6541
6561
  }
6542
- function resolveJsEffectPreparer(provided, deps) {
6543
- return provided ?? createJsEffectPreparer(deps);
6562
+ function resolveJsEffectPreparer(provided2, deps) {
6563
+ return provided2 ?? createJsEffectPreparer(deps);
6544
6564
  }
6545
6565
  function defaultRootResolver(root) {
6546
6566
  const doc = isElementNode(root) ? root.ownerDocument : root;
@@ -6557,11 +6577,214 @@ var kuinetic = (() => {
6557
6577
  }
6558
6578
  }
6559
6579
 
6580
+ // src/core/timing-contract.ts
6581
+ var ALL_TIMING_TOKENS = ["duration", "delay", "ease"];
6582
+ var CONTRACTS = /* @__PURE__ */ new WeakMap();
6583
+ function declareTimingContract(prepare, contract) {
6584
+ CONTRACTS.set(prepare, contract);
6585
+ return prepare;
6586
+ }
6587
+ function inheritTimingContract(inner, outer) {
6588
+ const contract = CONTRACTS.get(inner);
6589
+ return contract ? declareTimingContract(outer, contract) : outer;
6590
+ }
6591
+ function timingContractOf(primitive) {
6592
+ return primitive.prepare ? CONTRACTS.get(primitive.prepare) : void 0;
6593
+ }
6594
+
6595
+ // src/core/describe.ts
6596
+ var POSITIONAL = [
6597
+ { slot: "duration", accepts: "time" },
6598
+ { slot: "delay", accepts: "time" },
6599
+ { slot: "ease", accepts: "easing" }
6600
+ ];
6601
+ function describeEffect(registry, name, notes = {}) {
6602
+ const resolved = registry.resolve(name);
6603
+ if (!resolved) return void 0;
6604
+ const { preset, primitive } = resolved;
6605
+ const declared = primitive.parameters;
6606
+ const contract = timingContractOf(primitive);
6607
+ const honours = contract ? contract.honours ?? [] : ALL_TIMING_TOKENS;
6608
+ const positionalOrder = POSITIONAL.map((slot) => ({
6609
+ ...slot,
6610
+ ...Object.hasOwn(declared, slot.slot) ? { param: slot.slot } : {},
6611
+ honoured: honours.includes(slot.slot)
6612
+ }));
6613
+ const params = Object.entries(declared).map(([param, spec]) => {
6614
+ const override = preset.params?.[param];
6615
+ const note = findNote(spec.note, notes, [preset.name, primitive.id, "*"], param);
6616
+ return {
6617
+ name: param,
6618
+ type: spec.type,
6619
+ default: override ?? spec.default,
6620
+ presetDefault: override !== void 0,
6621
+ required: note?.required === true,
6622
+ spellings: spellingsFor(spec, override ?? spec.default),
6623
+ ...optionalFields(spec, param, note)
6624
+ };
6625
+ });
6626
+ const described = {
6627
+ name: preset.name,
6628
+ primitive: primitive.id,
6629
+ renderer: primitive.renderer,
6630
+ params,
6631
+ positionalOrder
6632
+ };
6633
+ if (contract && honours.length < ALL_TIMING_TOKENS.length) described.unhonouredBecause = contract.because;
6634
+ return described;
6635
+ }
6636
+ function optionalFields(spec, param, note) {
6637
+ const out = {};
6638
+ if (spec.keywords) out.keywords = spec.keywords;
6639
+ if (spec.minimum !== void 0) out.minimum = spec.minimum;
6640
+ if (spec.maximum !== void 0) out.maximum = spec.maximum;
6641
+ if (spec.integer) out.integer = true;
6642
+ const index = POSITIONAL.findIndex((slot) => slot.slot === param);
6643
+ if (index >= 0) out.positionalIndex = index;
6644
+ if (note) out.note = note.why;
6645
+ if (note?.whenOmitted) out.whenOmitted = note.whenOmitted;
6646
+ return out;
6647
+ }
6648
+ function describeAttribute(registry, value, notes = {}) {
6649
+ return describeSpecs(registry, parse(value).specs, notes);
6650
+ }
6651
+ function describeElementAttribute(registry, value, notes = {}) {
6652
+ const parsed = parse(value);
6653
+ const written = {};
6654
+ for (const [field, key] of Object.entries(ELEMENT_KEYS)) {
6655
+ const set = parsed[field];
6656
+ if (set !== void 0) written[key.key] = set;
6657
+ }
6658
+ return { steps: describeSpecs(registry, parsed.specs, notes), keys: describeKeys(notes), written };
6659
+ }
6660
+ function describeKeys(notes = {}) {
6661
+ const element = Object.values(ELEMENT_KEYS).map((key) => keyRow(key, "element", notes));
6662
+ return [...element, ...STEP_KEY_LIST.map((key) => keyRow(key, "step", notes))];
6663
+ }
6664
+ function describeSpecs(registry, specs, notes) {
6665
+ return specs.map((spec) => {
6666
+ const written = { written: writtenArgs(spec), writtenKeys: writtenStepKeys(spec) };
6667
+ const described = describeEffect(registry, spec.name, notes);
6668
+ if (described) return { ...described, ...written };
6669
+ const suggestion = suggest(spec.name, registry.names());
6670
+ const unknown = suggestion ? { name: spec.name, unknown: true, suggestion } : { name: spec.name, unknown: true };
6671
+ return { ...unknown, ...written };
6672
+ });
6673
+ }
6674
+ function writtenArgs(spec) {
6675
+ const written = { ...spec.params };
6676
+ if (spec.duration !== void 0) written.duration = spec.duration;
6677
+ if (spec.delay !== void 0) written.delay = spec.delay;
6678
+ if (spec.easing !== void 0) written.ease = spec.easing;
6679
+ return written;
6680
+ }
6681
+ function writtenStepKeys(spec) {
6682
+ const written = {};
6683
+ if (spec.at !== void 0) written.at = spec.at;
6684
+ Object.assign(written, spec.gate);
6685
+ if (spec.repeat !== void 0) written.repeat = spec.repeat;
6686
+ if (spec.yoyo !== void 0) written.yoyo = String(spec.yoyo);
6687
+ for (const [field, value] of Object.entries(spec.hoists ?? {})) {
6688
+ written[ELEMENT_KEYS[field].key] = value;
6689
+ }
6690
+ return written;
6691
+ }
6692
+ var KEY_NOTE_OWNER = "data-kui";
6693
+ function keyRow(key, scope, notes) {
6694
+ const note = findNote(void 0, notes, [KEY_NOTE_OWNER], key.key);
6695
+ const row = {
6696
+ name: key.key,
6697
+ type: key.type,
6698
+ default: key.default,
6699
+ presetDefault: false,
6700
+ required: false,
6701
+ spellings: [...key.spellings],
6702
+ scope
6703
+ };
6704
+ if (key.keywords) row.keywords = key.keywords;
6705
+ if (note) row.note = note.why;
6706
+ if (note?.whenOmitted) row.whenOmitted = note.whenOmitted;
6707
+ return row;
6708
+ }
6709
+ var TIMELINES2 = Object.keys({ time: 0, view: 0, scroll: 0, pointer: 0, pin: 0 });
6710
+ var RM_POLICIES2 = Object.keys({ shorten: 0, crossfade: 0, disable: 0 });
6711
+ var ELEMENT_KEYS = {
6712
+ activation: { key: "on", type: "text", default: "", spellings: ["enter", "load", "hover", "click", "hover/unhover"] },
6713
+ actions: { key: "actions", type: "text", default: "", spellings: ["play/pause/resume/reset", "play/none/none/reset"] },
6714
+ timeline: { key: "timeline", type: "keyword", default: "", spellings: TIMELINES2, keywords: TIMELINES2 },
6715
+ threshold: { key: "threshold", type: "percentage", default: "", spellings: ["0%", "25%"] },
6716
+ cascade: { key: "cascade", type: "time", default: "", spellings: ["90ms", "0.09s"] },
6717
+ spread: { key: "spread", type: "time", default: "", spellings: ["600ms", "0.6s"] },
6718
+ order: { key: "order", type: "text", default: "", spellings: ["start", "end", "center", "edges", "random", "3"] },
6719
+ cols: { key: "cols", type: "text", default: "", spellings: ["auto", "4"] },
6720
+ along: { key: "along", type: "keyword", default: "", spellings: ["x", "y"], keywords: ["x", "y"] },
6721
+ rm: { key: "rm", type: "keyword", default: "", spellings: RM_POLICIES2, keywords: RM_POLICIES2 },
6722
+ func: { key: "func", type: "text", default: "", spellings: ["onRevealDone"] }
6723
+ };
6724
+ var gateKey = (key) => ({
6725
+ key,
6726
+ type: "keyword",
6727
+ default: "",
6728
+ spellings: BREAKPOINT_NAMES,
6729
+ keywords: BREAKPOINT_NAMES
6730
+ });
6731
+ var STEP_KEYS = {
6732
+ at: { key: "at", type: "text", default: "", spellings: ["after", "with", "-200ms", "with+100ms"] },
6733
+ gate: { above: gateKey("above"), below: gateKey("below"), wide: gateKey("wide"), narrow: gateKey("narrow") },
6734
+ repeat: { key: "repeat", type: "text", default: "", spellings: ["infinite", "3"] },
6735
+ yoyo: { key: "yoyo", type: "keyword", default: "", spellings: ["true", "false"], keywords: ["true", "false"] }
6736
+ };
6737
+ var STEP_KEY_LIST = Object.values(STEP_KEYS).flatMap(
6738
+ (entry) => "key" in entry ? [entry] : Object.values(entry)
6739
+ );
6740
+ function findNote(inline, notes, owners, param) {
6741
+ const key = owners.map((owner) => `${owner}.${param}`).find((k) => Object.hasOwn(notes, k));
6742
+ const note = inline ?? (key === void 0 ? void 0 : notes[key]);
6743
+ if (note === void 0) return void 0;
6744
+ return typeof note === "string" ? { why: note } : note;
6745
+ }
6746
+ var NUMBER = /^-?(?:\d+(?:\.\d+)?|\.\d+)/;
6747
+ function spellingsFor(spec, fallback = spec.default) {
6748
+ const value = validate(fallback, spec).ok ? fallback.trim() : "";
6749
+ return EXAMPLES[spec.type](value, spec.keywords ?? []);
6750
+ }
6751
+ var EXAMPLES = {
6752
+ angle: (value) => angleForms(value),
6753
+ "angle|keyword": (value, keywords) => [...angleForms(keywords.includes(value) ? "" : value), ...keywords],
6754
+ keyword: (_, keywords) => [...keywords],
6755
+ time: timeForms,
6756
+ number: (value) => [value || "1"],
6757
+ percentage: (value) => [value || "50%"],
6758
+ "number|percentage": alphaForms,
6759
+ length: (value) => [value || "24px", "calc(1rem + 4px)"],
6760
+ "length|percentage": (value) => [value || "24px", "50%"],
6761
+ color: (value) => [value || "#e4f222", "rgb(228 242 34)", "currentcolor"],
6762
+ easing: (value) => [value || "ease-out", "linear", "cubic-bezier(.2, .8, .2, 1)", "spring"],
6763
+ path: () => ["M0,0L100,0"],
6764
+ text: () => []
6765
+ };
6766
+ function angleForms(value) {
6767
+ const n = NUMBER.exec(value)?.[0];
6768
+ const degrees = value.endsWith("deg") || /^-?[\d.]+d?$/.test(value);
6769
+ const base = degrees && n ? n : "45";
6770
+ const forms = [base, `${base}d`, `${base}deg`];
6771
+ return degrees || !value ? forms : [value, ...forms];
6772
+ }
6773
+ function timeForms(value) {
6774
+ const n = NUMBER.exec(value)?.[0];
6775
+ if (!n) return ["600ms", "0.6s"];
6776
+ return value.endsWith("ms") ? [value, `${decimalNumber(n, -3)}s`] : [value, `${decimalNumber(n, 3)}ms`];
6777
+ }
6778
+ function alphaForms(value) {
6779
+ const n = NUMBER.exec(value)?.[0];
6780
+ if (!n) return ["0.5", "50%"];
6781
+ return value.endsWith("%") ? [`${decimalNumber(n, -2)}`, value] : [value, `${decimalNumber(n, 2)}%`];
6782
+ }
6783
+
6560
6784
  // src/effects/shared.ts
6561
6785
  var TRIGGER_DELAY_PARAM = {
6562
6786
  delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" }
6563
6787
  };
6564
- var ALL_TIMING_TOKENS = ["duration", "delay", "ease"];
6565
6788
  function authoredTiming(params, token) {
6566
6789
  if (token === "ease") return params.timing.easing;
6567
6790
  const ms = token === "duration" ? params.timing.durationMs : params.timing.delayMs;
@@ -6589,20 +6812,20 @@ var kuinetic = (() => {
6589
6812
  }
6590
6813
  }
6591
6814
  function withTimingContract(id, contract, prepare) {
6592
- return (el, params, ctx) => {
6815
+ return declareTimingContract((el, params, ctx) => {
6593
6816
  warnUnhonouredTiming(id, contract, params, ctx.warn);
6594
6817
  return prepare(el, params, ctx);
6595
- };
6818
+ }, contract);
6596
6819
  }
6597
6820
  function stylesheetTimingPrepare(id, contract) {
6598
- return (el, params, ctx) => {
6821
+ return declareTimingContract((el, params, ctx) => {
6599
6822
  warnUnhonouredTiming(id, contract, params, ctx.warn);
6600
6823
  return deferredInstance(() => {
6601
6824
  mirrorTimingToCss(id, contract.honours ?? [], params, ctx);
6602
6825
  return () => {
6603
6826
  };
6604
6827
  });
6605
- };
6828
+ }, contract);
6606
6829
  }
6607
6830
  var TIMELINE_AGNOSTIC = ["time", "view", "scroll", "pin"];
6608
6831
  var COMMON = {
@@ -6679,12 +6902,12 @@ var kuinetic = (() => {
6679
6902
  * What the ring rules actually write (`ambient.css`) beyond the gradient: `mask` +
6680
6903
  * `mask-composite`, which subtract the element's own content box to leave a ring — the same
6681
6904
  * physical property `media-mask` claims under the `'mask'` channel — and `position: relative`
6682
- * plus a `padding` that *is* the ring's thickness, which is a claim on the host's box in the
6683
- * sense `pin` and `background-media` already use `'layout'` for. Declared only as
6684
- * `background`, a `gradient-border, pin-section` pair composed silently while both decided the
6685
- * host's `position`, and `gradient-border, mask-reveal` while both wrote `mask`.
6905
+ * plus a `padding` that *is* the ring's thickness — `position` as `pin` and `background-media`
6906
+ * claim it, `padding` as `header-shrink` does. Declared only as `background`, a
6907
+ * `gradient-border, pin-section` pair composed silently while both decided the host's
6908
+ * `position`, and `gradient-border, mask-reveal` while both wrote `mask`.
6686
6909
  */
6687
- cssPrimitive("ambient-gradient-ring", [CHANNEL.background, "mask", "layout"], {
6910
+ cssPrimitive("ambient-gradient-ring", [CHANNEL.background, "mask", "position", "padding"], {
6688
6911
  parameters: drift,
6689
6912
  defaultActivation: "load",
6690
6913
  reducedMotion: "disable",
@@ -7362,22 +7585,287 @@ var kuinetic = (() => {
7362
7585
  var STATE_PRIMITIVES = [...PRESS_PRIMITIVES, ...GROUP_PRIMITIVES];
7363
7586
  var STATE_PRESETS = [...PRESS_PRESETS, ...GROUP_PRESETS];
7364
7587
 
7588
+ // src/core/element-size.ts
7589
+ function watchElementSize(win, onChange) {
7590
+ const ResizeObserverCtor = win.ResizeObserver;
7591
+ const observer = ResizeObserverCtor ? new ResizeObserverCtor(onChange) : void 0;
7592
+ const elements = /* @__PURE__ */ new Set();
7593
+ win.addEventListener("resize", onChange, { passive: true });
7594
+ return {
7595
+ observe(element) {
7596
+ if (elements.has(element)) return;
7597
+ elements.add(element);
7598
+ observer?.observe(element);
7599
+ },
7600
+ unobserve(element) {
7601
+ elements.delete(element);
7602
+ observer?.unobserve(element);
7603
+ },
7604
+ disconnect() {
7605
+ win.removeEventListener("resize", onChange);
7606
+ observer?.disconnect();
7607
+ elements.clear();
7608
+ }
7609
+ };
7610
+ }
7611
+ function frameScheduler(win, callback) {
7612
+ let frame;
7613
+ let timeout;
7614
+ const run = () => {
7615
+ frame = void 0;
7616
+ timeout = void 0;
7617
+ callback();
7618
+ };
7619
+ return {
7620
+ request() {
7621
+ if (frame !== void 0 || timeout !== void 0) return;
7622
+ if (win.requestAnimationFrame) frame = win.requestAnimationFrame(run);
7623
+ else timeout = win.setTimeout(run, 16);
7624
+ },
7625
+ cancel() {
7626
+ if (frame !== void 0) win.cancelAnimationFrame(frame);
7627
+ if (timeout !== void 0) win.clearTimeout(timeout);
7628
+ frame = void 0;
7629
+ timeout = void 0;
7630
+ }
7631
+ };
7632
+ }
7633
+
7365
7634
  // src/effects/catalog/interaction-reveal.ts
7366
- function revealPrimitive(id, channels, parameters, perfClass = "compositor") {
7635
+ function revealPrimitive(id, channels, parameters, { perfClass = "compositor", placed } = {}) {
7636
+ const contract = {
7637
+ honours: ALL_TIMING_TOKENS,
7638
+ because: "interaction.css pins that value on this effect"
7639
+ };
7367
7640
  return {
7368
7641
  id,
7369
7642
  renderer: "javascript",
7370
- channels,
7643
+ // A placed family publishes the resolved side, and a running tease, on its host.
7644
+ channels: placed ? [...channels, attributeChannel(placed.attribute), attributeChannel(placed.tease)] : channels,
7371
7645
  parameters,
7372
7646
  supportedTimelines: ["time"],
7373
7647
  supportedActivations: ["load"],
7374
7648
  defaultActivation: "load",
7375
7649
  perfClass,
7376
7650
  reducedMotion: "shorten",
7377
- prepare: stylesheetTimingPrepare(id, {
7378
- honours: ALL_TIMING_TOKENS,
7379
- because: "interaction.css pins that value on this effect"
7380
- })
7651
+ prepare: placed ? withTimingContract(
7652
+ id,
7653
+ contract,
7654
+ (el, params, ctx) => deferredInstance(() => {
7655
+ mirrorTimingToCss(id, ALL_TIMING_TOKENS, params, ctx);
7656
+ return prepareReveal(el, params, placed, ctx);
7657
+ })
7658
+ ) : stylesheetTimingPrepare(id, contract)
7659
+ };
7660
+ }
7661
+ var OPPOSITE = { top: "bottom", bottom: "top", left: "right", right: "left" };
7662
+ function isSide(value) {
7663
+ return value !== null && Object.hasOwn(OPPOSITE, value);
7664
+ }
7665
+ function chooseSide(preferred, room, extent) {
7666
+ if (room[preferred] >= extent) return preferred;
7667
+ const opposite = OPPOSITE[preferred];
7668
+ return room[opposite] > room[preferred] ? opposite : preferred;
7669
+ }
7670
+ function extentOf(part, host, side) {
7671
+ switch (side) {
7672
+ case "top":
7673
+ return -part.offsetTop;
7674
+ case "bottom":
7675
+ return part.offsetTop + part.offsetHeight - host.clientHeight;
7676
+ case "left":
7677
+ return -part.offsetLeft;
7678
+ case "right":
7679
+ return part.offsetLeft + part.offsetWidth - host.clientWidth;
7680
+ }
7681
+ }
7682
+ function prepareReveal(el, params, spec, ctx) {
7683
+ const release2 = claimPart(el, params, spec, ctx);
7684
+ const attrs = createAttributeLedger(el);
7685
+ const restore = () => {
7686
+ attrs.restore();
7687
+ release2();
7688
+ };
7689
+ const place3 = params.text("place");
7690
+ const teaseMs = params.ms("tease", 0);
7691
+ const auto = place3 === "auto";
7692
+ if (!auto && isSide(place3)) attrs.set(spec.attribute, place3);
7693
+ const placeSide = auto ? autoPlacer(el, spec, attrs, ctx.win) : void 0;
7694
+ const shift = shifter(el, spec, ctx);
7695
+ const measure = () => {
7696
+ placeSide?.();
7697
+ shift();
7698
+ };
7699
+ let endTease = () => {
7700
+ };
7701
+ const shown = whileShown(el, ctx, measure, () => endTease());
7702
+ const tease = teaseMs > 0 ? teaseOnEnter(el, ctx, { ms: teaseMs, attribute: spec.tease, attrs, shown }) : void 0;
7703
+ if (tease) endTease = tease.end;
7704
+ return () => {
7705
+ tease?.stop();
7706
+ shown.stop();
7707
+ restore();
7708
+ };
7709
+ }
7710
+ function claimPart(el, params, spec, ctx) {
7711
+ const selector = resolveTarget(params.text("target"), ctx, spec.name);
7712
+ if (!selector) return () => {
7713
+ };
7714
+ const matches = queryScoped(el, ctx, selector, "self");
7715
+ const part = matches[0];
7716
+ if (!part) {
7717
+ ctx.warn(`${spec.name} target "${selector}" matched nothing inside this element`);
7718
+ return () => {
7719
+ };
7720
+ }
7721
+ if (matches.length > 1) {
7722
+ ctx.warn(
7723
+ `${spec.name} target "${selector}" matched ${matches.length} elements; only the first is used`
7724
+ );
7725
+ }
7726
+ const ledger = createAttributeLedger(part);
7727
+ if (!part.hasAttribute(spec.marker)) ledger.set(spec.marker, "");
7728
+ return () => ledger.restore();
7729
+ }
7730
+ function autoPlacer(el, spec, attrs, win) {
7731
+ const preferred = spec.preferred(el);
7732
+ let side = preferred;
7733
+ attrs.set(spec.attribute, side);
7734
+ return () => {
7735
+ const part = el.querySelector(`[${spec.marker}]`);
7736
+ if (!part) return;
7737
+ const rect = el.getBoundingClientRect();
7738
+ const room = {
7739
+ top: rect.top,
7740
+ bottom: win.innerHeight - rect.bottom,
7741
+ left: rect.left,
7742
+ right: win.innerWidth - rect.right
7743
+ };
7744
+ const next = chooseSide(preferred, room, extentOf(part, el, side));
7745
+ if (next === side) return;
7746
+ side = next;
7747
+ attrs.set(spec.attribute, side);
7748
+ };
7749
+ }
7750
+ var VIEWPORT_MARGIN = 8;
7751
+ function shiftWithin(start, size, viewport, margin = VIEWPORT_MARGIN) {
7752
+ const low = margin;
7753
+ const high = viewport - margin;
7754
+ if (start < low || size > high - low) return low - start;
7755
+ if (start + size > high) return high - start - size;
7756
+ return 0;
7757
+ }
7758
+ function shifter(el, spec, ctx) {
7759
+ let written = 0;
7760
+ return () => {
7761
+ const part = el.querySelector(`[${spec.marker}]`);
7762
+ if (!part) return;
7763
+ const stamped = el.getAttribute(spec.attribute);
7764
+ const side = isSide(stamped) ? stamped : spec.preferred(el);
7765
+ const rect = part.getBoundingClientRect();
7766
+ const hostRect = el.getBoundingClientRect();
7767
+ 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"];
7768
+ const hostSize = el[layoutKey];
7769
+ const zoom = hostSize > 0 ? hostRect[sizeKey] / hostSize : 1;
7770
+ if (zoom <= 0) return;
7771
+ const entranceScale = parseFloat(ctx.win.getComputedStyle(part).scale) || 1;
7772
+ const size = rect[sizeKey] / entranceScale;
7773
+ const start = hostRect[startKey] + (el[borderKey] + part[offsetKey]) * zoom - size / 2;
7774
+ const viewport = ctx.doc.documentElement[viewportKey] || ctx.win[windowKey];
7775
+ const next = shiftWithin(start, size, viewport) / zoom;
7776
+ if (next === written) return;
7777
+ written = next;
7778
+ ctx.style.set(spec.shift, `${next}px`);
7779
+ };
7780
+ }
7781
+ function whileShown(el, ctx, measure, onUser) {
7782
+ const { win } = ctx;
7783
+ const frame = frameScheduler(win, measure);
7784
+ let hovered = false;
7785
+ let focused = false;
7786
+ let teasing = false;
7787
+ let watching;
7788
+ const sync = () => {
7789
+ const shown = hovered || focused || teasing;
7790
+ if (shown && !watching) {
7791
+ measure();
7792
+ watching = new AbortController();
7793
+ const options = { capture: true, passive: true, signal: watching.signal };
7794
+ win.addEventListener("scroll", frame.request, options);
7795
+ win.addEventListener("resize", frame.request, options);
7796
+ } else if (!shown && watching) {
7797
+ watching.abort();
7798
+ watching = void 0;
7799
+ frame.cancel();
7800
+ }
7801
+ };
7802
+ const on = (type, update) => el.addEventListener(type, (event) => {
7803
+ update(event);
7804
+ sync();
7805
+ }, { signal: ctx.signal });
7806
+ on("pointerenter", () => {
7807
+ hovered = true;
7808
+ onUser();
7809
+ });
7810
+ on("pointerleave", () => hovered = false);
7811
+ on("focusin", () => {
7812
+ focused = true;
7813
+ onUser();
7814
+ });
7815
+ on("focusout", (event) => {
7816
+ focused = el.contains(event.relatedTarget);
7817
+ });
7818
+ return {
7819
+ setTeasing(on2) {
7820
+ teasing = on2;
7821
+ sync();
7822
+ },
7823
+ stop() {
7824
+ watching?.abort();
7825
+ frame.cancel();
7826
+ }
7827
+ };
7828
+ }
7829
+ function teaseOnEnter(el, ctx, {
7830
+ ms,
7831
+ attribute,
7832
+ attrs,
7833
+ shown
7834
+ }) {
7835
+ const { win } = ctx;
7836
+ let timer;
7837
+ let observer;
7838
+ const end = () => {
7839
+ if (timer === void 0) return;
7840
+ win.clearTimeout(timer);
7841
+ timer = void 0;
7842
+ attrs.remove(attribute);
7843
+ shown.setTeasing(false);
7844
+ };
7845
+ const start = () => {
7846
+ shown.setTeasing(true);
7847
+ attrs.set(attribute, "");
7848
+ timer = win.setTimeout(end, ms);
7849
+ };
7850
+ const Observer = win.IntersectionObserver;
7851
+ if (Observer) {
7852
+ observer = new Observer(
7853
+ (entries) => {
7854
+ if (!entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= 0.5)) return;
7855
+ observer.disconnect();
7856
+ start();
7857
+ },
7858
+ { threshold: 0.5 }
7859
+ );
7860
+ observer.observe(el);
7861
+ }
7862
+ return {
7863
+ end,
7864
+ stop() {
7865
+ observer?.disconnect();
7866
+ if (timer !== void 0) win.clearTimeout(timer);
7867
+ timer = void 0;
7868
+ }
7381
7869
  };
7382
7870
  }
7383
7871
  var labelSwapParams = {
@@ -7410,10 +7898,34 @@ var kuinetic = (() => {
7410
7898
  duration: { type: "time", default: "160ms", cssProperty: "--kui-duration" },
7411
7899
  delay: { type: "time", default: "1000ms", cssProperty: "--kui-delay" },
7412
7900
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
7413
- distance: { type: "length", default: "4px", cssProperty: "--kui-hover-intent-distance" }
7901
+ distance: { type: "length", default: "4px", cssProperty: "--kui-hover-intent-distance" },
7902
+ color: { type: "color", default: "#f4f4f0", cssProperty: "--kui-hover-intent-color" },
7903
+ "bg-color": { type: "color", default: "#111111", cssProperty: "--kui-hover-intent-bg-color" },
7904
+ radius: { type: "length", default: "12px", cssProperty: "--kui-hover-intent-radius" },
7905
+ // `place` and `tease` are read by `prepareReveal`, not by any stylesheet — see
7906
+ // `ParamSpecBase.cssProperty`.
7907
+ place: {
7908
+ type: "keyword",
7909
+ default: "top",
7910
+ keywords: ["top", "bottom", "auto"],
7911
+ cssProperty: "--kui-hover-intent-place"
7912
+ },
7913
+ tease: { type: "time", default: "0ms", cssProperty: "--kui-hover-intent-tease" },
7914
+ // Names the hint, in place of a `data-kui-hint` in the markup — see `claimPart`. Declared, so
7915
+ // `compile.ts` leaves it here rather than moving the effect onto the hint.
7916
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
7414
7917
  };
7415
7918
  var HOVER_INTENT_PRIMITIVES = [
7416
- revealPrimitive("hover-intent", ["hint"], hoverIntentParams)
7919
+ revealPrimitive("hover-intent", ["hint"], hoverIntentParams, {
7920
+ placed: {
7921
+ name: "hover-intent",
7922
+ marker: "data-kui-hint",
7923
+ attribute: "data-kui-hint-place",
7924
+ tease: "data-kui-hint-tease",
7925
+ shift: "--kui-hover-intent-shift",
7926
+ preferred: () => "top"
7927
+ }
7928
+ })
7417
7929
  ];
7418
7930
  var HOVER_INTENT_PRESETS = [
7419
7931
  { name: "hover-intent", phase: "state", primitive: "hover-intent" }
@@ -7431,16 +7943,65 @@ var kuinetic = (() => {
7431
7943
  finite: true,
7432
7944
  minimum: 0,
7433
7945
  maximum: 1
7434
- }
7946
+ },
7947
+ color: { type: "color", default: "currentcolor", cssProperty: "--kui-anchored-preview-color" },
7948
+ "bg-color": {
7949
+ type: "color",
7950
+ default: "transparent",
7951
+ cssProperty: "--kui-anchored-preview-bg-color"
7952
+ },
7953
+ radius: { type: "length", default: "0px", cssProperty: "--kui-anchored-preview-radius" },
7954
+ // `place` and `tease` are read by `prepareReveal`, not by any stylesheet — see
7955
+ // `ParamSpecBase.cssProperty`. `tease` is `hover-intent`'s, unchanged: `0ms` is off.
7956
+ place: {
7957
+ type: "keyword",
7958
+ default: "top",
7959
+ keywords: ["top", "bottom", "left", "right", "auto"],
7960
+ cssProperty: "--kui-anchored-preview-place"
7961
+ },
7962
+ tease: { type: "time", default: "0ms", cssProperty: "--kui-anchored-preview-tease" },
7963
+ // Names the preview, in place of a `data-kui-preview` in the markup — see `claimPart`.
7964
+ target: { type: "text", default: "", cssProperty: "--kui-target" }
7435
7965
  };
7966
+ function previewSideOf(el) {
7967
+ for (const token of (el.getAttribute(ATTR.normalized) ?? "").split(/\s+/)) {
7968
+ const side = /^anchored-preview(?:-(bottom|left|right))?$/.exec(token);
7969
+ if (side) return side[1] ?? "top";
7970
+ }
7971
+ return "top";
7972
+ }
7436
7973
  var ANCHORED_PREVIEW_PRIMITIVES = [
7437
- revealPrimitive("anchored-preview", ["preview"], anchoredPreviewParams)
7974
+ revealPrimitive("anchored-preview", ["preview"], anchoredPreviewParams, {
7975
+ placed: {
7976
+ name: "anchored-preview",
7977
+ marker: "data-kui-preview",
7978
+ attribute: "data-kui-preview-place",
7979
+ tease: "data-kui-preview-tease",
7980
+ shift: "--kui-anchored-preview-shift",
7981
+ preferred: previewSideOf
7982
+ }
7983
+ })
7438
7984
  ];
7439
7985
  var ANCHORED_PREVIEW_PRESETS = [
7440
7986
  { name: "anchored-preview", phase: "state", primitive: "anchored-preview" },
7441
- { name: "anchored-preview-bottom", phase: "state", primitive: "anchored-preview" },
7442
- { name: "anchored-preview-left", phase: "state", primitive: "anchored-preview" },
7443
- { name: "anchored-preview-right", phase: "state", primitive: "anchored-preview" }
7987
+ {
7988
+ name: "anchored-preview-bottom",
7989
+ phase: "state",
7990
+ primitive: "anchored-preview",
7991
+ params: { place: "bottom" }
7992
+ },
7993
+ {
7994
+ name: "anchored-preview-left",
7995
+ phase: "state",
7996
+ primitive: "anchored-preview",
7997
+ params: { place: "left" }
7998
+ },
7999
+ {
8000
+ name: "anchored-preview-right",
8001
+ phase: "state",
8002
+ primitive: "anchored-preview",
8003
+ params: { place: "right" }
8004
+ }
7444
8005
  ];
7445
8006
  var searchExpandParams = {
7446
8007
  duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
@@ -7458,7 +8019,7 @@ var kuinetic = (() => {
7458
8019
  "search-expand",
7459
8020
  ["expand", "discrete", "search-field"],
7460
8021
  searchExpandParams,
7461
- "layout"
8022
+ { perfClass: "layout" }
7462
8023
  )
7463
8024
  ];
7464
8025
  var SEARCH_EXPAND_PRESETS = [
@@ -7874,7 +8435,10 @@ var kuinetic = (() => {
7874
8435
  label: { type: "text", default: "", cssProperty: "--kui-label" }
7875
8436
  };
7876
8437
  var POINTER_PRIMITIVES = [
7877
- pointerPrimitive("tilt-3d", ["rotate"], tiltParams, deferPrepare(prepareTilt3d)),
8438
+ // `skew` is the `transform` shorthand's channel, which is what `prepareTilt3d` writes (a
8439
+ // perspective and two axis rotations) — never the `rotate` property — beside an inline
8440
+ // `transition` for its settle.
8441
+ pointerPrimitive("tilt-3d", ["skew", "transition"], tiltParams, deferPrepare(prepareTilt3d)),
7878
8442
  pointerPrimitive("tilt-parallax", ["translate"], parallaxParams, deferPrepare(prepareTiltParallax)),
7879
8443
  pointerPrimitive("cursor-follow", ["translate"], cursorDotParams, deferPrepare(prepareCursorFollow)),
7880
8444
  pointerPrimitive("cursor-lag", ["translate"], cursorDotParams, deferPrepare(prepareCursorLag)),
@@ -7883,7 +8447,13 @@ var kuinetic = (() => {
7883
8447
  // `pseudo-before`: the glow overlay is a `::before` rule in `interaction.css`, so this primitive
7884
8448
  // owns that box the same way the border family does. See `border-draw`'s comment in
7885
8449
  // `HOVER_PRIMITIVES` for what the token means and why it was added here in the same change.
7886
- pointerPrimitive("cursor-spotlight", ["spotlight", "pseudo-before"], {}, deferPrepare(prepareSpotlight))
8450
+ // `position`: the `relative` it gives an unpositioned host so that overlay has an anchor.
8451
+ pointerPrimitive(
8452
+ "cursor-spotlight",
8453
+ ["spotlight", "pseudo-before", "position"],
8454
+ {},
8455
+ deferPrepare(prepareSpotlight)
8456
+ )
7887
8457
  ];
7888
8458
  function prepareCursorFollow(el, params, ctx) {
7889
8459
  return prepareCursorDot(el, params, ctx, "kui-cursor-dot-follow");
@@ -8363,52 +8933,6 @@ var kuinetic = (() => {
8363
8933
  };
8364
8934
  }
8365
8935
 
8366
- // src/core/element-size.ts
8367
- function watchElementSize(win, onChange) {
8368
- const ResizeObserverCtor = win.ResizeObserver;
8369
- const observer = ResizeObserverCtor ? new ResizeObserverCtor(onChange) : void 0;
8370
- const elements = /* @__PURE__ */ new Set();
8371
- win.addEventListener("resize", onChange, { passive: true });
8372
- return {
8373
- observe(element) {
8374
- if (elements.has(element)) return;
8375
- elements.add(element);
8376
- observer?.observe(element);
8377
- },
8378
- unobserve(element) {
8379
- elements.delete(element);
8380
- observer?.unobserve(element);
8381
- },
8382
- disconnect() {
8383
- win.removeEventListener("resize", onChange);
8384
- observer?.disconnect();
8385
- elements.clear();
8386
- }
8387
- };
8388
- }
8389
- function frameScheduler(win, callback) {
8390
- let frame;
8391
- let timeout;
8392
- const run = () => {
8393
- frame = void 0;
8394
- timeout = void 0;
8395
- callback();
8396
- };
8397
- return {
8398
- request() {
8399
- if (frame !== void 0 || timeout !== void 0) return;
8400
- if (win.requestAnimationFrame) frame = win.requestAnimationFrame(run);
8401
- else timeout = win.setTimeout(run, 16);
8402
- },
8403
- cancel() {
8404
- if (frame !== void 0) win.cancelAnimationFrame(frame);
8405
- if (timeout !== void 0) win.clearTimeout(timeout);
8406
- frame = void 0;
8407
- timeout = void 0;
8408
- }
8409
- };
8410
- }
8411
-
8412
8936
  // src/effects/catalog/media-shared.ts
8413
8937
  var AXIS_DEGREES = { vertical: 0, horizontal: 90 };
8414
8938
  var UNIT_DEGREES = { deg: 1, d: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 };
@@ -8767,14 +9291,14 @@ var kuinetic = (() => {
8767
9291
  settle2 = resolve;
8768
9292
  });
8769
9293
  let landed = false;
8770
- const land = () => {
9294
+ const land2 = () => {
8771
9295
  if (landed) return;
8772
9296
  landed = true;
8773
9297
  stage.classList.remove("kui-slat-animating");
8774
9298
  built.restore();
8775
9299
  settle2();
8776
9300
  };
8777
- const timer = ctx.win.setTimeout(land, slatAssembleFinishMs(params, count));
9301
+ const timer = ctx.win.setTimeout(land2, slatAssembleFinishMs(params, count));
8778
9302
  return {
8779
9303
  cleanup: () => {
8780
9304
  ctx.win.clearTimeout(timer);
@@ -8785,7 +9309,7 @@ var kuinetic = (() => {
8785
9309
  finished,
8786
9310
  finish: () => {
8787
9311
  ctx.win.clearTimeout(timer);
8788
- land();
9312
+ land2();
8789
9313
  }
8790
9314
  };
8791
9315
  }
@@ -8920,7 +9444,9 @@ var kuinetic = (() => {
8920
9444
  {
8921
9445
  id: "slat-assemble",
8922
9446
  renderer: "javascript",
8923
- channels: [CHANNEL.opacity, CHANNEL.translate, CHANNEL.rotate],
9447
+ // `position` for the `relative` it gives an unpositioned host, and the subtree because
9448
+ // `installSlatStage` appends its slat stage there.
9449
+ channels: [CHANNEL.opacity, CHANNEL.translate, CHANNEL.rotate, "position", SUBTREE_CHANNEL],
8924
9450
  parameters: slatParams,
8925
9451
  supportedTimelines: ["time"],
8926
9452
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -8944,12 +9470,15 @@ var kuinetic = (() => {
8944
9470
  * and it is what makes `background-media, video-scrub` on one element a reported conflict
8945
9471
  * rather than two effects silently fighting over the same picture.
8946
9472
  *
8947
- * `layout` is claimed for the same reason `pin` claims it: preparation writes `position` and
8948
- * `isolation` on the *host*, which is a stacking-context claim on someone else's element. Left
8949
- * undeclared, `background-media, pin-section` composed silently while both decided what
8950
- * `position` the host has — the conflict detector cannot report a claim it was never told about.
9473
+ * `position` and `isolation` are the two properties preparation writes on the *host*, a
9474
+ * stacking-context claim on someone else's element. Left undeclared, `background-media,
9475
+ * pin-section` composed silently while both decided what `position` the host has — the
9476
+ * conflict detector cannot report a claim it was never told about.
9477
+ *
9478
+ * And the subtree, because the layer is appended into the host (`installBackgroundMedia`): it
9479
+ * would become a deck's last slide, or `scroll-story`'s `:last-child` in place of its sections.
8951
9480
  */
8952
- channels: ["media", "layout"],
9481
+ channels: ["media", "position", "isolation", SUBTREE_CHANNEL],
8953
9482
  renderer: "javascript",
8954
9483
  parameters: backgroundMediaParams,
8955
9484
  // Not a claim to support four timelines — an abstention. A backdrop is not driven by progress
@@ -9478,7 +10007,9 @@ var kuinetic = (() => {
9478
10007
  return {
9479
10008
  id,
9480
10009
  renderer: "javascript",
9481
- channels: ["content"],
10010
+ // The subtree too: both counters rebuild the host's children (the number, or the odometer's
10011
+ // digit columns), so a deck or a widget beside one would lose what it put there.
10012
+ channels: ["content", SUBTREE_CHANNEL],
9482
10013
  parameters,
9483
10014
  supportedTimelines: ["time"],
9484
10015
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -9866,7 +10397,9 @@ var kuinetic = (() => {
9866
10397
  return {
9867
10398
  id,
9868
10399
  renderer: "javascript",
9869
- channels,
10400
+ // Every one of these replaces the host's children with its own spans or layers, so it owns
10401
+ // them: beside a deck it would split the slides, beside `slow-mo` it would swallow the toggle.
10402
+ channels: [...channels, SUBTREE_CHANNEL],
9870
10403
  parameters: options.parameters,
9871
10404
  supportedTimelines: ["time"],
9872
10405
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -9996,21 +10529,21 @@ var kuinetic = (() => {
9996
10529
  const finished = new Promise((resolve) => {
9997
10530
  settle2 = resolve;
9998
10531
  });
9999
- const land = () => {
10532
+ const land2 = () => {
10000
10533
  ctx.win.clearTimeout(timer);
10001
10534
  timer = void 0;
10002
10535
  layers.decorative.removeAttribute("aria-hidden");
10003
10536
  layers.srOnly.remove();
10004
10537
  settle2();
10005
10538
  };
10006
- let timer = ctx.win.setTimeout(land, splitRevealFinishMs(params, count));
10539
+ let timer = ctx.win.setTimeout(land2, splitRevealFinishMs(params, count));
10007
10540
  return {
10008
10541
  finished,
10009
- land,
10542
+ land: land2,
10010
10543
  rearm(nextCount) {
10011
10544
  if (timer === void 0) return;
10012
10545
  ctx.win.clearTimeout(timer);
10013
- timer = ctx.win.setTimeout(land, splitRevealFinishMs(params, nextCount));
10546
+ timer = ctx.win.setTimeout(land2, splitRevealFinishMs(params, nextCount));
10014
10547
  },
10015
10548
  cancel() {
10016
10549
  if (timer !== void 0) ctx.win.clearTimeout(timer);
@@ -10249,7 +10782,8 @@ var kuinetic = (() => {
10249
10782
  prepare: deferPrepare(prepareTypewriter),
10250
10783
  perfClass: "continuous"
10251
10784
  }),
10252
- jsTextPrimitive("scramble-text", ["content"], {
10785
+ // `min-width`/`min-height`: the rest box it pins while the glyphs churn (`prepareScramble`).
10786
+ jsTextPrimitive("scramble-text", ["content", "min-width", "min-height"], {
10253
10787
  parameters: scrambleParams,
10254
10788
  prepare: deferPrepare(prepareScramble),
10255
10789
  perfClass: "continuous"
@@ -10658,6 +11192,36 @@ var kuinetic = (() => {
10658
11192
  return registry;
10659
11193
  }
10660
11194
 
11195
+ // src/core/deck-viewer.ts
11196
+ var provided = null;
11197
+ function provideDeckViewer(viewer) {
11198
+ provided = viewer;
11199
+ }
11200
+ function attachDeckViewer(request) {
11201
+ return provided ? provided(request) : null;
11202
+ }
11203
+ var VIEWER_EVENT = "kui:viewer";
11204
+ function announceViewer(doc, opener, open2) {
11205
+ const Ctor = doc.defaultView?.CustomEvent ?? CustomEvent;
11206
+ const source = opener.isConnected ? opener : doc;
11207
+ source.dispatchEvent(new Ctor(VIEWER_EVENT, { bubbles: true, detail: { open: open2 } }));
11208
+ }
11209
+ var DECK_LIGHTBOX_PARAM = {
11210
+ type: "keyword",
11211
+ default: "false",
11212
+ cssProperty: "--kui-deck-lightbox",
11213
+ keywords: ["true", "false"]
11214
+ };
11215
+ function deckLightbox(enabled, request, warn) {
11216
+ if (!enabled) return () => {
11217
+ };
11218
+ const release2 = attachDeckViewer(request);
11219
+ if (release2) return release2;
11220
+ warn("lightbox:true needs the showcase module (the lightbox gallery), which is not loaded; cards will not open");
11221
+ return () => {
11222
+ };
11223
+ }
11224
+
10661
11225
  // src/effects/step-marking.ts
10662
11226
  var STEP_STATE_ATTR = "data-kui-step-state";
10663
11227
  var STEP_OFFSET_ATTR = "data-kui-step-offset";
@@ -10746,28 +11310,49 @@ var kuinetic = (() => {
10746
11310
  }
10747
11311
  return Math.max(1, ...groups.values());
10748
11312
  }
11313
+ function bindControl(request, param, run) {
11314
+ const { el, ctx, params, label } = request;
11315
+ const selector = resolveTarget(params.text(param), ctx, `${label} ${param}`);
11316
+ if (!selector) return null;
11317
+ if (queryControls(el, ctx, selector).length === 0) ctx.warn(`${label} ${param} "${selector}" matched nothing`);
11318
+ return { selector, run };
11319
+ }
10749
11320
  function delegateControls(request) {
10750
- const { el, ctx, scope, groups } = request;
11321
+ const { el, ctx, groups } = request;
10751
11322
  if (groups.length === 0) return () => {
10752
11323
  };
10753
- const root = scope === "page" ? ctx.doc : el;
10754
- const onClick = (event) => {
10755
- const from = event.target;
10756
- if (typeof from?.closest !== "function") return;
11324
+ const press = (from, inside) => {
10757
11325
  for (const { selector, run } of groups) {
10758
- const matches = queryScoped(el, ctx, selector, scope);
11326
+ const matches = queryControls(el, ctx, selector);
10759
11327
  const matched = new Set(matches);
11328
+ const stop = inside ? el : null;
10760
11329
  let node = from;
10761
- while (node && !matched.has(node)) node = node.parentElement;
10762
- if (!node) continue;
11330
+ while (node && node !== stop && !matched.has(node)) node = node.parentElement;
11331
+ if (!node || node === stop) continue;
10763
11332
  run(node, matches.indexOf(node));
10764
11333
  }
10765
11334
  };
10766
- root.addEventListener("click", onClick);
10767
- return () => root.removeEventListener("click", onClick);
11335
+ const pressed = (event) => {
11336
+ const from = event.target;
11337
+ return typeof from?.closest === "function" ? from : null;
11338
+ };
11339
+ const onInside = (event) => {
11340
+ const from = pressed(event);
11341
+ if (from) press(from, true);
11342
+ };
11343
+ const onOutside = (event) => {
11344
+ const from = pressed(event);
11345
+ if (from && !el.contains(from)) press(from, false);
11346
+ };
11347
+ el.addEventListener("click", onInside);
11348
+ ctx.doc.addEventListener("click", onOutside);
11349
+ return () => {
11350
+ el.removeEventListener("click", onInside);
11351
+ ctx.doc.removeEventListener("click", onOutside);
11352
+ };
10768
11353
  }
10769
11354
  function createStepIndex(options) {
10770
- const { el, params, ctx, scope, resolveSteps, onRender } = options;
11355
+ const { el, params, ctx, resolveSteps, onRender } = options;
10771
11356
  const name = options.name ?? "step-progress";
10772
11357
  const marker = createStepMarker(resolveSteps, (message) => ctx.warn(`${name} ${message}`));
10773
11358
  const self = createAttributeLedger(el);
@@ -10786,26 +11371,23 @@ var kuinetic = (() => {
10786
11371
  };
10787
11372
  const next = () => goTo(nextStep(step, total()));
10788
11373
  const prev = () => goTo(prevStep(step, total()));
10789
- const groups = [];
10790
- const bindControl = (param, run) => {
10791
- const selector = resolveTarget(params.text(param), ctx, `${name} ${param}`);
10792
- if (!selector) return false;
10793
- if (queryScoped(el, ctx, selector, scope).length === 0) {
10794
- ctx.warn(`${name} ${param} "${selector}" matched nothing`);
10795
- }
10796
- groups.push({ selector, run });
10797
- return true;
11374
+ const byHand = (move) => {
11375
+ options.onInput?.();
11376
+ move();
10798
11377
  };
10799
- const named = [
10800
- bindControl("next", next),
10801
- bindControl("prev", prev),
10802
- bindControl("jump", (_node, position) => {
10803
- if (position >= 0) goTo(position);
10804
- })
10805
- ].some(Boolean);
10806
- const fallback = options.clickFallback !== false && !named;
10807
- if (fallback) el.addEventListener("click", next);
10808
- const releaseControls = delegateControls({ el, ctx, scope, groups });
11378
+ const request = { el, ctx, params, label: name };
11379
+ const groups = [
11380
+ bindControl(request, "next", () => byHand(next)),
11381
+ bindControl(request, "prev", () => byHand(prev)),
11382
+ bindControl(request, "jump", (_node, position) => {
11383
+ if (position >= 0) byHand(() => goTo(position));
11384
+ }),
11385
+ ...options.controls ?? []
11386
+ ].filter((group) => group !== null);
11387
+ const fallback = options.clickFallback !== false && groups.length === 0;
11388
+ const onContainerClick = () => byHand(next);
11389
+ if (fallback) el.addEventListener("click", onContainerClick);
11390
+ const releaseControls = delegateControls({ el, ctx, groups });
10809
11391
  render();
10810
11392
  return {
10811
11393
  goTo,
@@ -10814,7 +11396,7 @@ var kuinetic = (() => {
10814
11396
  current: () => step,
10815
11397
  total,
10816
11398
  release: () => {
10817
- if (fallback) el.removeEventListener("click", next);
11399
+ if (fallback) el.removeEventListener("click", onContainerClick);
10818
11400
  releaseControls();
10819
11401
  self.restore();
10820
11402
  selfStyle.restore();
@@ -10823,24 +11405,300 @@ var kuinetic = (() => {
10823
11405
  };
10824
11406
  }
10825
11407
 
10826
- // src/effects/forms/primitives.ts
10827
- var timing = {
10828
- duration: { type: "time", default: "400ms", cssProperty: "--kui-duration" },
10829
- ...TRIGGER_DELAY_PARAM,
10830
- ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
10831
- };
10832
- var FORM_STATE_TIMING = {
10833
- because: "forms.css pins its timing literally \u2014 the motion lands on a sibling that inline custom properties cannot reach"
10834
- };
10835
- var NATIVE_STATE_PRIMITIVE = {
10836
- id: "native-state",
10837
- renderer: "javascript",
10838
- channels: [CHANNEL.translate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.stroke, CHANNEL.color],
10839
- parameters: {},
10840
- supportedTimelines: ["time"],
10841
- supportedActivations: ["load"],
10842
- defaultActivation: "load",
10843
- perfClass: "compositor",
11408
+ // src/effects/auto-motion.ts
11409
+ var MIN_PERIOD_MS = 2e3;
11410
+ var MAX_FRAME_MS = 100;
11411
+ function clampPeriod(ms) {
11412
+ if (!Number.isFinite(ms) || ms === 0) return 0;
11413
+ return Math.sign(ms) * Math.max(MIN_PERIOD_MS, Math.abs(ms));
11414
+ }
11415
+ function cyclesFor(dtMs, periodMs) {
11416
+ return Math.min(Math.max(dtMs, 0), MAX_FRAME_MS) / periodMs;
11417
+ }
11418
+ function isKeyboardFocus(target) {
11419
+ const node = target;
11420
+ if (typeof node?.matches !== "function") return true;
11421
+ try {
11422
+ return node.matches(":focus-visible");
11423
+ } catch {
11424
+ return true;
11425
+ }
11426
+ }
11427
+ function createAutoMotion(request) {
11428
+ const { el, ctx, spinMs, autoplayMs, settleMs, advance, step, setSpinning, onPausedChange } = request;
11429
+ const pauseOnHover = request.pauseOnHover ?? true;
11430
+ const enabled = spinMs !== 0 || autoplayMs !== 0;
11431
+ if (!enabled) {
11432
+ return {
11433
+ enabled,
11434
+ interrupt() {
11435
+ },
11436
+ hold() {
11437
+ },
11438
+ toggle() {
11439
+ },
11440
+ release() {
11441
+ }
11442
+ };
11443
+ }
11444
+ const win = ctx.win;
11445
+ let userPaused = ctx.reducedMotion;
11446
+ let hovered = false;
11447
+ let focused = false;
11448
+ let onscreen = true;
11449
+ let gesture = false;
11450
+ let held = false;
11451
+ let viewing = false;
11452
+ let viewerOpen = false;
11453
+ let focusBeforeViewer = false;
11454
+ let frame = 0;
11455
+ let lastFrameAt = null;
11456
+ let timer = null;
11457
+ let holdTimer = null;
11458
+ let spinning = false;
11459
+ const active = () => !userPaused && !hovered && !focused && onscreen && !gesture && !held && !viewing && !ctx.doc.hidden;
11460
+ const requestFrame = (callback) => typeof win.requestAnimationFrame === "function" ? win.requestAnimationFrame(callback) : win.setTimeout(() => callback(Date.now()), 16);
11461
+ const cancelFrame = (handle) => {
11462
+ if (typeof win.cancelAnimationFrame === "function") win.cancelAnimationFrame(handle);
11463
+ else win.clearTimeout(handle);
11464
+ };
11465
+ const publishSpinning = (next) => {
11466
+ if (next === spinning) return;
11467
+ spinning = next;
11468
+ setSpinning(next);
11469
+ };
11470
+ const tick = (now) => {
11471
+ frame = 0;
11472
+ if (!active()) return;
11473
+ if (lastFrameAt !== null) advance(cyclesFor(now - lastFrameAt, spinMs));
11474
+ lastFrameAt = now;
11475
+ frame = requestFrame(tick);
11476
+ };
11477
+ const startSpin = () => {
11478
+ if (frame !== 0) return;
11479
+ publishSpinning(true);
11480
+ lastFrameAt = null;
11481
+ frame = requestFrame(tick);
11482
+ };
11483
+ const stopSpin = () => {
11484
+ if (frame !== 0) cancelFrame(frame);
11485
+ frame = 0;
11486
+ publishSpinning(false);
11487
+ };
11488
+ const armAutoplay = () => {
11489
+ if (timer !== null) return;
11490
+ timer = win.setTimeout(() => {
11491
+ timer = null;
11492
+ step(autoplayMs > 0 ? 1 : -1);
11493
+ update();
11494
+ }, Math.abs(autoplayMs));
11495
+ };
11496
+ const clearAutoplay = () => {
11497
+ if (timer === null) return;
11498
+ win.clearTimeout(timer);
11499
+ timer = null;
11500
+ };
11501
+ function update() {
11502
+ const running = active();
11503
+ if (spinMs !== 0) (running ? startSpin : stopSpin)();
11504
+ if (autoplayMs !== 0) (running ? armAutoplay : clearAutoplay)();
11505
+ }
11506
+ const onEnter = () => {
11507
+ hovered = true;
11508
+ update();
11509
+ };
11510
+ const onLeave = () => {
11511
+ hovered = false;
11512
+ update();
11513
+ };
11514
+ const onFocusIn = (event) => {
11515
+ focused = (!viewerOpen || focusBeforeViewer) && isKeyboardFocus(event.target);
11516
+ update();
11517
+ };
11518
+ const onFocusOut = (event) => {
11519
+ if (!viewerOpen) focusBeforeViewer = focused;
11520
+ const next = event.relatedTarget;
11521
+ focused = next !== null && el.contains(next) && isKeyboardFocus(next);
11522
+ update();
11523
+ };
11524
+ const onVisibility = () => update();
11525
+ const onViewer = (event) => {
11526
+ const open2 = event.detail?.open === true;
11527
+ viewerOpen = open2;
11528
+ viewing = open2 && el.contains(event.target);
11529
+ update();
11530
+ };
11531
+ if (pauseOnHover) {
11532
+ el.addEventListener("pointerenter", onEnter);
11533
+ el.addEventListener("pointerleave", onLeave);
11534
+ }
11535
+ el.addEventListener("focusin", onFocusIn);
11536
+ el.addEventListener("focusout", onFocusOut);
11537
+ ctx.doc.addEventListener("visibilitychange", onVisibility);
11538
+ ctx.doc.addEventListener(VIEWER_EVENT, onViewer);
11539
+ const Observer = win.IntersectionObserver;
11540
+ const observer = Observer ? new Observer((entries) => {
11541
+ for (const entry of entries) onscreen = entry.isIntersecting;
11542
+ update();
11543
+ }) : null;
11544
+ observer?.observe(el);
11545
+ onPausedChange(userPaused);
11546
+ update();
11547
+ return {
11548
+ enabled,
11549
+ interrupt() {
11550
+ held = true;
11551
+ if (holdTimer !== null) win.clearTimeout(holdTimer);
11552
+ holdTimer = win.setTimeout(() => {
11553
+ holdTimer = null;
11554
+ held = false;
11555
+ update();
11556
+ }, Math.max(0, settleMs));
11557
+ update();
11558
+ },
11559
+ hold(holding) {
11560
+ gesture = holding;
11561
+ update();
11562
+ },
11563
+ toggle() {
11564
+ userPaused = !userPaused;
11565
+ onPausedChange(userPaused);
11566
+ update();
11567
+ },
11568
+ release() {
11569
+ if (frame !== 0) cancelFrame(frame);
11570
+ frame = 0;
11571
+ if (timer !== null) win.clearTimeout(timer);
11572
+ if (holdTimer !== null) win.clearTimeout(holdTimer);
11573
+ timer = null;
11574
+ holdTimer = null;
11575
+ observer?.disconnect();
11576
+ el.removeEventListener("pointerenter", onEnter);
11577
+ el.removeEventListener("pointerleave", onLeave);
11578
+ el.removeEventListener("focusin", onFocusIn);
11579
+ el.removeEventListener("focusout", onFocusOut);
11580
+ ctx.doc.removeEventListener("visibilitychange", onVisibility);
11581
+ ctx.doc.removeEventListener(VIEWER_EVENT, onViewer);
11582
+ publishSpinning(false);
11583
+ }
11584
+ };
11585
+ }
11586
+ var AUTOPLAY_PARAM = { type: "time", default: "0s", cssProperty: "--kui-autoplay" };
11587
+ var PAUSE_PARAM = { type: "text", default: "", cssProperty: "--kui-pause" };
11588
+ var HOVER_PARAM = {
11589
+ type: "keyword",
11590
+ default: "pause",
11591
+ keywords: ["pause", "none"],
11592
+ cssProperty: "--kui-hover"
11593
+ };
11594
+ function motionPeriods(request) {
11595
+ const { params, ctx, label } = request;
11596
+ const spinRaw = request.spin ? params.ms("spin", 0) : 0;
11597
+ const autoplayRaw = params.ms("autoplay", 0);
11598
+ const spin2 = clampPeriod(spinRaw);
11599
+ const autoplay = clampPeriod(autoplayRaw);
11600
+ if (spin2 !== spinRaw) ctx.warn(`${label} spin: minimum is 2s; clamped to ${Math.abs(spin2) / 1e3}s`);
11601
+ if (autoplay !== autoplayRaw) {
11602
+ ctx.warn(`${label} autoplay: minimum is 2s; clamped to ${Math.abs(autoplay) / 1e3}s`);
11603
+ }
11604
+ if (spin2 !== 0 && autoplay !== 0) {
11605
+ ctx.warn(`${label}: spin: and autoplay: are both set; spin: wins`);
11606
+ return { spin: spin2, autoplay: 0 };
11607
+ }
11608
+ return { spin: spin2, autoplay };
11609
+ }
11610
+ function createDeckMotion(request) {
11611
+ const { el, ctx, params, label, settleMs, step } = request;
11612
+ const { spin: spin2, autoplay } = motionPeriods(request);
11613
+ const ledgers = /* @__PURE__ */ new Map();
11614
+ const pauseSelector = resolveTarget(params.text("pause"), ctx, `${label} pause`);
11615
+ const reflectPaused = (paused) => {
11616
+ if (!pauseSelector) return;
11617
+ for (const node of queryControls(el, ctx, pauseSelector)) {
11618
+ let ledger = ledgers.get(node);
11619
+ if (!ledger) {
11620
+ ledger = createAttributeLedger(node);
11621
+ ledgers.set(node, ledger);
11622
+ }
11623
+ ledger.set("aria-pressed", String(paused));
11624
+ }
11625
+ };
11626
+ const motion = createAutoMotion({
11627
+ el,
11628
+ ctx,
11629
+ spinMs: spin2,
11630
+ autoplayMs: autoplay,
11631
+ settleMs,
11632
+ pauseOnHover: !params.is("hover", "none"),
11633
+ advance: request.spin?.advance,
11634
+ step,
11635
+ setSpinning: request.spin?.setSpinning,
11636
+ onPausedChange: reflectPaused
11637
+ });
11638
+ let pause = null;
11639
+ if (pauseSelector && !motion.enabled) {
11640
+ ctx.warn(`${label} pause: has nothing to pause without ${request.spin ? "spin: or " : ""}autoplay:`);
11641
+ } else if (pauseSelector) {
11642
+ pause = bindControl({ el, ctx, params, label }, "pause", () => motion.toggle());
11643
+ }
11644
+ return {
11645
+ motion,
11646
+ pause,
11647
+ release() {
11648
+ motion.release();
11649
+ for (const ledger of ledgers.values()) ledger.restore();
11650
+ ledgers.clear();
11651
+ }
11652
+ };
11653
+ }
11654
+
11655
+ // src/effects/swipe-event.ts
11656
+ var SWIPE_EVENT = "kui:swipe";
11657
+ var DIRECTIONS = /* @__PURE__ */ new Set(["left", "right", "up", "down"]);
11658
+ function announceSwipe(win, el, direction) {
11659
+ const Ctor = win.CustomEvent;
11660
+ el.dispatchEvent(new Ctor(SWIPE_EVENT, { bubbles: true, detail: { direction } }));
11661
+ }
11662
+ function swipeStep(direction) {
11663
+ return direction === "left" || direction === "up" ? 1 : -1;
11664
+ }
11665
+ function swipeReaches(source, el) {
11666
+ if (source === el) return true;
11667
+ if (!source.contains(el)) return false;
11668
+ for (let node = el.parentElement; node && node !== source; node = node.parentElement) {
11669
+ if (node.hasAttribute("data-kui-step")) return false;
11670
+ }
11671
+ return true;
11672
+ }
11673
+ function stepOnSwipe(doc, el, step) {
11674
+ const onSwipe = (event) => {
11675
+ const direction = event.detail?.direction;
11676
+ const source = event.target;
11677
+ if (!direction || !DIRECTIONS.has(direction) || source.nodeType !== 1) return;
11678
+ if (swipeReaches(source, el)) step(swipeStep(direction));
11679
+ };
11680
+ doc.addEventListener(SWIPE_EVENT, onSwipe);
11681
+ return () => doc.removeEventListener(SWIPE_EVENT, onSwipe);
11682
+ }
11683
+
11684
+ // src/effects/forms/primitives.ts
11685
+ var timing = {
11686
+ duration: { type: "time", default: "400ms", cssProperty: "--kui-duration" },
11687
+ ...TRIGGER_DELAY_PARAM,
11688
+ ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
11689
+ };
11690
+ var FORM_STATE_TIMING = {
11691
+ because: "forms.css pins its timing literally \u2014 the motion lands on a sibling that inline custom properties cannot reach"
11692
+ };
11693
+ var NATIVE_STATE_PRIMITIVE = {
11694
+ id: "native-state",
11695
+ renderer: "javascript",
11696
+ channels: [CHANNEL.translate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.stroke, CHANNEL.color],
11697
+ parameters: {},
11698
+ supportedTimelines: ["time"],
11699
+ supportedActivations: ["load"],
11700
+ defaultActivation: "load",
11701
+ perfClass: "compositor",
10844
11702
  // Native pseudo-classes drive these and the motion lands on a sibling, not on the control that
10845
11703
  // carries the attribute, so base.css's policy layer enforces this through its sibling
10846
11704
  // `transition-duration` rules rather than the `animation-*` ones.
@@ -10943,7 +11801,7 @@ var kuinetic = (() => {
10943
11801
  }
10944
11802
  var STRENGTH_METER_PRIMITIVE = jsInputPrimitive(
10945
11803
  "strength-meter",
10946
- ["meter"],
11804
+ ["meter", attributeChannel("data-kui-strength-level")],
10947
11805
  {},
10948
11806
  deferPrepare(prepareStrengthMeter)
10949
11807
  );
@@ -10954,14 +11812,55 @@ var kuinetic = (() => {
10954
11812
  deferPrepare(prepareRangeFill)
10955
11813
  );
10956
11814
  function prepareStepProgress(el, params, ctx) {
10957
- const selector = resolveTarget(params.text("target"), ctx, "step-progress");
11815
+ const label = "step-progress";
11816
+ const selector = resolveTarget(params.text("target"), ctx, label);
10958
11817
  const scope = scopeParam(params, "page");
10959
11818
  const resolveSteps = () => selector ? queryScoped(el, ctx, selector, scope) : el.children;
10960
- return createStepIndex({ el, params, ctx, scope, resolveSteps }).release;
11819
+ const lightbox = params.is("lightbox");
11820
+ const { motion, pause, release: releaseMotion } = createDeckMotion({
11821
+ el,
11822
+ ctx,
11823
+ params,
11824
+ label,
11825
+ settleMs: effectDurationMs(params, 400),
11826
+ step: (direction) => direction > 0 ? index.next() : index.prev()
11827
+ });
11828
+ const index = createStepIndex({
11829
+ el,
11830
+ params,
11831
+ ctx,
11832
+ resolveSteps,
11833
+ controls: pause ? [pause] : [],
11834
+ onInput: () => motion.interrupt(),
11835
+ /*
11836
+ * With `lightbox:true` a click on a slide opens the viewer, so the container click cannot also
11837
+ * advance the deck: one press would both open the gallery and move the slide behind it. The
11838
+ * deck still moves on its arrows, dots, swipe and autoplay.
11839
+ */
11840
+ clickFallback: !lightbox
11841
+ });
11842
+ const releaseViewer = deckLightbox(
11843
+ lightbox,
11844
+ { host: el, cards: resolveSteps, doc: ctx.doc, reducedMotion: ctx.reducedMotion },
11845
+ (message) => ctx.warn(`${label} ${message}`)
11846
+ );
11847
+ const releaseSwipe = stepOnSwipe(ctx.doc, el, (direction) => {
11848
+ motion.interrupt();
11849
+ if (direction > 0) index.next();
11850
+ else index.prev();
11851
+ });
11852
+ return () => {
11853
+ releaseViewer();
11854
+ releaseSwipe();
11855
+ releaseMotion();
11856
+ index.release();
11857
+ };
10961
11858
  }
10962
11859
  var STEP_PROGRESS_BASE = jsInputPrimitive(
10963
11860
  "step-progress",
10964
- ["state"],
11861
+ // The host's children are its steps unless `target:` says otherwise, so a widget that inserts
11862
+ // one (`slow-mo`'s toggle) would become a step: the subtree is this deck's.
11863
+ [SUBTREE_CHANNEL, attributeChannel("data-kui-step")],
10965
11864
  {
10966
11865
  // Empty default, not '4': `readParams` fills every declared default in unconditionally and
10967
11866
  // does not validate it, so an empty one is how `prepareStepProgress` tells "unauthored" from
@@ -11008,6 +11907,16 @@ var kuinetic = (() => {
11008
11907
  * at 390px and at 1440px, and the page keeps owning the box. Same reason `rest:` is a number.
11009
11908
  */
11010
11909
  main: { type: "number", default: "1", cssProperty: "--kui-main", finite: true, minimum: 0 },
11910
+ /*
11911
+ * The deck steps itself every `autoplay:` (`0s`, the default, is off), and `pause:` names the
11912
+ * author's play/pause control, and `hover:none` stops a resting pointer pausing it. The spatial
11913
+ * decks' declarations and scheduler, shared — see `prepareStepProgress` and `effects/auto-motion.ts`.
11914
+ */
11915
+ autoplay: AUTOPLAY_PARAM,
11916
+ pause: PAUSE_PARAM,
11917
+ hover: HOVER_PARAM,
11918
+ // A click on a slide opens every slide in the lightbox gallery. See `core/deck-viewer.ts`.
11919
+ lightbox: DECK_LIGHTBOX_PARAM,
11011
11920
  // Which tree `target:` is searched in. Unset means this primitive's own historical answer —
11012
11921
  // see `prepareStepProgress`. One declaration, shared: `effects/step-marking.ts`.
11013
11922
  scope: SCOPE_PARAM
@@ -11054,7 +11963,7 @@ var kuinetic = (() => {
11054
11963
  }
11055
11964
  var SUBMIT_FLOW_PRIMITIVE = jsInputPrimitive(
11056
11965
  "submit-flow",
11057
- ["state"],
11966
+ [attributeChannel("data-kui-stage")],
11058
11967
  {
11059
11968
  load: { type: "time", default: "1200ms", cssProperty: "--kui-load" },
11060
11969
  hold: { type: "time", default: "1500ms", cssProperty: "--kui-hold" }
@@ -11104,9 +12013,12 @@ var kuinetic = (() => {
11104
12013
  * two readings is the stylesheet. `step-progress` was the wrong word to type on a deck of
11105
12014
  * slides, which is the only reason this alias exists; nothing behaves differently under it.
11106
12015
  *
11107
- * It is still an *index*, not a carousel component: no ARIA, no roving focus, no autoplay. That
11108
- * boundary is the one section H states — the library animates elements you control and does not
11109
- * own the widget — and naming this `carousel` does not move it.
12016
+ * It is still an *index*, not a carousel component: no ARIA, no roving focus. That boundary is
12017
+ * the one section H states — the library animates elements you control and does not own the
12018
+ * widget — and naming this `carousel` does not move it. `autoplay:`/`pause:` do not cross it: they
12019
+ * press the same "next" a visitor's control does, on a timer, and the control is still the
12020
+ * author's markup (`effects/auto-motion.ts`, shared with the spatial decks). Likewise
12021
+ * `lightbox:true` hands a click on a slide to the showcase gallery (`core/deck-viewer.ts`).
11110
12022
  *
11111
12023
  * No `requiresOwnSubtree` here, unlike `step-progress` beside it. That flag means "this name's
11112
12024
  * shipped CSS reaches past the element into its descendants, so the universal `target:` must
@@ -11185,6 +12097,17 @@ var kuinetic = (() => {
11185
12097
  longPressTimer = null;
11186
12098
  }
11187
12099
  function onDown(event) {
12100
+ if (origin && pointerId !== null && event.pointerId !== pointerId) {
12101
+ clearLongPress();
12102
+ const last = samples[samples.length - 1];
12103
+ releaseCapture(pointerId);
12104
+ finish({ x: last.x, y: last.y, time: deps.now() });
12105
+ return;
12106
+ }
12107
+ if (options.accept && !options.accept(event)) {
12108
+ origin = null;
12109
+ return;
12110
+ }
11188
12111
  origin = sampleOf(event);
11189
12112
  samples = [origin];
11190
12113
  active = false;
@@ -11268,7 +12191,9 @@ var kuinetic = (() => {
11268
12191
  stopListeningOnDocument();
11269
12192
  releaseCapture(event.pointerId);
11270
12193
  if (!origin) return;
11271
- const sample = sampleOf(event);
12194
+ finish(sampleOf(event));
12195
+ }
12196
+ function finish(sample) {
11272
12197
  samples.push(sample);
11273
12198
  reportEnd(vectorNow(sample), sample);
11274
12199
  origin = null;
@@ -11277,6 +12202,7 @@ var kuinetic = (() => {
11277
12202
  samples = [];
11278
12203
  }
11279
12204
  function onLostCapture(event) {
12205
+ if (event.target !== el) return;
11280
12206
  if (!captured || event.pointerId !== pointerId) return;
11281
12207
  captured = false;
11282
12208
  onUp(event);
@@ -11431,16 +12357,19 @@ var kuinetic = (() => {
11431
12357
  runners.x.to(offset.x + vector.vx * carry);
11432
12358
  runners.y.to(offset.y + vector.vy * carry);
11433
12359
  }
11434
- function prepareSwipeable(el, params) {
12360
+ function prepareSwipeable(el, params, ctx) {
12361
+ const axis = params.text("axis", "both");
12362
+ if (axis !== "both") ctx.style.set("touch-action", `${axis === "x" ? "pan-y" : "pan-x"} pinch-zoom`);
11435
12363
  const stop = recognise(
11436
12364
  el,
11437
12365
  {
11438
12366
  onSwipe(direction) {
11439
12367
  el.setAttribute("data-kui-swipe", direction);
12368
+ announceSwipe(ctx.win, el, direction);
11440
12369
  }
11441
12370
  },
11442
12371
  {
11443
- axis: params.text("axis", "both"),
12372
+ axis,
11444
12373
  swipeVelocity: params.num("velocity", 300),
11445
12374
  // This primitive moves nothing, so it must not hold the pointer from `pointerdown` — that
11446
12375
  // retargets the following `click` at this element and kills every interactive child (a
@@ -11506,7 +12435,7 @@ var kuinetic = (() => {
11506
12435
  var GESTURE_PRIMITIVES = [
11507
12436
  gesturePrimitive(
11508
12437
  "draggable",
11509
- ["translate"],
12438
+ ["translate", attributeChannel("data-kui-dragging")],
11510
12439
  {
11511
12440
  ...springParams2,
11512
12441
  axis: { type: "keyword", default: "both", cssProperty: "--kui-axis", keywords: ["x", "y", "both"] },
@@ -11537,7 +12466,7 @@ var kuinetic = (() => {
11537
12466
  ),
11538
12467
  gesturePrimitive(
11539
12468
  "swipeable",
11540
- ["state"],
12469
+ [attributeChannel("data-kui-swipe")],
11541
12470
  {
11542
12471
  axis: { type: "keyword", default: "both", cssProperty: "--kui-axis", keywords: ["x", "y", "both"] },
11543
12472
  velocity: { type: "number", default: "300", cssProperty: "--kui-velocity" }
@@ -11546,7 +12475,7 @@ var kuinetic = (() => {
11546
12475
  ),
11547
12476
  gesturePrimitive(
11548
12477
  "pressable",
11549
- ["state"],
12478
+ [attributeChannel("data-kui-pressed")],
11550
12479
  { duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" } },
11551
12480
  // `duration` here is the hold threshold, not a span of motion — `long-press 800ms` means
11552
12481
  // "count it once the finger has been down for 800ms". It is read (see `preparePressable`), so
@@ -11597,6 +12526,7 @@ var kuinetic = (() => {
11597
12526
  },
11598
12527
  { name: "swipe", primitive: "swipeable", phase: "state" },
11599
12528
  { name: "swipe-x", primitive: "swipeable", params: { axis: "x" }, phase: "state" },
12529
+ { name: "swipe-y", primitive: "swipeable", params: { axis: "y" }, phase: "state" },
11600
12530
  { name: "long-press", primitive: "pressable", phase: "state" },
11601
12531
  { name: "magnetic", primitive: "magnetic", phase: "state" },
11602
12532
  {
@@ -11892,13 +12822,15 @@ var kuinetic = (() => {
11892
12822
  ),
11893
12823
  layoutPrimitive(
11894
12824
  "auto-height",
11895
- ["layout"],
12825
+ // The two properties it writes on the panel: `overflow: hidden`, and the `height` it animates.
12826
+ ["overflow", "height"],
11896
12827
  { attribute: { type: "text", default: "data-open", cssProperty: "--kui-attribute" } },
11897
12828
  deferPrepare(prepareAutoHeight)
11898
12829
  ),
11899
12830
  layoutPrimitive(
11900
12831
  "flip-indicator",
11901
- ["translate", "layout"],
12832
+ // The bar's landing spot (`translate`) and its `width`, matched to the selected tab.
12833
+ ["translate", "width"],
11902
12834
  {
11903
12835
  follow: { type: "text", default: "", cssProperty: "--kui-follow" },
11904
12836
  attribute: { type: "text", default: "aria-selected", cssProperty: "--kui-attribute" }
@@ -12147,26 +13079,25 @@ var kuinetic = (() => {
12147
13079
  var NAV_JS_PRIMITIVES = [
12148
13080
  navPrimitive(
12149
13081
  "header-shrink",
12150
- // `'shadow'` alongside `'layout'`: `header-shrink`'s host rule also transitions `box-shadow`
12151
- // (navigation.css), which `layout` alone does not cover — see `header-shrink`'s own
12152
- // `Preset.transitions` below and `channel-properties.ts`'s `layout` entry for why the two
12153
- // interpolated properties (`padding-block`/`font-size`) stay on `layout` while this one moves
12154
- // to the channel that already owns every other box-shadow writer (`lift-shadow`,
12155
- // `border-glow`). Composing `header-shrink` with either of those is now a refused conflict
12156
- // instead of a silent clobber on the same property — the self-consistency bug this closes.
12157
- ["layout", "shadow"],
13082
+ // The three properties its host rule writes (navigation.css): the two it interpolates from
13083
+ // `--kui-shrink`, `padding-block` and `font-size`, each under its own name, and `box-shadow`
13084
+ // on the channel that already owns every other box-shadow writer (`lift-shadow`,
13085
+ // `border-glow`), so composing `header-shrink` with either of those is a refused conflict
13086
+ // instead of a silent clobber. The two used to sit in the shared `'layout'` bucket, which also
13087
+ // refused `header-shrink, pin-until` — a sticky header that shrinks — over nothing they share.
13088
+ ["padding", "font-size", "shadow", attributeChannel("data-kui-shrunk")],
12158
13089
  { offset: { type: "number", default: "120", cssProperty: "--kui-offset" } },
12159
13090
  deferPrepare(prepareHeaderShrink)
12160
13091
  ),
12161
13092
  navPrimitive(
12162
13093
  "header-hide-on-scroll",
12163
- [CHANNEL.translate],
13094
+ [CHANNEL.translate, attributeChannel("data-kui-hidden")],
12164
13095
  { offset: { type: "number", default: "8", cssProperty: "--kui-offset" } },
12165
13096
  deferPrepare(prepareHeaderHide)
12166
13097
  ),
12167
13098
  navPrimitive(
12168
13099
  "back-to-top-fade",
12169
- [CHANNEL.opacity, CHANNEL.translate],
13100
+ [CHANNEL.opacity, CHANNEL.translate, attributeChannel("data-kui-visible")],
12170
13101
  { offset: { type: "number", default: "400", cssProperty: "--kui-offset" } },
12171
13102
  deferPrepare(prepareBackToTop)
12172
13103
  )
@@ -12729,7 +13660,10 @@ var kuinetic = (() => {
12729
13660
  var SCROLL_PRIMITIVES = [
12730
13661
  scrollPrimitive({
12731
13662
  id: "pin",
12732
- channels: ["layout", "progress"],
13663
+ // `position: sticky` and its `top` (`installSticky`): the host's position is what it holds. The
13664
+ // spacer it can insert is a sibling, not a child, so the host's subtree is not claimed. And the
13665
+ // `data-kui-pinned` flag it publishes while holding.
13666
+ channels: ["position", "progress", attributeChannel("data-kui-pinned")],
12733
13667
  parameters: {
12734
13668
  ...distanceParam2,
12735
13669
  ...stickyParams
@@ -12739,7 +13673,13 @@ var kuinetic = (() => {
12739
13673
  }),
12740
13674
  scrollPrimitive({
12741
13675
  id: "scroll-progress",
12742
- channels: ["progress"],
13676
+ // `data-kui-step` beside `progress`: with `steps:` this publishes the same index attribute (and
13677
+ // its `--kui-step` twin) a `carousel` does, so the two on one element would overwrite each
13678
+ // other's step every frame. The claim is static, so it holds with `steps:0` too — a list can't
13679
+ // be composed or refused per param value. One claim covers both writes: every primitive that
13680
+ // writes the `--kui-step` index also writes the attribute, so a separate channel for the
13681
+ // property could never change a decision.
13682
+ channels: ["progress", attributeChannel("data-kui-step")],
12743
13683
  parameters: {
12744
13684
  ...distanceParam2,
12745
13685
  steps: { type: "number", default: "0", cssProperty: "--kui-steps" },
@@ -12775,7 +13715,8 @@ var kuinetic = (() => {
12775
13715
  }),
12776
13716
  scrollPrimitive({
12777
13717
  id: "media-scrub",
12778
- channels: ["media", "progress"],
13718
+ // `position`: it holds its host the way a pin does (`position: sticky` and its `top`).
13719
+ channels: ["media", "progress", "position"],
12779
13720
  parameters: {
12780
13721
  ...distanceParam2,
12781
13722
  // A scrub is a hold, so it needs the same two knobs a pin does. Declaring them here is what
@@ -12794,7 +13735,7 @@ var kuinetic = (() => {
12794
13735
  }),
12795
13736
  scrollPrimitive({
12796
13737
  id: "scroll-spy",
12797
- channels: ["state"],
13738
+ channels: [attributeChannel("data-kui-active")],
12798
13739
  parameters: {
12799
13740
  // `distance`: the per-section form only. `offset-top`: the container form only. Each is a
12800
13741
  // no-op — warned, not silent — in the other; see `prepareScrollSpySingle` and
@@ -12820,8 +13761,8 @@ var kuinetic = (() => {
12820
13761
  scrollPrimitive({
12821
13762
  id: "smooth-scroll",
12822
13763
  /*
12823
- * Its own channel, not the `'layout'` it used to share with `pin`, `stacking-cards` and
12824
- * `scroll-snap`. The channel model exists to stop two effects fighting over the same CSS
13764
+ * Its own channel, not the `'layout'` bucket it used to share with `pin`, `stacking-cards` and
13765
+ * `scroll-snap` (a bucket since split into the properties each writes). The channel model exists to stop two effects fighting over the same CSS
12825
13766
  * property, and this one writes exactly `scroll-behavior` — a property that describes how a
12826
13767
  * *user-or-script-initiated* scroll is performed, and that no other primitive touches.
12827
13768
  *
@@ -12842,7 +13783,11 @@ var kuinetic = (() => {
12842
13783
  }),
12843
13784
  scrollPrimitive({
12844
13785
  id: "scroll-snap",
12845
- channels: ["layout"],
13786
+ // `scroll-snap-type`, and with `target:` the container it makes of the host (`installSnapContainer`):
13787
+ // `overflow`, and `display: flex` on the x axis — `discrete`'s property. A claim is static, so
13788
+ // it holds without `target:` too. The subtree likewise: with no `target:` the host's children
13789
+ // are the snap items, each given a `scroll-snap-align`.
13790
+ channels: ["scroll-snap-type", "overflow", "discrete", SUBTREE_CHANNEL],
12846
13791
  parameters: {
12847
13792
  axis: { type: "keyword", default: "y", cssProperty: "--kui-axis", keywords: ["x", "y"] },
12848
13793
  strictness: {
@@ -13184,7 +14129,7 @@ var kuinetic = (() => {
13184
14129
  return children.filter((child) => child.textContent.trim() === "");
13185
14130
  }
13186
14131
  function withIconParts(inner) {
13187
- return (el, params, ctx) => {
14132
+ return inheritTimingContract(inner, (el, params, ctx) => {
13188
14133
  if (!el.querySelector(".kui-bar")) {
13189
14134
  const bars = findBars(el);
13190
14135
  if (bars.length > 0) {
@@ -13203,7 +14148,7 @@ var kuinetic = (() => {
13203
14148
  }
13204
14149
  }
13205
14150
  return inner(el, params, ctx);
13206
- };
14151
+ });
13207
14152
  }
13208
14153
 
13209
14154
  // src/effects/svg/index.ts
@@ -13242,18 +14187,18 @@ var kuinetic = (() => {
13242
14187
  };
13243
14188
  let current = 0;
13244
14189
  const enter = () => drive(1);
13245
- const leave = () => drive(0);
14190
+ const leave2 = () => drive(0);
13246
14191
  el.addEventListener("pointerenter", enter, { passive: true });
13247
14192
  el.addEventListener("focusin", enter, { passive: true });
13248
- el.addEventListener("pointerleave", leave, { passive: true });
13249
- el.addEventListener("focusout", leave, { passive: true });
14193
+ el.addEventListener("pointerleave", leave2, { passive: true });
14194
+ el.addEventListener("focusout", leave2, { passive: true });
13250
14195
  return () => {
13251
14196
  cancelled = true;
13252
14197
  cancelAnimationFrame(frame);
13253
14198
  el.removeEventListener("pointerenter", enter);
13254
14199
  el.removeEventListener("focusin", enter);
13255
- el.removeEventListener("pointerleave", leave);
13256
- el.removeEventListener("focusout", leave);
14200
+ el.removeEventListener("pointerleave", leave2);
14201
+ el.removeEventListener("focusout", leave2);
13257
14202
  path2.setAttribute("d", startPath);
13258
14203
  };
13259
14204
  }
@@ -13274,7 +14219,16 @@ var kuinetic = (() => {
13274
14219
  var ICON_TOGGLE_PRIMITIVE = {
13275
14220
  id: "icon-toggle",
13276
14221
  renderer: "javascript",
13277
- channels: [CHANNEL.translate, CHANNEL.rotate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.clip],
14222
+ // The subtree too: with no `.kui-bar` markup the host's own children are the bars
14223
+ // (`icon-parts.ts` stamps them), so a deck or widget beside it would have its children moved.
14224
+ channels: [
14225
+ CHANNEL.translate,
14226
+ CHANNEL.rotate,
14227
+ CHANNEL.scale,
14228
+ CHANNEL.opacity,
14229
+ CHANNEL.clip,
14230
+ SUBTREE_CHANNEL
14231
+ ],
13278
14232
  parameters: {
13279
14233
  duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
13280
14234
  // The state flip is the start moment: `aria-expanded` goes true and the bars begin to move.
@@ -13472,8 +14426,10 @@ var kuinetic = (() => {
13472
14426
  // `'discrete'` alongside `rotate`: the unconditional `[data-kui-fx~='flip-card']` rule pins
13473
14427
  // `display: grid` so the two faces stack in one cell instead of flowing as normal block
13474
14428
  // siblings — unrelated to `catalog/discrete.ts`'s show/hide use of the same physical property,
13475
- // but `display` is tracked as one channel regardless of the value written into it.
13476
- channels: [CHANNEL.rotate, "discrete"],
14429
+ // but `display` is tracked as one channel regardless of the value written into it. The subtree
14430
+ // because the host's children are the two faces (`flip-parts.ts` marks them, and injects the
14431
+ // toggle button when none is authored).
14432
+ channels: [CHANNEL.rotate, "discrete", SUBTREE_CHANNEL],
13477
14433
  parameters: {
13478
14434
  duration: { type: "time", default: "700ms", cssProperty: "--kui-duration" },
13479
14435
  // The turn has a start moment — the click, or the pointer arriving — so a delay before it is
@@ -13611,8 +14567,8 @@ var kuinetic = (() => {
13611
14567
  function snapTo(projected) {
13612
14568
  return Math.round(projected) + 0;
13613
14569
  }
13614
- function wrapPlace(place2, total) {
13615
- return total > 0 ? (place2 % total + total) % total : 0;
14570
+ function wrapPlace(place3, total) {
14571
+ return total > 0 ? (place3 % total + total) % total : 0;
13616
14572
  }
13617
14573
  var KEY_DELTAS = {
13618
14574
  ArrowRight: 1,
@@ -13624,8 +14580,26 @@ var kuinetic = (() => {
13624
14580
  Home: null,
13625
14581
  End: null
13626
14582
  };
13627
- function createRingDrag(request) {
13628
- const { el, ctx, enabled, travelPx, total, positionOf, moveTo, setDragging } = request;
14583
+ function holdSelection(doc) {
14584
+ let ledger;
14585
+ const release2 = () => {
14586
+ ledger?.restore();
14587
+ ledger = void 0;
14588
+ };
14589
+ return {
14590
+ hold() {
14591
+ release2();
14592
+ doc.getSelection()?.removeAllRanges();
14593
+ ledger = createStyleLedger(doc.documentElement);
14594
+ ledger.claim("user-select");
14595
+ ledger.claim("-webkit-user-select");
14596
+ ledger.set("user-select", "none");
14597
+ ledger.set("-webkit-user-select", "none");
14598
+ },
14599
+ release: release2
14600
+ };
14601
+ }
14602
+ function guardClicks(el, enabled) {
13629
14603
  let suppressClick = false;
13630
14604
  const onClickCapture = (event) => {
13631
14605
  if (!suppressClick) return;
@@ -13633,19 +14607,49 @@ var kuinetic = (() => {
13633
14607
  event.stopPropagation();
13634
14608
  event.preventDefault();
13635
14609
  };
14610
+ const onPressCapture = () => {
14611
+ suppressClick = false;
14612
+ };
14613
+ const onNativeDrag = (event) => {
14614
+ event.preventDefault();
14615
+ };
14616
+ el.addEventListener("click", onClickCapture, true);
14617
+ el.addEventListener("pointerdown", onPressCapture, true);
14618
+ if (enabled) el.addEventListener("dragstart", onNativeDrag);
14619
+ return {
14620
+ arm() {
14621
+ suppressClick = true;
14622
+ },
14623
+ release() {
14624
+ el.removeEventListener("click", onClickCapture, true);
14625
+ el.removeEventListener("pointerdown", onPressCapture, true);
14626
+ el.removeEventListener("dragstart", onNativeDrag);
14627
+ }
14628
+ };
14629
+ }
14630
+ function createRingDrag(request) {
14631
+ const { el, ctx, enabled, travelPx, total, positionOf, moveTo, setDragging } = request;
14632
+ const clicks = guardClicks(el, enabled);
14633
+ let dragFrom = 0;
14634
+ let sign = 1;
14635
+ const selection = holdSelection(el.ownerDocument);
13636
14636
  const stopRecognising = enabled ? recognise(
13637
14637
  el,
13638
14638
  {
13639
14639
  onStart() {
14640
+ dragFrom = positionOf();
14641
+ sign = request.travelSign();
14642
+ selection.hold();
13640
14643
  setDragging(true);
13641
14644
  },
13642
14645
  onMove(vector) {
13643
- moveTo(positionOf() - vector.dx / travelPx);
14646
+ moveTo(dragFrom - sign * vector.dx / travelPx);
13644
14647
  },
13645
14648
  onEnd(vector) {
14649
+ selection.release();
13646
14650
  setDragging(false);
13647
- suppressClick = true;
13648
- const settled = snapTo(projectRelease(positionOf(), vector.vx, travelPx));
14651
+ clicks.arm();
14652
+ const settled = snapTo(projectRelease(positionOf(), sign * vector.vx, travelPx));
13649
14653
  moveTo(wrapPlace(settled, total()));
13650
14654
  }
13651
14655
  },
@@ -13654,7 +14658,15 @@ var kuinetic = (() => {
13654
14658
  // threshold than `recognise`'s own default of 4px is what buys that — a ring occupies a
13655
14659
  // large slab of a phone screen, and a scroll that begins with a two-pixel horizontal wobble
13656
14660
  // should not capture the pointer away from the page.
13657
- { axis: "x", threshold: DRAG_THRESHOLD_PX }
14661
+ //
14662
+ // Capture only once the press becomes a drag (`'drag'`), never on `pointerdown`. Held from
14663
+ // the press, capture retargets the `click` that ends a plain tap at this host instead of the
14664
+ // card under the pointer — found by the `lightbox:true` browser tier, where a real click on
14665
+ // a card arrived as a click on the ring and opened nothing. The same retargeting made every
14666
+ // link and button inside a card dead in a real browser while jsdom (which has no capture)
14667
+ // stayed green. The host never moves under the pointer — its cards do — so the press has
14668
+ // nothing to stay captured for until it is a drag, and a drag's click is swallowed above.
14669
+ { axis: "x", threshold: DRAG_THRESHOLD_PX, capturePointer: "drag" }
13658
14670
  ) : () => {
13659
14671
  };
13660
14672
  if (enabled) ctx.style.set("touch-action", "pan-y");
@@ -13673,216 +14685,213 @@ var kuinetic = (() => {
13673
14685
  const grantedFocus = el.getAttribute("tabindex") === null;
13674
14686
  if (grantedFocus) el.setAttribute("tabindex", "0");
13675
14687
  el.addEventListener("keydown", onKeyDown);
13676
- el.addEventListener("click", onClickCapture, true);
13677
14688
  return () => {
13678
14689
  stopRecognising();
14690
+ selection.release();
13679
14691
  el.removeEventListener("keydown", onKeyDown);
13680
- el.removeEventListener("click", onClickCapture, true);
14692
+ clicks.release();
13681
14693
  if (grantedFocus) el.removeAttribute("tabindex");
13682
14694
  };
13683
14695
  }
13684
14696
 
13685
- // src/effects/carousel/index.ts
14697
+ // src/effects/carousel/deck.ts
13686
14698
  var DRAGGING_ATTR = "data-kui-ring-dragging";
13687
- var FACE_ATTR = "data-kui-ring-face";
13688
- var QUARTER_TURN_DEG = 90;
13689
- var FLATTENING = [
13690
- ["overflow", (value) => value !== "" && value !== "visible"],
13691
- ["clip-path", (value) => value !== "" && value !== "none"],
13692
- ["opacity", (value) => value !== "" && Number(value) < 1],
13693
- ["filter", (value) => value !== "" && value !== "none"],
13694
- ["backdrop-filter", (value) => value !== "" && value !== "none"]
14699
+ var SPATIAL_DECK_CHANNELS = [
14700
+ SUBTREE_CHANNEL,
14701
+ attributeChannel("data-kui-step"),
14702
+ attributeChannel(DRAGGING_ATTR),
14703
+ attributeChannel("tabindex")
13695
14704
  ];
13696
- function warnFlatteningAncestor(el, ctx) {
13697
- const body = el.ownerDocument.body;
13698
- let node = el.parentElement;
13699
- while (node && node !== body) {
13700
- const found = flatteningDeclaration(node, ctx);
13701
- if (found) {
13702
- ctx.warn(
13703
- `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.`
13704
- );
13705
- return;
13706
- }
13707
- node = node.parentElement;
13708
- }
13709
- }
13710
- function flatteningDeclaration(node, ctx) {
13711
- const style = ctx.win.getComputedStyle?.(node);
13712
- if (!style) return null;
13713
- for (const [property2, flattens] of FLATTENING) {
13714
- const value = style.getPropertyValue(property2);
13715
- if (flattens(value)) return { property: property2, value };
13716
- }
13717
- return null;
13718
- }
13719
- function ledgersFor(store, node) {
13720
- let attributes = store.attributes.get(node);
13721
- if (!attributes) {
13722
- attributes = createAttributeLedger(node);
13723
- store.attributes.set(node, attributes);
13724
- }
13725
- let styles = store.styles.get(node);
13726
- if (!styles) {
13727
- styles = createStyleLedger(node);
13728
- store.styles.set(node, styles);
13729
- }
13730
- return { attributes, styles };
14705
+ var SPINNING_ATTR = "data-kui-ring-spinning";
14706
+ var FACE_ATTR = "data-kui-ring-face";
14707
+ var WRAP_ATTR = "data-kui-ring-wrap";
14708
+ function commitStyle(win, node) {
14709
+ return win.getComputedStyle?.(node).transform;
13731
14710
  }
13732
- function faceAt(angleDeg) {
13733
- return Math.abs(normaliseDegrees(angleDeg)) >= QUARTER_TURN_DEG ? "back" : "front";
14711
+ function travelSign(liveX, nextX, previousX) {
14712
+ if (nextX !== null) return nextX < liveX ? -1 : 1;
14713
+ if (previousX !== null) return previousX > liveX ? -1 : 1;
14714
+ return 1;
13734
14715
  }
13735
- function normaliseDegrees(angleDeg) {
13736
- const wrapped2 = (angleDeg % 360 + 360) % 360;
13737
- return wrapped2 > 180 ? wrapped2 - 360 : wrapped2;
14716
+ function offsetWrapped(previous, next, count) {
14717
+ return Math.abs(next - previous) > count / 2;
13738
14718
  }
13739
14719
  function snapPosition(position, total) {
13740
14720
  if (total <= 0) return { step: 0, drift: 0 };
13741
14721
  const nearest = Math.round(position);
13742
14722
  return { step: (nearest % total + total) % total, drift: position - nearest };
13743
14723
  }
13744
- var RING_PARAMETERS = {
14724
+ var DECK_PARAMETERS = {
13745
14725
  duration: { type: "time", default: "620ms", cssProperty: "--kui-duration" },
13746
14726
  ...TRIGGER_DELAY_PARAM,
13747
14727
  ease: { type: "easing", default: "cubic-bezier(0.22, 1, 0.36, 1)", cssProperty: "--kui-ease" },
13748
- /*
13749
- * A real CSS `<angle>`, not a normalised scalar.
13750
- *
13751
- * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
13752
- * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
13753
- * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
13754
- * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
13755
- * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
13756
- * `clamp()` an author can see in devtools rather than a hidden rescale.
13757
- *
13758
- * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
13759
- * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
13760
- * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
13761
- * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
13762
- */
13763
- tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
13764
- /*
13765
- * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
13766
- *
13767
- * This is the parameter the concave name exists around. From the centre of a *full* ring most of
13768
- * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
13769
- * two thirds of the content — the inside name defaults to a slice for that reason.
13770
- */
13771
- arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
13772
- /*
13773
- * The ring's radius, or unset for one derived from the content.
13774
- *
13775
- * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
13776
- * `readParams` fills every declared default in unconditionally, so an empty one is the only way
13777
- * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
13778
- * the circumradius of a regular polygon with this many sides and this much side length —
13779
- * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
13780
- * Every consumer solving that by hand was the alternative, and they would all have solved it
13781
- * differently.
13782
- *
13783
- * The width half of that has to come from JavaScript: CSS can compute with a measurement but
13784
- * cannot take one. See `measureItemWidth`.
13785
- */
13786
- radius: { type: "length", default: "", cssProperty: "--kui-radius" },
13787
- /** Breathing room between neighbours, spent by the derived radius above. */
13788
- gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
13789
- /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
13790
- perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
13791
- /*
13792
- * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
13793
- *
13794
- * `radial` is the ring's natural state and needs nothing: a slot placed by
13795
- * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
13796
- * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
13797
- * ring read as the far side.
13798
- *
13799
- * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
13800
- * and the billboard have to stay on separate boxes or every later change to one silently
13801
- * multiplies into the other — so text stays readable at every position, which is what a raised
13802
- * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
13803
- */
13804
- facing: {
13805
- type: "keyword",
13806
- default: "radial",
13807
- cssProperty: "--kui-facing",
13808
- keywords: ["radial", "camera"]
13809
- },
13810
- /** Whether a pointer can spin the ring by hand. */
14728
+ /** Whether a pointer can move the deck by hand. */
13811
14729
  grab: { type: "keyword", default: "true", cssProperty: "--kui-grab", keywords: ["true", "false"] },
13812
14730
  /*
13813
- * How far the pointer travels, in pixels, to move the ring one place.
14731
+ * How far the pointer travels, in pixels, to move the deck one place.
13814
14732
  *
13815
14733
  * A pixels-per-step mapping rather than pixels-per-degree, because a step is the unit everything
13816
14734
  * else here is in — the index, the offset, the snap — and a degree is not: the same drag would
13817
14735
  * move a six-item ring one place and a sixty-item ring ten, purely because the spacing changed.
13818
14736
  */
13819
14737
  travel: { type: "number", default: "220", cssProperty: "--kui-travel", finite: true, minimum: 1 },
13820
- /** Which elements sit on the ring. Unset means this element's own children. */
14738
+ /** Which elements are the slides. Unset means this element's own children. */
13821
14739
  target: { type: "text", default: "", cssProperty: "--kui-target" },
13822
14740
  /** Optional controls, resolved exactly as `step-progress` resolves its own. */
13823
14741
  next: { type: "text", default: "", cssProperty: "--kui-next" },
13824
14742
  prev: { type: "text", default: "", cssProperty: "--kui-prev" },
13825
14743
  jump: { type: "text", default: "", cssProperty: "--kui-jump" },
14744
+ /** A control that pauses and resumes `spin:`/`autoplay:`. See `PAUSE_PARAM` (`auto-motion.ts`). */
14745
+ pause: PAUSE_PARAM,
14746
+ /** `hover:none`: a resting pointer no longer pauses `spin:`/`autoplay:`. See `HOVER_PARAM`. */
14747
+ hover: HOVER_PARAM,
14748
+ /*
14749
+ * Continuous rotation: how long one full cycle of the deck takes. `0s` is off.
14750
+ *
14751
+ * A period rather than a speed because a period is what a designer can picture ("one turn every
14752
+ * forty seconds") and it is a `time`, which the attribute grammar already reads, clamps and
14753
+ * documents. A full cycle is every slide passing once — one revolution of a full ring — so adding a
14754
+ * card does not change how fast the ring turns. **Negative runs the other way**: `spin:-40s`. A
14755
+ * signed value rather than a `direction:` keyword so one row can say everything about its motion
14756
+ * in one token, which is what three rows at three speeds on one page want.
14757
+ */
14758
+ spin: { type: "time", default: "0s", cssProperty: "--kui-spin" },
14759
+ /*
14760
+ * Stepped motion: how long the deck rests on each slide. When both are set `spin:` wins, because a
14761
+ * deck cannot be both gliding and resting. See `AUTOPLAY_PARAM` (`auto-motion.ts`).
14762
+ */
14763
+ autoplay: AUTOPLAY_PARAM,
14764
+ /*
14765
+ * `lightbox:true`: a click on a card opens every card of this deck in the shared lightbox gallery,
14766
+ * at the card clicked. A drag still only moves the deck — its click is swallowed before it can
14767
+ * reach a card (`drag.ts`). The gallery itself lives in the showcase module; see
14768
+ * `core/deck-viewer.ts` for how a deck reaches it without importing it.
14769
+ */
14770
+ lightbox: DECK_LIGHTBOX_PARAM,
13826
14771
  scope: SCOPE_PARAM
13827
14772
  };
13828
- function measureItemWidth(styles, slots) {
13829
- for (const slot of slots) {
13830
- const width = slot.offsetWidth;
13831
- if (width > 0) styles.set("--kui-item-width", `${width}px`);
13832
- return;
13833
- }
13834
- }
13835
- function prepareSpatialRing(el, params, ctx) {
13836
- warnFlatteningAncestor(el, ctx);
13837
- mirrorTimingToCss("spatial-ring", ALL_TIMING_TOKENS, params, ctx);
13838
- const selector = resolveTarget(params.text("target"), ctx, "carousel");
14773
+ function ledgerFor(store, node) {
14774
+ let ledger = store.attributes.get(node);
14775
+ if (!ledger) {
14776
+ ledger = createAttributeLedger(node);
14777
+ store.attributes.set(node, ledger);
14778
+ }
14779
+ return ledger;
14780
+ }
14781
+ function measureItem(styles, slots) {
14782
+ const first = slots[0];
14783
+ if (!first) return;
14784
+ if (first.offsetWidth > 0) styles.set("--kui-item-width", `${first.offsetWidth}px`);
14785
+ if (first.offsetHeight > 0) styles.set("--kui-item-height", `${first.offsetHeight}px`);
14786
+ }
14787
+ function prepareSpatialDeck(el, params, ctx, layout) {
14788
+ const { label } = layout;
14789
+ mirrorTimingToCss(layout.id, ALL_TIMING_TOKENS, params, ctx);
14790
+ const selector = resolveTarget(params.text("target"), ctx, label);
13839
14791
  const scope = scopeParam(params, "self");
13840
14792
  const resolveSlots = () => selector ? queryScoped(el, ctx, selector, scope) : el.children;
13841
- const marker = createStepMarker(resolveSlots, (message) => ctx.warn(`carousel ${message}`));
14793
+ const marker = createStepMarker(resolveSlots, (message) => ctx.warn(`${label} ${message}`));
13842
14794
  const total = () => countSteps(params, resolveSlots);
13843
14795
  const hostAttributes = createAttributeLedger(el);
13844
14796
  const hostStyles = createStyleLedger(el);
13845
- hostAttributes.set("data-kui-ring-facing", params.text("facing", "radial"));
13846
- const slots = { attributes: /* @__PURE__ */ new Map(), styles: /* @__PURE__ */ new Map() };
13847
- const derivesRadius = params.text("radius") === "";
14797
+ for (const [name, value] of Object.entries(layout.hostAttributes ?? {})) hostAttributes.set(name, value);
14798
+ const slots = { attributes: /* @__PURE__ */ new Map() };
14799
+ const attachment = layout.attach?.(hostStyles);
13848
14800
  let position = 0;
13849
- const arcDeg = degreesOf(params.text("arc", "360deg"), 360);
14801
+ let rendered = { step: -1, count: 1, nodes: [] };
14802
+ const lastOffsets = /* @__PURE__ */ new WeakMap();
14803
+ const settleWrapped = (nodes, count) => {
14804
+ for (const node of nodes) {
14805
+ const raw = Number(node.getAttribute("data-kui-step-offset"));
14806
+ const offset = layout.placeOf ? layout.placeOf(raw, count) : raw;
14807
+ const previous = lastOffsets.get(node);
14808
+ lastOffsets.set(node, offset);
14809
+ if (previous === void 0 || !offsetWrapped(previous, offset, count)) continue;
14810
+ const flag = createAttributeLedger(node);
14811
+ flag.set(WRAP_ATTR, "true");
14812
+ commitStyle(ctx.win, node);
14813
+ flag.restore();
14814
+ }
14815
+ };
14816
+ const markFaces = (nodes, count, drift2) => {
14817
+ for (const node of nodes) {
14818
+ const offset = Number(node.getAttribute("data-kui-step-offset"));
14819
+ const face = layout.faceOf(offset - drift2, offset, count);
14820
+ if (node.getAttribute(FACE_ATTR) !== face) ledgerFor(slots, node).set(FACE_ATTR, face);
14821
+ }
14822
+ };
14823
+ const publishPosition = (step, drift2) => {
14824
+ hostStyles.set("--kui-step-position", (step + drift2).toFixed(4));
14825
+ };
13850
14826
  const render = () => {
13851
14827
  const count = total();
13852
14828
  const { step, drift: drift2 } = snapPosition(position, count);
13853
14829
  hostAttributes.set("data-kui-step", String(step));
13854
14830
  hostStyles.set("--kui-step", String(step));
13855
- hostStyles.set("--kui-step-position", (step + drift2).toFixed(4));
14831
+ hostStyles.set("--kui-item-count", String(count));
14832
+ publishPosition(step, drift2);
13856
14833
  marker.mark(step);
13857
- const slotNodes = [...resolveSlots()];
13858
- if (derivesRadius) measureItemWidth(hostStyles, slotNodes);
13859
- const spacing = arcDeg / count;
13860
- for (const node of slotNodes) {
13861
- const offset = Number(node.getAttribute("data-kui-step-offset"));
13862
- const angle = (offset - drift2) * spacing;
13863
- ledgersFor(slots, node).attributes.set(FACE_ATTR, faceAt(angle));
14834
+ const nodes = [...resolveSlots()];
14835
+ if (layout.measure) measureItem(hostStyles, nodes);
14836
+ markFaces(nodes, count, drift2);
14837
+ settleWrapped(nodes, count);
14838
+ attachment?.render(nodes, count);
14839
+ rendered = { step, count, nodes };
14840
+ };
14841
+ const renderFrame = () => {
14842
+ const { step, drift: drift2 } = snapPosition(position, rendered.count);
14843
+ if (step !== rendered.step) {
14844
+ render();
14845
+ return;
13864
14846
  }
14847
+ publishPosition(step, drift2);
14848
+ markFaces(rendered.nodes, rendered.count, drift2);
13865
14849
  };
14850
+ const { motion, pause, release: releaseMotion } = createDeckMotion({
14851
+ el,
14852
+ ctx,
14853
+ params,
14854
+ label,
14855
+ settleMs: effectDurationMs(params, 620),
14856
+ step(direction) {
14857
+ const count = total();
14858
+ const { step } = snapPosition(position, count);
14859
+ position = direction > 0 ? nextStep(step, count) : prevStep(step, count);
14860
+ render();
14861
+ },
14862
+ spin: {
14863
+ advance(cycles) {
14864
+ position = wrapPlace(position + cycles * rendered.count, rendered.count);
14865
+ renderFrame();
14866
+ },
14867
+ setSpinning: (spinning) => hostAttributes.set(SPINNING_ATTR, String(spinning))
14868
+ }
14869
+ });
13866
14870
  const goTo = (next) => {
14871
+ motion.interrupt();
13867
14872
  position = next;
13868
14873
  render();
13869
14874
  };
13870
- const groups = [];
13871
- const bindControl = (param, run) => {
13872
- const control2 = resolveTarget(params.text(param), ctx, `carousel ${param}`);
13873
- if (!control2) return false;
13874
- if (queryScoped(el, ctx, control2, scope).length === 0) {
13875
- ctx.warn(`carousel ${param} "${control2}" matched nothing`);
14875
+ const request = { el, ctx, params, label };
14876
+ const groups = [
14877
+ bindControl(request, "next", () => goTo(nextStep(snapPosition(position, total()).step, total()))),
14878
+ bindControl(request, "prev", () => goTo(prevStep(snapPosition(position, total()).step, total()))),
14879
+ bindControl(request, "jump", (_node, at) => {
14880
+ if (at >= 0) goTo(at);
14881
+ }),
14882
+ pause
14883
+ ].filter((group) => group !== null);
14884
+ const releaseControls = delegateControls({ el, ctx, groups });
14885
+ const screenTravel = () => {
14886
+ const centres = /* @__PURE__ */ new Map();
14887
+ for (const node of rendered.nodes) {
14888
+ const offset = Number(node.getAttribute("data-kui-step-offset"));
14889
+ if (Math.abs(offset) > 1) continue;
14890
+ const box = node.getBoundingClientRect();
14891
+ centres.set(offset, box.left + box.width / 2);
13876
14892
  }
13877
- groups.push({ selector: control2, run });
13878
- return true;
14893
+ return travelSign(centres.get(0) ?? 0, centres.get(1) ?? null, centres.get(-1) ?? null);
13879
14894
  };
13880
- bindControl("next", () => goTo(nextStep(snapPosition(position, total()).step, total())));
13881
- bindControl("prev", () => goTo(prevStep(snapPosition(position, total()).step, total())));
13882
- bindControl("jump", (_node, at) => {
13883
- if (at >= 0) goTo(at);
13884
- });
13885
- const releaseControls = delegateControls({ el, ctx, scope, groups });
13886
14895
  const releaseDrag = createRingDrag({
13887
14896
  el,
13888
14897
  ctx,
@@ -13890,22 +14899,405 @@ var kuinetic = (() => {
13890
14899
  travelPx: params.num("travel", 220),
13891
14900
  total,
13892
14901
  positionOf: () => position,
14902
+ travelSign: screenTravel,
13893
14903
  moveTo: goTo,
13894
- setDragging: (dragging) => hostAttributes.set(DRAGGING_ATTR, String(dragging))
14904
+ setDragging: (dragging) => {
14905
+ hostAttributes.set(DRAGGING_ATTR, String(dragging));
14906
+ motion.hold(dragging);
14907
+ }
13895
14908
  });
14909
+ const releaseViewer = deckLightbox(
14910
+ params.is("lightbox"),
14911
+ { host: el, cards: resolveSlots, doc: ctx.doc, reducedMotion: ctx.reducedMotion },
14912
+ (message) => ctx.warn(`${label} ${message}`)
14913
+ );
13896
14914
  render();
13897
14915
  return () => {
14916
+ releaseViewer();
14917
+ releaseMotion();
13898
14918
  releaseDrag();
13899
14919
  releaseControls();
13900
14920
  marker.restore();
13901
14921
  for (const ledger of slots.attributes.values()) ledger.restore();
13902
- for (const ledger of slots.styles.values()) ledger.restore();
13903
14922
  slots.attributes.clear();
13904
- slots.styles.clear();
14923
+ attachment?.release();
13905
14924
  hostAttributes.restore();
13906
14925
  hostStyles.restore();
13907
14926
  };
13908
14927
  }
14928
+
14929
+ // src/effects/carousel/fit.ts
14930
+ var RING_FIT_PROPERTY = "--kui-ring-fit";
14931
+ var SAMPLE_DEG = 0.25;
14932
+ var SEARCH_CEILING_PX = 1e5;
14933
+ var SEARCH_STEPS = 34;
14934
+ var RAD = Math.PI / 180;
14935
+ function reachableAngles(arcDeg, count) {
14936
+ if (Math.abs(arcDeg) >= 360) return { fromDeg: -180, toDeg: 180 };
14937
+ const places = Math.max(1, count);
14938
+ const spacing = arcDeg / places;
14939
+ const high = Math.floor(places / 2);
14940
+ const low = high - places + 1;
14941
+ const ends = [(low - 0.5) * spacing, (high + 0.5) * spacing];
14942
+ return { fromDeg: Math.min(...ends), toDeg: Math.max(...ends) };
14943
+ }
14944
+ function place2(point, { sinA, cosA, sinT, cosT }) {
14945
+ const [x, y, z] = point;
14946
+ const x1 = x * cosA + z * sinA;
14947
+ const z1 = -x * sinA + z * cosA;
14948
+ return [x1, y * cosT - z1 * sinT, y * sinT + z1 * cosT];
14949
+ }
14950
+ function cardFits(g, r, rotation) {
14951
+ const z0 = (g.inside ? -1 : 1) * r;
14952
+ const centre = place2([0, 0, z0], rotation);
14953
+ const hw = g.width / 2;
14954
+ const hh = g.height / 2;
14955
+ for (const [cx, cy] of [[-hw, -hh], [hw, -hh], [-hw, hh], [hw, hh]]) {
14956
+ const [x, , z] = g.facing === "camera" ? [centre[0] + cx, centre[1] + cy, centre[2]] : place2([cx, cy, z0], rotation);
14957
+ if (z >= g.perspective - 1) return false;
14958
+ const scale = Number.isFinite(g.perspective) ? g.perspective / (g.perspective - z) : 1;
14959
+ const screen2 = (g.originOffset + x) * scale;
14960
+ if (screen2 < g.minX || screen2 > g.maxX) return false;
14961
+ }
14962
+ return true;
14963
+ }
14964
+ function fitsAt(g, r, angles) {
14965
+ const sinT = Math.sin(g.tiltDeg * RAD);
14966
+ const cosT = Math.cos(g.tiltDeg * RAD);
14967
+ return angles.every((angle) => cardFits(g, r, { sinA: Math.sin(angle * RAD), cosA: Math.cos(angle * RAD), sinT, cosT }));
14968
+ }
14969
+ function sampleAngles(g) {
14970
+ const from = g.inside ? Math.max(g.fromDeg, -90) : g.fromDeg;
14971
+ const to = g.inside ? Math.min(g.toDeg, 90) : g.toDeg;
14972
+ const steps = Math.max(1, Math.ceil((to - from) / SAMPLE_DEG));
14973
+ return Array.from({ length: steps + 1 }, (_, i) => from + (to - from) * i / steps);
14974
+ }
14975
+ function solveRingFit(g) {
14976
+ const angles = sampleAngles(g);
14977
+ if (!fitsAt(g, 0, angles)) return 0;
14978
+ if (fitsAt(g, SEARCH_CEILING_PX, angles)) return Infinity;
14979
+ let low = 0;
14980
+ let high = SEARCH_CEILING_PX;
14981
+ for (let i = 0; i < SEARCH_STEPS; i++) {
14982
+ const mid = (low + high) / 2;
14983
+ if (fitsAt(g, mid, angles)) low = mid;
14984
+ else high = mid;
14985
+ }
14986
+ return Math.floor(low * 100) / 100;
14987
+ }
14988
+ function fitBounds(el, win) {
14989
+ const doc = el.ownerDocument;
14990
+ for (let node = el.parentElement; node && node !== doc.body && node !== doc.documentElement; node = node.parentElement) {
14991
+ const overflow = win.getComputedStyle(node).overflowX;
14992
+ if (overflow === "clip" || overflow === "hidden") return null;
14993
+ if (overflow === "auto" || overflow === "scroll") {
14994
+ const left2 = node.getBoundingClientRect().left + node.clientLeft;
14995
+ return { left: left2, right: left2 + node.clientWidth };
14996
+ }
14997
+ }
14998
+ const left = -win.scrollX;
14999
+ return { left, right: left + doc.documentElement.clientWidth };
15000
+ }
15001
+ function pixels(value, fallback) {
15002
+ const parsed = Number.parseFloat(value);
15003
+ return Number.isFinite(parsed) ? parsed : fallback;
15004
+ }
15005
+ function createRingFit(options) {
15006
+ const { el, ctx, styles } = options;
15007
+ if (typeof ctx.win.getComputedStyle !== "function" || typeof ctx.win.addEventListener !== "function") {
15008
+ return { render() {
15009
+ }, release() {
15010
+ } };
15011
+ }
15012
+ const tiltDeg = Math.min(80, Math.max(-80, -options.tiltParamDeg));
15013
+ let card;
15014
+ let count = 1;
15015
+ const solve = () => {
15016
+ const bounds = fitBounds(el, ctx.win);
15017
+ if (!bounds || !card || card.offsetWidth === 0) {
15018
+ styles.set(RING_FIT_PROPERTY, "");
15019
+ return;
15020
+ }
15021
+ const style = ctx.win.getComputedStyle(el);
15022
+ const host = el.getBoundingClientRect();
15023
+ const originX = host.left + pixels(style.perspectiveOrigin, host.width / 2);
15024
+ const fit = solveRingFit({
15025
+ width: card.offsetWidth,
15026
+ height: card.offsetHeight,
15027
+ perspective: pixels(style.perspective, Infinity),
15028
+ tiltDeg,
15029
+ // The stylesheet's own selector for the concave placement, read rather than threaded through
15030
+ // as a parameter: the name is what `carousel.css` branches on, so it is what this must too.
15031
+ inside: el.matches("[data-kui-fx~='carousel-3d-inside']"),
15032
+ // `facing:camera` counter-rotates the slot's element *children* (`carousel.css`); a slot with
15033
+ // none — bare text — is drawn at its radial angle, and is fitted as the radial card it is.
15034
+ facing: card.firstElementChild ? options.facing : "radial",
15035
+ ...reachableAngles(options.arcDeg, count),
15036
+ originOffset: host.left + host.width / 2 - originX,
15037
+ minX: bounds.left - originX,
15038
+ maxX: bounds.right - originX
15039
+ });
15040
+ styles.set(RING_FIT_PROPERTY, Number.isFinite(fit) ? `${fit}px` : "");
15041
+ };
15042
+ const frame = frameScheduler(ctx.win, solve);
15043
+ const watch = watchElementSize(ctx.win, frame.request);
15044
+ watch.observe(el);
15045
+ watch.observe(el.ownerDocument.documentElement);
15046
+ return {
15047
+ render(nodes, total) {
15048
+ const first = nodes[0];
15049
+ if (first === card && total === count) return;
15050
+ card = first;
15051
+ count = total;
15052
+ solve();
15053
+ },
15054
+ release() {
15055
+ watch.disconnect();
15056
+ frame.cancel();
15057
+ }
15058
+ };
15059
+ }
15060
+
15061
+ // src/effects/carousel/stack.ts
15062
+ function stackFace(offset, drift2, count, depth) {
15063
+ const ahead = stackPlace(offset, count) - drift2;
15064
+ return ahead > -0.5 && ahead < depth + 1 ? "front" : "back";
15065
+ }
15066
+ function stackPlace(offset, count) {
15067
+ return wrapPlace(offset + 1, count) - 1;
15068
+ }
15069
+ var STACK_PARAMETERS = {
15070
+ ...DECK_PARAMETERS,
15071
+ /*
15072
+ * How far each card behind the front one steps sideways. Positive is to the right, negative to
15073
+ * the left. A length rather than a percentage because the host reserves `depth × shift` of room
15074
+ * on that side (cards moved by a transform take no layout space) and a padding percentage would
15075
+ * resolve against a different box than a translate percentage does.
15076
+ */
15077
+ shift: { type: "length", default: "48px", cssProperty: "--kui-stack-shift" },
15078
+ /** How far each card behind steps upward. Negative recedes downward instead. */
15079
+ rise: { type: "length", default: "36px", cssProperty: "--kui-stack-rise" },
15080
+ /** Scale lost per place behind the front: `0.08` makes the third card back 76% size. */
15081
+ shrink: { type: "number", default: "0.08", cssProperty: "--kui-stack-shrink", finite: true, minimum: 0, maximum: 0.3 },
15082
+ /** Blur added per place behind the front. The front card is always sharp. */
15083
+ blur: { type: "length", default: "2px", cssProperty: "--kui-stack-blur" },
15084
+ /** Opacity lost per place behind the front. */
15085
+ fade: { type: "number", default: "0.2", cssProperty: "--kui-stack-fade", finite: true, minimum: 0, maximum: 1 },
15086
+ /*
15087
+ * How many cards show behind the front one. The rest wait, hidden, at the back — `visibility`,
15088
+ * not only `opacity`, so a card nobody can see is also not a tab stop or a click target.
15089
+ */
15090
+ depth: { type: "number", default: "3", cssProperty: "--kui-stack-depth", integer: true, minimum: 1, maximum: 8 }
15091
+ };
15092
+ function prepareSpatialStack(el, params, ctx) {
15093
+ const depth = params.num("depth", 3);
15094
+ return prepareSpatialDeck(el, params, ctx, {
15095
+ id: "spatial-stack",
15096
+ label: "carousel-stack",
15097
+ faceOf: (place3, offset, count) => stackFace(offset, offset - place3, count, depth),
15098
+ placeOf: stackPlace,
15099
+ measure: false
15100
+ });
15101
+ }
15102
+ var SPATIAL_STACK_PRIMITIVE = {
15103
+ id: "spatial-stack",
15104
+ renderer: "javascript",
15105
+ // What `deck.ts` writes on the host: the cards (the subtree), the step index, the drag.
15106
+ channels: [CHANNEL.skew, "discrete", CHANNEL.opacity, CHANNEL.filter, ...SPATIAL_DECK_CHANNELS],
15107
+ parameters: STACK_PARAMETERS,
15108
+ supportedTimelines: ["time"],
15109
+ supportedActivations: ["load", "enter", "click", "manual"],
15110
+ defaultActivation: "load",
15111
+ perfClass: "compositor",
15112
+ reducedMotion: "shorten",
15113
+ prepare: deferPrepare(prepareSpatialStack)
15114
+ };
15115
+ var STACK_PRESETS = [
15116
+ {
15117
+ name: "carousel-stack",
15118
+ primitive: "spatial-stack",
15119
+ requiresOwnSubtree: true,
15120
+ phase: "idle"
15121
+ }
15122
+ ];
15123
+
15124
+ // src/effects/carousel/index.ts
15125
+ var QUARTER_TURN_DEG = 90;
15126
+ var FLATTENING = [
15127
+ ["overflow", (value) => value !== "" && value !== "visible"],
15128
+ ["clip-path", (value) => value !== "" && value !== "none"],
15129
+ ["opacity", (value) => value !== "" && Number(value) < 1],
15130
+ ["filter", (value) => value !== "" && value !== "none"],
15131
+ ["backdrop-filter", (value) => value !== "" && value !== "none"],
15132
+ ["isolation", (value) => value === "isolate"],
15133
+ ["mix-blend-mode", (value) => value !== "" && value !== "normal"],
15134
+ ["mask-image", (value) => value !== "" && value !== "none"]
15135
+ ];
15136
+ function warnFlattenedHost(el, ctx) {
15137
+ const found = flatteningDeclaration(el, ctx);
15138
+ if (!found) return;
15139
+ ctx.warn(
15140
+ `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.`
15141
+ );
15142
+ }
15143
+ function flatteningDeclaration(node, ctx) {
15144
+ const style = ctx.win.getComputedStyle?.(node);
15145
+ if (!style) return null;
15146
+ for (const [property2, flattens] of FLATTENING) {
15147
+ const value = style.getPropertyValue(property2);
15148
+ if (flattens(value)) return { property: property2, value };
15149
+ }
15150
+ return null;
15151
+ }
15152
+ function faceAt(angleDeg) {
15153
+ return Math.abs(normaliseDegrees(angleDeg)) >= QUARTER_TURN_DEG ? "back" : "front";
15154
+ }
15155
+ function normaliseDegrees(angleDeg) {
15156
+ const wrapped2 = (angleDeg % 360 + 360) % 360;
15157
+ return wrapped2 > 180 ? wrapped2 - 360 : wrapped2;
15158
+ }
15159
+ var RING_PARAMETERS = {
15160
+ ...DECK_PARAMETERS,
15161
+ /*
15162
+ * A real CSS `<angle>`, not a normalised scalar.
15163
+ *
15164
+ * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
15165
+ * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
15166
+ * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
15167
+ * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
15168
+ * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
15169
+ * `clamp()` an author can see in devtools rather than a hidden rescale.
15170
+ *
15171
+ * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
15172
+ * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
15173
+ * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
15174
+ * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
15175
+ */
15176
+ tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
15177
+ /*
15178
+ * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
15179
+ *
15180
+ * This is the parameter the concave name exists around. From the centre of a *full* ring most of
15181
+ * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
15182
+ * two thirds of the content — the inside name defaults to a slice for that reason.
15183
+ */
15184
+ arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
15185
+ /*
15186
+ * The ring's radius, or unset for one derived from the content.
15187
+ *
15188
+ * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
15189
+ * `readParams` fills every declared default in unconditionally, so an empty one is the only way
15190
+ * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
15191
+ * the circumradius of a regular polygon with this many sides and this much side length —
15192
+ * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
15193
+ * Every consumer solving that by hand was the alternative, and they would all have solved it
15194
+ * differently.
15195
+ *
15196
+ * The width half of that has to come from JavaScript: CSS can compute with a measurement but
15197
+ * cannot take one. See `measureItem` in `deck.ts`.
15198
+ *
15199
+ * Authored or derived, a depth ring's radius is a ceiling: a ring that would push its page
15200
+ * sideways is pulled in exactly as far as it has to be (`fit.ts`, `--kui-ring-fit`), and one that
15201
+ * already fits — or sits inside an ancestor that clips it — keeps this radius to the pixel.
15202
+ */
15203
+ radius: { type: "length", default: "", cssProperty: "--kui-radius" },
15204
+ /** Breathing room between neighbours, spent by the derived radius above. */
15205
+ gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
15206
+ /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
15207
+ perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
15208
+ /*
15209
+ * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
15210
+ *
15211
+ * `radial` is the ring's natural state and needs nothing: a slot placed by
15212
+ * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
15213
+ * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
15214
+ * ring read as the far side.
15215
+ *
15216
+ * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
15217
+ * and the billboard have to stay on separate boxes or every later change to one silently
15218
+ * multiplies into the other — so text stays readable at every position, which is what a raised
15219
+ * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
15220
+ */
15221
+ facing: {
15222
+ type: "keyword",
15223
+ default: "radial",
15224
+ cssProperty: "--kui-facing",
15225
+ keywords: ["radial", "camera"]
15226
+ },
15227
+ /*
15228
+ * Which plane the ring lies in: `depth` (the default — a carousel you look *at*, turning about a
15229
+ * vertical pole) or `screen` (a clock face, turning about the axis that points at you).
15230
+ *
15231
+ * ## Why a parameter of this primitive, and not a new one
15232
+ *
15233
+ * A flat ring of cards around a centred word is the same object as the 3D ring seen from its
15234
+ * pole: N slots, evenly spaced by `arc / count`, placed from `--kui-offset` and the shared drift,
15235
+ * advanced by the same drag, keys, controls and `spin:`. Every number this primitive publishes
15236
+ * means the same thing in both planes; only the three transform functions that turn a place into
15237
+ * a position differ, and those live in `carousel.css` anyway. A second primitive would have
15238
+ * duplicated the whole index to change one stylesheet rule and one face test.
15239
+ *
15240
+ * It is a parameter rather than only a name (unlike convex/concave, which differ in every
15241
+ * default and so earned two names) because the runtime has to know it: a flat slot is never
15242
+ * turned away from the viewer, so the quarter-turn face test that stops the back of a 3D ring
15243
+ * swallowing clicks would, on a clock face, disable every card in the bottom half. The name that
15244
+ * ships it, `carousel-orbit`, sets it; the stylesheet gates on the published attribute rather
15245
+ * than the name, so `carousel-3d plane:screen` is equally honoured — same rule as `facing:`.
15246
+ *
15247
+ * `facing:` keeps its meaning here. `radial` turns each card with the ring, top edge outward like
15248
+ * the numerals on a watch bezel; `camera` keeps every card upright. In the screen plane upright
15249
+ * needs no child counter-rotation at all — the slot is *translated* to its place on the circle
15250
+ * instead of rotated there — so it costs no extra markup, unlike the 3D billboard.
15251
+ */
15252
+ plane: {
15253
+ type: "keyword",
15254
+ default: "depth",
15255
+ cssProperty: "--kui-ring-plane",
15256
+ keywords: ["depth", "screen"]
15257
+ }
15258
+ };
15259
+ function prepareSpatialRing(el, params, ctx) {
15260
+ const plane = params.text("plane", "depth");
15261
+ if (plane === "depth") warnFlattenedHost(el, ctx);
15262
+ const arcDeg = degreesOf(params.text("arc", "360deg"), 360);
15263
+ const faceOf = (place3, _offset, count) => plane === "screen" ? "front" : faceAt(place3 * (arcDeg / count));
15264
+ return prepareSpatialDeck(el, params, ctx, {
15265
+ id: "spatial-ring",
15266
+ label: "carousel",
15267
+ faceOf,
15268
+ /*
15269
+ * A derived radius needs a card width; a flat ring also needs a card's size to reserve its own
15270
+ * circle (the host is sized to it, since transformed slots take no layout space), so it measures
15271
+ * even when the author named a radius.
15272
+ */
15273
+ measure: params.text("radius") === "" || plane === "screen",
15274
+ hostAttributes: {
15275
+ /*
15276
+ * `facing:` and `plane:` reach CSS as attributes, not only as the custom properties their
15277
+ * specs declare. A stylesheet cannot select on the *value* of a custom property — `@container
15278
+ * style(--kui-facing: camera)` can, and is not supported widely enough to hang a documented
15279
+ * parameter on — so the one thing a selector needs is published in the one form a selector
15280
+ * can read. Same pairing, for the same reason, as the number/attribute pair `step-marking.ts`
15281
+ * publishes for the ring place.
15282
+ */
15283
+ "data-kui-ring-facing": params.text("facing", "radial"),
15284
+ "data-kui-ring-plane": plane
15285
+ },
15286
+ /*
15287
+ * The fit to the page (`fit.ts`). Depth plane only: a flat ring already reserves and clamps to
15288
+ * its own square (`carousel.css`, `[data-kui-ring-plane='screen']`), and its cards never leave
15289
+ * the plane that square is measured in.
15290
+ */
15291
+ attach: plane === "depth" ? (styles) => createRingFit({
15292
+ el,
15293
+ ctx,
15294
+ styles,
15295
+ arcDeg,
15296
+ tiltParamDeg: degreesOf(params.text("tilt", "0deg"), 0),
15297
+ facing: params.text("facing", "radial") === "camera" ? "camera" : "radial"
15298
+ }) : void 0
15299
+ });
15300
+ }
13909
15301
  function degreesOf(value, fallbackDeg) {
13910
15302
  const match = /^(-?[\d.]+)(deg|rad|turn|grad)?$/i.exec(value.trim());
13911
15303
  if (!match) return fallbackDeg;
@@ -13925,7 +15317,15 @@ var kuinetic = (() => {
13925
15317
  var SPATIAL_RING_PRIMITIVE = {
13926
15318
  id: "spatial-ring",
13927
15319
  renderer: "javascript",
13928
- channels: [CHANNEL.skew, "discrete"],
15320
+ // What `deck.ts` writes on the host (the cards, the step index, the drag), then the two
15321
+ // attributes only the ring's own layout publishes.
15322
+ channels: [
15323
+ CHANNEL.skew,
15324
+ "discrete",
15325
+ ...SPATIAL_DECK_CHANNELS,
15326
+ attributeChannel("data-kui-ring-facing"),
15327
+ attributeChannel("data-kui-ring-plane")
15328
+ ],
13929
15329
  parameters: RING_PARAMETERS,
13930
15330
  supportedTimelines: ["time"],
13931
15331
  // `load`, not `enter`: a carousel that only wires its arrows once scrolled into view is broken,
@@ -13936,7 +15336,7 @@ var kuinetic = (() => {
13936
15336
  reducedMotion: "shorten",
13937
15337
  prepare: deferPrepare(prepareSpatialRing)
13938
15338
  };
13939
- var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE];
15339
+ var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE, SPATIAL_STACK_PRIMITIVE];
13940
15340
  var CAROUSEL_PRESETS = [
13941
15341
  {
13942
15342
  name: "carousel-3d",
@@ -13973,10 +15373,31 @@ var kuinetic = (() => {
13973
15373
  params: { arc: "120deg", facing: "camera", perspective: "900px" },
13974
15374
  requiresOwnSubtree: true,
13975
15375
  phase: "idle"
15376
+ },
15377
+ /*
15378
+ * The flat ring: cards on a clock face around whatever sits in the middle of the host (a word, a
15379
+ * logo — anything the `target:` does not name stays centred and off the ring). `plane:screen` is
15380
+ * the whole difference; see the parameter's note for why that is a parameter of this primitive and
15381
+ * not a new one.
15382
+ *
15383
+ * Named for what it does rather than which way it goes — `orbit` is the motion, and it has no
15384
+ * direction to name (the directional-naming rule, "named by travel", is about entrances that
15385
+ * *have* one; a ring's direction is the sign of `spin:`). Not `carousel-3d-*`, because it is not
15386
+ * three-dimensional, and a name that promised depth would send people looking for a `tilt:`.
15387
+ *
15388
+ * `facing:camera` by default: upright cards read as a set of things; radially turned ones read as
15389
+ * a dial, which is the less common ask and one keyword away.
15390
+ */
15391
+ {
15392
+ name: "carousel-orbit",
15393
+ primitive: "spatial-ring",
15394
+ params: { plane: "screen", facing: "camera" },
15395
+ requiresOwnSubtree: true,
15396
+ phase: "idle"
13976
15397
  }
13977
15398
  ];
13978
15399
  function registerCarousel(registry) {
13979
- return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS);
15400
+ return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS).registerPresets(STACK_PRESETS);
13980
15401
  }
13981
15402
 
13982
15403
  // src/effects/tween/properties.ts
@@ -14532,7 +15953,10 @@ var kuinetic = (() => {
14532
15953
  keywords: [...DEVICE_FRAME_KINDS]
14533
15954
  },
14534
15955
  color: { type: "color", default: "#111", cssProperty: "--kui-device-color" },
14535
- radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" }
15956
+ radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" },
15957
+ // Its own knob rather than a reading of `radius:`: the frame's corner and the screen's are two
15958
+ // shapes, and the derived screen value already follows `radius:` when this is left unwritten.
15959
+ "screen-radius": { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-screen-radius" }
14536
15960
  };
14537
15961
  function prepareDeviceFrame(el, params) {
14538
15962
  const state = createAttributeLedger(el);
@@ -14545,8 +15969,9 @@ var kuinetic = (() => {
14545
15969
  // Exclusive ownership of the frame chrome. Its static box-model declarations are outside
14546
15970
  // the channel-property audit, as they are for the existing catalog. The chrome also reaches
14547
15971
  // into the direct media child (`object-fit: cover` etc.); that is a `requiresOwnSubtree`
14548
- // question, as with the other showcase widgets.
14549
- channels: ["frame"],
15972
+ // question, as with the other showcase widgets. The frame kind it stamps on the host is an
15973
+ // attribute it writes, so it claims that too.
15974
+ channels: ["frame", attributeChannel("data-kui-device")],
14550
15975
  parameters: deviceFrameParams,
14551
15976
  // `'layout'`: the bezel's `border`+`padding` are box-model properties added once, at
14552
15977
  // activation, which is a real (if one-time) layout — not the "transform/opacity only" budget
@@ -14581,10 +16006,12 @@ var kuinetic = (() => {
14581
16006
  state.lock = ledger;
14582
16007
  }
14583
16008
  function clearContent(state) {
14584
- state.current?.onClose?.();
16009
+ const closing = state.current;
16010
+ closing?.onClose?.();
14585
16011
  state.current = void 0;
14586
16012
  state.currentOwner = void 0;
14587
16013
  state.slot?.replaceChildren();
16014
+ if (closing?.opener) announceViewer(state.doc, closing.opener, false);
14588
16015
  }
14589
16016
  function onClose(state) {
14590
16017
  state.dialog?.classList.remove("is-open");
@@ -14603,11 +16030,9 @@ var kuinetic = (() => {
14603
16030
  }, state.current.reducedMotion ? 0 : state.current.duration);
14604
16031
  }
14605
16032
  function clickedOutside(state, target) {
14606
- const content = state.current.node;
14607
- if (content.classList.contains("kui-lightbox-gallery")) {
14608
- return !content.contains(target) || !target.matches("img") && !target.closest("button");
14609
- }
14610
- return !content.contains(target) && !target.closest("button");
16033
+ const { node, inside } = state.current;
16034
+ if (inside) return !inside(target);
16035
+ return !node.contains(target) && !target.closest("button");
14611
16036
  }
14612
16037
  function forceReflow(element) {
14613
16038
  return element.offsetWidth;
@@ -14657,6 +16082,7 @@ var kuinetic = (() => {
14657
16082
  dialog.scrollTop = 0;
14658
16083
  forceReflow(dialog);
14659
16084
  dialog.classList.add("is-open");
16085
+ if (content.opener) announceViewer(state.doc, content.opener, true);
14660
16086
  }
14661
16087
  function closeNow(state) {
14662
16088
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -14736,25 +16162,31 @@ var kuinetic = (() => {
14736
16162
  }
14737
16163
 
14738
16164
  // src/showcase/lightbox.ts
16165
+ var DEFAULT_DURATION_MS = 280;
16166
+ var DEFAULT_SCALE = "0.965";
16167
+ var DEFAULT_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
14739
16168
  var paramsSchema = {
14740
- media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video"] },
16169
+ media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video", "mixed"] },
14741
16170
  target: { type: "text", default: "", cssProperty: "--kui-target" },
14742
16171
  scope: SCOPE_PARAM,
14743
- scale: { type: "number", default: "0.965", cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
14744
- duration: { type: "time", default: "280ms", cssProperty: "--kui-lightbox-duration" },
14745
- ease: { type: "easing", default: "cubic-bezier(0.22, 1, 0.36, 1)", cssProperty: "--kui-lightbox-ease" },
16172
+ scale: { type: "number", default: DEFAULT_SCALE, cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
16173
+ duration: { type: "time", default: `${DEFAULT_DURATION_MS}ms`, cssProperty: "--kui-lightbox-duration" },
16174
+ ease: { type: "easing", default: DEFAULT_EASE, cssProperty: "--kui-lightbox-ease" },
14746
16175
  loop: { type: "keyword", default: "true", cssProperty: "--kui-lightbox-loop", keywords: ["true", "false"] },
14747
16176
  aspect: { type: "keyword", default: "", cssProperty: "--kui-lightbox-aspect", keywords: ["wide", "tall", "square"] },
14748
16177
  caption: { type: "keyword", default: "figcaption", cssProperty: "--kui-lightbox-caption", keywords: ["figcaption", "alt", "title", "none"] }
14749
16178
  };
14750
16179
  function defaultTargets(el, media) {
14751
- if (el.matches("a[href]") || media === "image" && el.matches("img")) return [el];
16180
+ if (el.matches("a[href]") || media !== "video" && el.matches("img")) return [el];
14752
16181
  return [...el.querySelectorAll(media === "video" ? ":scope a[href]" : ":scope a[href], :scope img")];
14753
16182
  }
16183
+ function effectName(media) {
16184
+ return media === "video" ? "video-lightbox" : "lightbox";
16185
+ }
14754
16186
  function targets(el, params, ctx, media) {
14755
16187
  const authored = params.text("target");
14756
16188
  if (!authored) return defaultTargets(el, media);
14757
- const selector = resolveTarget(authored, ctx, media === "video" ? "video-lightbox" : "lightbox");
16189
+ const selector = resolveTarget(authored, ctx, effectName(media));
14758
16190
  return selector ? queryScoped(el, ctx, selector, scopeParam(params, "self")) : [];
14759
16191
  }
14760
16192
  function imageTrigger(target) {
@@ -14765,52 +16197,137 @@ var kuinetic = (() => {
14765
16197
  }
14766
16198
  function captionFor(trigger, image, source) {
14767
16199
  if (source === "none") return "";
14768
- if (source === "alt") return image.alt;
14769
- if (source === "title") return (trigger.getAttribute("title") || image.title).trim();
14770
- return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || image.alt;
16200
+ const alt = image?.alt ?? "";
16201
+ if (source === "alt") return alt;
16202
+ if (source === "title") return titleOf(trigger, image);
16203
+ return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || alt;
16204
+ }
16205
+ function titleOf(trigger, image) {
16206
+ return (trigger.getAttribute("title") || image?.title || "").trim();
14771
16207
  }
14772
16208
  function imageItem(trigger, captionSource) {
14773
16209
  const image = imageIn(trigger);
14774
16210
  if (!image) return null;
14775
16211
  const link = trigger instanceof HTMLAnchorElement ? trigger : null;
14776
16212
  return {
16213
+ kind: "image",
14777
16214
  trigger,
14778
16215
  image,
14779
16216
  src: link?.href ?? (image.currentSrc || image.src),
14780
16217
  caption: captionFor(trigger, image, captionSource)
14781
16218
  };
14782
16219
  }
14783
- function imageItems(targets2, captionSource) {
16220
+ function videoTitle(link, poster) {
16221
+ return link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
16222
+ }
16223
+ function videoItem(link, captionSource) {
16224
+ const source = resolveMediaSource(link.getAttribute("href") ?? "");
16225
+ if (!source) return null;
16226
+ const poster = link.querySelector("img");
16227
+ return { kind: "video", trigger: link, source, title: videoTitle(link, poster), caption: captionFor(link, poster, captionSource) };
16228
+ }
16229
+ function itemFor(trigger, media, captionSource) {
16230
+ if (media !== "image" && trigger instanceof HTMLAnchorElement) {
16231
+ const video = videoItem(trigger, captionSource);
16232
+ if (video || media === "video") return video;
16233
+ }
16234
+ return imageItem(trigger, captionSource);
16235
+ }
16236
+ function triggersFor(found, media) {
14784
16237
  const seen = /* @__PURE__ */ new Set();
14785
- const items = [];
14786
- for (const target of targets2) {
14787
- const trigger = imageTrigger(target);
16238
+ const triggers = [];
16239
+ for (const target of found) {
16240
+ const trigger = media === "video" ? target : imageTrigger(target);
14788
16241
  if (seen.has(trigger)) continue;
14789
- const item = imageItem(trigger, captionSource);
14790
- if (!item) continue;
16242
+ if (!usable(trigger, media)) continue;
14791
16243
  seen.add(trigger);
14792
- items.push(item);
16244
+ triggers.push(trigger);
14793
16245
  }
14794
- return items;
16246
+ return triggers;
16247
+ }
16248
+ function usable(trigger, media) {
16249
+ const link = trigger instanceof HTMLAnchorElement;
16250
+ if (media === "video") return link;
16251
+ const image = imageIn(trigger) !== null;
16252
+ return media === "image" ? image : link || image;
14795
16253
  }
14796
16254
  function modalOptions(params, ctx) {
14797
16255
  return {
14798
- duration: params.timing.durationMs ?? params.ms("duration", 280),
14799
- scale: params.text("scale", "0.965"),
14800
- ease: params.timing.easing ?? params.text("ease", "cubic-bezier(0.22, 1, 0.36, 1)"),
16256
+ duration: params.timing.durationMs ?? params.ms("duration", DEFAULT_DURATION_MS),
16257
+ scale: params.text("scale", DEFAULT_SCALE),
16258
+ ease: params.timing.easing ?? params.text("ease", DEFAULT_EASE),
14801
16259
  reducedMotion: ctx.reducedMotion
14802
16260
  };
14803
16261
  }
14804
- function showImage(item, view, caption) {
16262
+ function showImage(item, view) {
14805
16263
  view.src = item.src;
14806
16264
  view.alt = item.image.alt;
14807
- caption.textContent = item.caption;
14808
- caption.hidden = item.caption === "";
14809
16265
  const ratio = item.image.naturalWidth ? item.image.naturalHeight / item.image.naturalWidth : 1;
14810
16266
  view.className = ratio > 1.5 ? "is-tall" : "is-fit";
14811
16267
  }
16268
+ function videoFrame(item, aspect, doc) {
16269
+ const frame = doc.createElement("div");
16270
+ frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect || item.source.aspect}`;
16271
+ if (item.source.kind === "file") {
16272
+ const video = doc.createElement("video");
16273
+ video.src = item.source.embedUrl;
16274
+ video.controls = true;
16275
+ video.autoplay = true;
16276
+ video.playsInline = true;
16277
+ video.setAttribute("aria-label", item.title);
16278
+ frame.append(video);
16279
+ } else {
16280
+ const iframe = doc.createElement("iframe");
16281
+ iframe.src = item.source.embedUrl;
16282
+ iframe.title = item.title;
16283
+ iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
16284
+ iframe.allowFullscreen = true;
16285
+ frame.append(iframe);
16286
+ }
16287
+ return frame;
16288
+ }
16289
+ function pauseVideo(video) {
16290
+ if (video.readyState > 0) video.pause();
16291
+ }
16292
+ function stopMedia(frame) {
16293
+ const video = frame.querySelector("video");
16294
+ if (video) {
16295
+ pauseVideo(video);
16296
+ video.removeAttribute("src");
16297
+ }
16298
+ frame.replaceChildren();
16299
+ }
16300
+ function leave(state, node) {
16301
+ if (node === state.view) return;
16302
+ const video = node.querySelector("video");
16303
+ if (video) pauseVideo(video);
16304
+ else node.replaceChildren();
16305
+ }
16306
+ function land(state, item) {
16307
+ if (item.kind === "image") {
16308
+ showImage(item, state.view);
16309
+ return state.view;
16310
+ }
16311
+ const cached2 = state.players.get(item);
16312
+ if (cached2) {
16313
+ void cached2.querySelector("video").play().catch(() => {
16314
+ });
16315
+ return cached2;
16316
+ }
16317
+ const frame = videoFrame(item, state.aspect, state.figure.ownerDocument);
16318
+ if (item.source.kind === "file") state.players.set(item, frame);
16319
+ return frame;
16320
+ }
14812
16321
  function renderGallery(state, focus) {
14813
- showImage(state.items[state.index], state.view, state.caption);
16322
+ const item = state.items[state.index];
16323
+ if (state.shown?.item !== item) {
16324
+ if (state.shown) leave(state, state.shown.node);
16325
+ const node = land(state, item);
16326
+ state.shown = { item, node };
16327
+ state.caption.textContent = item.caption;
16328
+ state.caption.hidden = item.caption === "";
16329
+ state.figure.replaceChildren(node, state.caption);
16330
+ }
14814
16331
  state.counter.textContent = `${state.index + 1} of ${state.items.length}`;
14815
16332
  state.previous.disabled = !state.loop && state.index === 0;
14816
16333
  state.next.disabled = !state.loop && state.index === state.items.length - 1;
@@ -14828,6 +16345,7 @@ var kuinetic = (() => {
14828
16345
  }
14829
16346
  function galleryKey(state, event) {
14830
16347
  if (state.items.length < 2) return;
16348
+ if (event.target.closest("video")) return;
14831
16349
  if (event.key === "ArrowLeft") moveGallery(state, -1, true);
14832
16350
  else if (event.key === "ArrowRight") moveGallery(state, 1, true);
14833
16351
  else if (event.key === "Home") {
@@ -14839,8 +16357,40 @@ var kuinetic = (() => {
14839
16357
  } else return;
14840
16358
  event.preventDefault();
14841
16359
  }
14842
- function galleryContent(items, initial, loop2, options) {
14843
- const doc = items[initial].image.ownerDocument;
16360
+ function swipeGallery(state, box) {
16361
+ const viewport = box.ownerDocument.defaultView?.visualViewport ?? null;
16362
+ const zoomed = () => (viewport?.scale ?? 1) > 1.01;
16363
+ const sync = () => {
16364
+ box.classList.toggle("is-swipeable", !zoomed());
16365
+ };
16366
+ sync();
16367
+ viewport?.addEventListener("resize", sync);
16368
+ const stop = recognise(box, {
16369
+ onSwipe(direction) {
16370
+ if (direction === "left" || direction === "right") moveGallery(state, swipeStep(direction), false);
16371
+ }
16372
+ }, {
16373
+ // A press on a button still clicks it: capture is taken only once the finger has moved.
16374
+ capturePointer: "drag",
16375
+ accept: (event) => event.pointerType !== "mouse" && !zoomed() && event.target.closest("video") === null
16376
+ });
16377
+ return () => {
16378
+ stop();
16379
+ viewport?.removeEventListener("resize", sync);
16380
+ };
16381
+ }
16382
+ function closeViewer(state) {
16383
+ state.view.removeAttribute("src");
16384
+ for (const frame of state.players.values()) stopMedia(frame);
16385
+ if (state.shown && state.shown.node !== state.view) stopMedia(state.shown.node);
16386
+ }
16387
+ function vocabulary(items) {
16388
+ const kinds = new Set(items.map((item) => item.kind));
16389
+ if (kinds.size > 1) return { noun: "item", label: "Media viewer" };
16390
+ return kinds.has("video") ? { noun: "video", label: "Video viewer" } : { noun: "image", label: "Image viewer" };
16391
+ }
16392
+ function galleryContent(items, initial, options) {
16393
+ const doc = items[initial].trigger.ownerDocument;
14844
16394
  const box = doc.createElement("div");
14845
16395
  box.className = "kui-lightbox-gallery";
14846
16396
  const figure = doc.createElement("figure");
@@ -14848,7 +16398,6 @@ var kuinetic = (() => {
14848
16398
  const view = doc.createElement("img");
14849
16399
  view.decoding = "async";
14850
16400
  const caption = doc.createElement("figcaption");
14851
- figure.append(view, caption);
14852
16401
  box.append(figure);
14853
16402
  const counter = doc.createElement("span");
14854
16403
  counter.className = "kui-lightbox-counter";
@@ -14858,149 +16407,244 @@ var kuinetic = (() => {
14858
16407
  previous.type = next.type = "button";
14859
16408
  previous.textContent = "\u2039";
14860
16409
  next.textContent = "\u203A";
14861
- previous.setAttribute("aria-label", "Previous image");
14862
- next.setAttribute("aria-label", "Next image");
16410
+ const { noun, label } = vocabulary(items);
16411
+ previous.setAttribute("aria-label", `Previous ${noun}`);
16412
+ next.setAttribute("aria-label", `Next ${noun}`);
14863
16413
  previous.className = "kui-lightbox-prev";
14864
16414
  next.className = "kui-lightbox-next";
14865
- const state = { items, index: initial, loop: loop2, figure, view, caption, counter, previous, next };
16415
+ const state = {
16416
+ items,
16417
+ index: initial,
16418
+ loop: options.loop,
16419
+ aspect: options.aspect,
16420
+ figure,
16421
+ view,
16422
+ caption,
16423
+ counter,
16424
+ previous,
16425
+ next,
16426
+ players: /* @__PURE__ */ new Map()
16427
+ };
14866
16428
  if (items.length > 1) {
14867
16429
  box.append(previous, next, counter);
14868
16430
  previous.addEventListener("click", () => clickGallery(state, -1, previous));
14869
16431
  next.addEventListener("click", () => clickGallery(state, 1, next));
14870
16432
  }
14871
16433
  renderGallery(state, false);
16434
+ const unswipe = items.length > 1 ? swipeGallery(state, box) : void 0;
16435
+ const { duration, scale, ease, reducedMotion } = options;
14872
16436
  return {
14873
- ...options,
16437
+ duration,
16438
+ scale,
16439
+ ease,
16440
+ reducedMotion,
14874
16441
  node: box,
14875
- label: "Image viewer",
14876
- onClose: () => view.removeAttribute("src"),
16442
+ label,
16443
+ // The media and the controls keep the viewer open; the empty figure around them closes it.
16444
+ inside: (target) => box.contains(target) && target.closest("img, video, iframe, button") !== null,
16445
+ onClose: () => {
16446
+ unswipe?.();
16447
+ closeViewer(state);
16448
+ },
14877
16449
  onKey: (event) => galleryKey(state, event)
14878
16450
  };
14879
16451
  }
14880
- function stopMedia(frame) {
14881
- const video = frame.querySelector("video");
14882
- if (video) {
14883
- if (video.readyState > 0) video.pause();
14884
- video.removeAttribute("src");
14885
- }
14886
- frame.replaceChildren();
14887
- }
14888
- function videoContent(link, source, options, aspect) {
14889
- const doc = link.ownerDocument;
14890
- const frame = doc.createElement("div");
14891
- frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect}`;
14892
- const poster = link.querySelector("img");
14893
- const title = link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
14894
- if (source.kind === "file") {
14895
- const video = doc.createElement("video");
14896
- video.src = source.embedUrl;
14897
- video.controls = true;
14898
- video.autoplay = true;
14899
- video.playsInline = true;
14900
- video.setAttribute("aria-label", title);
14901
- frame.append(video);
14902
- } else {
14903
- const iframe = doc.createElement("iframe");
14904
- iframe.src = source.embedUrl;
14905
- iframe.title = title;
14906
- iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
14907
- iframe.allowFullscreen = true;
14908
- frame.append(iframe);
14909
- }
14910
- return { ...options, node: frame, label: "Video viewer", onClose: () => stopMedia(frame) };
14911
- }
14912
16452
  function primaryClick(event) {
14913
16453
  return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
14914
16454
  }
14915
- function wireImages(items, wiring, loop2) {
16455
+ function openAt(triggers, trigger, wiring) {
16456
+ const items = [];
16457
+ let initial = -1;
16458
+ for (const candidate of triggers) {
16459
+ const item = itemFor(candidate, wiring.media, wiring.captionSource);
16460
+ if (!item) continue;
16461
+ if (candidate === trigger) initial = items.length;
16462
+ items.push(item);
16463
+ }
16464
+ if (initial < 0) {
16465
+ const href = trigger.getAttribute("href") ?? "";
16466
+ if (!wiring.warned.has(href)) wiring.ctx.warn(`${effectName(wiring.media)} cannot embed "${href}"; the link will navigate normally`);
16467
+ wiring.warned.add(href);
16468
+ return false;
16469
+ }
16470
+ wiring.shell.open({ ...galleryContent(items, initial, wiring.options), opener: trigger });
16471
+ return true;
16472
+ }
16473
+ function wireTriggers(triggers, wiring) {
14916
16474
  const restore = [];
14917
- items.forEach((item, index) => {
14918
- const bare = item.trigger === item.image;
16475
+ for (const trigger of triggers) {
16476
+ const bare = trigger instanceof HTMLImageElement;
14919
16477
  if (bare) {
14920
- const attrs = createAttributeLedger(item.image);
16478
+ const attrs = createAttributeLedger(trigger);
14921
16479
  attrs.set("role", "button");
14922
16480
  attrs.set("tabindex", "0");
14923
- attrs.set("aria-label", item.image.alt ? `Open larger image: ${item.image.alt}` : "Open larger image");
16481
+ attrs.set("aria-label", trigger.alt ? `Open larger image: ${trigger.alt}` : "Open larger image");
14924
16482
  restore.push(() => attrs.restore());
14925
- item.image.addEventListener("keydown", (event) => {
16483
+ trigger.addEventListener("keydown", (event) => {
14926
16484
  if (event.key !== "Enter" && event.key !== " ") return;
14927
16485
  event.preventDefault();
14928
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
16486
+ openAt(triggers, trigger, wiring);
14929
16487
  }, { signal: wiring.signal });
14930
16488
  }
14931
- item.trigger.addEventListener("click", (event) => {
16489
+ trigger.addEventListener("click", (event) => {
14932
16490
  if (!primaryClick(event)) return;
14933
- if (!bare) event.preventDefault();
14934
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
16491
+ if (openAt(triggers, trigger, wiring)) event.preventDefault();
14935
16492
  }, { signal: wiring.signal });
14936
- });
16493
+ }
14937
16494
  return restore;
14938
16495
  }
14939
- function wireVideos(targets2, wiring, aspect, ctx) {
14940
- const warned = /* @__PURE__ */ new Set();
14941
- for (const target of targets2) {
14942
- if (!(target instanceof HTMLAnchorElement)) continue;
14943
- target.addEventListener("click", (event) => {
14944
- if (!primaryClick(event)) return;
14945
- const href = target.getAttribute("href") ?? "";
14946
- const source = resolveMediaSource(href);
14947
- if (!source) {
14948
- if (!warned.has(href)) ctx.warn(`video-lightbox cannot embed "${href}"; the link will navigate normally`);
14949
- warned.add(href);
14950
- return;
14951
- }
14952
- event.preventDefault();
14953
- wiring.shell.open(videoContent(target, source, wiring.options, aspect || source.aspect));
14954
- }, { signal: wiring.signal });
14955
- }
16496
+ function cardItem(card) {
16497
+ const link = card.matches("a[href]") ? card : card.querySelector("a[href]");
16498
+ return link && itemFor(link, "mixed", "figcaption") || itemFor(card, "mixed", "figcaption");
16499
+ }
16500
+ var OWN_CLICK = "button, input, select, textarea, label, summary, [contenteditable]";
16501
+ function attachDeckLightbox(request) {
16502
+ const { host, cards, doc, reducedMotion } = request;
16503
+ const shell = acquireModalShell(doc);
16504
+ const options = {
16505
+ duration: DEFAULT_DURATION_MS,
16506
+ scale: DEFAULT_SCALE,
16507
+ ease: DEFAULT_EASE,
16508
+ reducedMotion,
16509
+ loop: true,
16510
+ aspect: ""
16511
+ };
16512
+ const turnedAway = (card) => card.getAttribute("data-kui-ring-face") === "back";
16513
+ const openAt2 = (pick, faceChecked = false) => {
16514
+ const all = [...cards()];
16515
+ const card = all.find(pick);
16516
+ if (!card || !faceChecked && turnedAway(card)) return false;
16517
+ const items = [];
16518
+ let initial = -1;
16519
+ for (const candidate of all) {
16520
+ const item = cardItem(candidate);
16521
+ if (!item) continue;
16522
+ if (candidate === card) initial = items.length;
16523
+ items.push(item);
16524
+ }
16525
+ if (initial < 0) return false;
16526
+ shell.open({ ...galleryContent(items, initial, options), opener: card });
16527
+ return true;
16528
+ };
16529
+ let pressed = null;
16530
+ const cardUnder = (event) => {
16531
+ const all = [...cards()];
16532
+ const hits = typeof doc.elementsFromPoint === "function" ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
16533
+ for (const node of [...hits, event.target]) {
16534
+ const card = all.find((candidate) => candidate.contains(node));
16535
+ if (card) return turnedAway(card) ? null : { card, node };
16536
+ }
16537
+ return null;
16538
+ };
16539
+ const onPress = (event) => {
16540
+ pressed = event.button === 0 ? cardUnder(event) : null;
16541
+ };
16542
+ const onClick = (event) => {
16543
+ const press = event.detail > 0 ? pressed : null;
16544
+ pressed = null;
16545
+ const target = event.target;
16546
+ if (event.defaultPrevented || !primaryClick(event) || target.closest(OWN_CLICK)) return;
16547
+ if (press?.node.closest(OWN_CLICK)) return;
16548
+ const opened = press ? openAt2((card) => card === press.card, true) : openAt2((card) => card.contains(target));
16549
+ if (opened) event.preventDefault();
16550
+ };
16551
+ const onKeyDown = (event) => {
16552
+ const key = event.key;
16553
+ if (event.target !== host || key !== "Enter" && key !== " ") return;
16554
+ if (openAt2((card) => card.getAttribute(STEP_STATE_ATTR) === "active")) event.preventDefault();
16555
+ };
16556
+ host.addEventListener("pointerdown", onPress);
16557
+ host.addEventListener("click", onClick);
16558
+ host.addEventListener("keydown", onKeyDown);
16559
+ return () => {
16560
+ host.removeEventListener("pointerdown", onPress);
16561
+ host.removeEventListener("click", onClick);
16562
+ host.removeEventListener("keydown", onKeyDown);
16563
+ shell.release();
16564
+ };
14956
16565
  }
14957
16566
  function prepareLightbox(el, params, ctx) {
14958
16567
  const media = params.text("media", "image");
14959
- const found = targets(el, params, ctx, media);
14960
- if (found.length === 0) return () => {
16568
+ const triggers = triggersFor(targets(el, params, ctx, media), media);
16569
+ if (triggers.length === 0) return () => {
14961
16570
  };
14962
16571
  const controller = new AbortController();
14963
16572
  const shell = acquireModalShell(ctx.doc);
14964
- const wiring = { shell, options: modalOptions(params, ctx), signal: controller.signal };
14965
- let restore = [];
14966
- if (media === "video") {
14967
- wireVideos(found, wiring, params.text("aspect"), ctx);
14968
- } else {
14969
- const items = imageItems(found, params.text("caption", "figcaption"));
14970
- restore = wireImages(items, wiring, params.is("loop"));
14971
- }
16573
+ const options = { ...modalOptions(params, ctx), loop: params.is("loop"), aspect: params.text("aspect") };
16574
+ const restore = wireTriggers(triggers, {
16575
+ shell,
16576
+ options,
16577
+ signal: controller.signal,
16578
+ media,
16579
+ captionSource: params.text("caption", "figcaption"),
16580
+ ctx,
16581
+ warned: /* @__PURE__ */ new Set()
16582
+ });
14972
16583
  return continuousSetup(() => {
14973
16584
  controller.abort();
14974
16585
  for (const undo of restore) undo();
14975
16586
  shell.release();
14976
16587
  });
14977
16588
  }
16589
+ var TRIGGERS_CHANNEL = "lightbox:triggers";
14978
16590
  var LIGHTBOX_PRIMITIVE = widgetPrimitive(
14979
16591
  "lightbox",
14980
- { channels: ["widget"], parameters: paramsSchema, perfClass: "paint" },
16592
+ { channels: [TRIGGERS_CHANNEL], parameters: paramsSchema, perfClass: "paint" },
14981
16593
  withTimingContract("lightbox", { honours: ["duration", "ease"], because: "the dialog opens immediately, so it has no delay phase" }, deferPrepare(prepareLightbox))
14982
16594
  );
14983
16595
  var LIGHTBOX_PRESETS = [
14984
16596
  { name: "lightbox", primitive: "lightbox", params: { media: "image" } },
14985
- { name: "video-lightbox", primitive: "lightbox", params: { media: "video" } }
16597
+ // A video row has never shown captions; `caption:figcaption` opts one in.
16598
+ { name: "video-lightbox", primitive: "lightbox", params: { media: "video", caption: "none" } }
14986
16599
  ];
14987
16600
 
14988
16601
  // src/showcase/compare.ts
14989
16602
  var COMPARE_AXES = ["x", "y"];
14990
16603
  var COMPARE_AXIS_ATTR = "data-kui-compare-axis";
16604
+ var COMPARE_DIVIDERS_ATTR = "data-kui-compare-dividers";
14991
16605
  var compareParams = {
14992
16606
  position: {
14993
16607
  type: "percentage",
14994
16608
  default: "50%",
14995
16609
  cssProperty: "--kui-compare"
14996
16610
  },
16611
+ /*
16612
+ * Where each divider of a three-or-more frame starts, as one quoted, space-separated list —
16613
+ * `positions:'20% 45% 80%'` — because a comma would start a new step, not a new value. Default
16614
+ * (empty) is an even split. `position:` stays the two-media spelling: it is a single percentage
16615
+ * and every existing page relies on it validating as one.
16616
+ */
16617
+ positions: { type: "text", default: "", cssProperty: "--kui-compare-positions" },
14997
16618
  axis: {
14998
16619
  type: "keyword",
14999
16620
  default: "x",
15000
16621
  cssProperty: "--kui-compare-axis",
15001
16622
  keywords: [...COMPARE_AXES]
15002
- }
16623
+ },
16624
+ /*
16625
+ * The auto-drag, in `background-media`'s words (`src/effects/catalog/media.ts`): `always` starts
16626
+ * at load, `in-view` the first time the slider is on screen, `never` (default) not at all.
16627
+ *
16628
+ * Not `on:load` / `on:enter`, because the activation grammar already has a job here: it is what
16629
+ * *builds* the slider, and a widget accepts only `on:load` (`src/showcase/shared.ts`) so the
16630
+ * control exists before a keyboard user tabs to it. `on:enter` would defer the widget, not the
16631
+ * motion — so the motion's start is its own parameter.
16632
+ */
16633
+ autoplay: {
16634
+ type: "keyword",
16635
+ default: "never",
16636
+ cssProperty: "--kui-compare-autoplay",
16637
+ keywords: ["never", "in-view", "always"]
16638
+ },
16639
+ /** One edge-to-edge sweep. The positional spelling (`compare 4s`) means the same thing. */
16640
+ duration: { type: "time", default: "3s", cssProperty: "--kui-compare-duration" },
16641
+ /** Head toward 0% first instead of 100%. */
16642
+ reverse: { type: "keyword", default: "false", cssProperty: "--kui-compare-reverse", keywords: ["true", "false"] },
16643
+ /** Sweep back and forth until someone takes the handle, instead of one pass. */
16644
+ loop: { type: "keyword", default: "false", cssProperty: "--kui-compare-loop", keywords: ["true", "false"] }
15003
16645
  };
16646
+ var MIN_SWEEP_MS = 250;
16647
+ var MAX_FRAME_MS2 = 100;
15004
16648
  var VERTICAL_KEY_DELTA = { ArrowUp: -1, ArrowDown: 1 };
15005
16649
  function getMediaName(item, fallback) {
15006
16650
  const alt = item.localName === "picture" ? item.querySelector("img")?.getAttribute("alt") : item.getAttribute("alt");
@@ -15016,76 +16660,402 @@ var kuinetic = (() => {
15016
16660
  }
15017
16661
  return "Compare";
15018
16662
  }
15019
- function createCompareElements(doc, label, initialPercent, axis) {
16663
+ function createRange(doc, label, value, axis) {
15020
16664
  const range = doc.createElement("input");
15021
16665
  range.type = "range";
15022
16666
  range.className = "kui-compare-range";
15023
16667
  range.min = "0";
15024
16668
  range.max = "100";
15025
16669
  range.step = "1";
15026
- range.value = String(initialPercent);
15027
- range.setAttribute("aria-label", label);
15028
- range.setAttribute("aria-valuetext", `${initialPercent}% after`);
16670
+ range.value = String(value);
16671
+ range.setAttribute("aria-label", label.name);
16672
+ range.setAttribute("aria-valuetext", label.valueText);
15029
16673
  if (axis === "y") range.setAttribute("aria-orientation", "vertical");
16674
+ return range;
16675
+ }
16676
+ function createHandle(doc) {
15030
16677
  const handle = doc.createElement("span");
15031
16678
  handle.className = "kui-compare-handle";
15032
16679
  handle.setAttribute("aria-hidden", "true");
15033
- return { range, handle };
16680
+ return handle;
15034
16681
  }
15035
- function prepareCompare(el, params, ctx) {
15036
- const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
15037
- if (media.length < 2) {
15038
- ctx.warn("compare requires at least two media children (img, picture, or video)");
16682
+ function verticalKeys(range, axis, onInput) {
16683
+ return (event) => {
16684
+ const delta = VERTICAL_KEY_DELTA[event.key];
16685
+ if (axis !== "y" || delta === void 0) return;
16686
+ event.preventDefault();
16687
+ if (delta < 0) range.stepDown();
16688
+ else range.stepUp();
16689
+ onInput();
16690
+ };
16691
+ }
16692
+ function sweepPercent(phase) {
16693
+ return 50 - 50 * Math.cos(Math.PI * phase / 100);
16694
+ }
16695
+ function startPhase(percent, reverse) {
16696
+ const outbound = 100 / Math.PI * Math.acos(1 - percent / 50);
16697
+ return reverse ? 200 - outbound : outbound;
16698
+ }
16699
+ function sweepDuration(params, ctx) {
16700
+ const authored = params.timing.durationMs ?? params.ms("duration", 3e3);
16701
+ if (authored >= MIN_SWEEP_MS) return authored;
16702
+ ctx.warn(`compare duration: minimum is ${MIN_SWEEP_MS}ms; clamped to ${MIN_SWEEP_MS}ms`);
16703
+ return MIN_SWEEP_MS;
16704
+ }
16705
+ function bindAutoDrag(options) {
16706
+ const { host, grab, ctx, mode, loop: loop2 } = options;
16707
+ if (mode === "never" || ctx.reducedMotion) return () => {
16708
+ };
16709
+ const run = runAutoDrag(options, () => stop());
16710
+ let observer = null;
16711
+ if (mode === "in-view" || loop2) {
16712
+ observer = observeOnscreen(ctx.win, host, run.setOnscreen);
15039
16713
  }
15040
- const axis = params.text("axis", "x");
15041
- const attrs = createAttributeLedger(el);
15042
- attrs.set(COMPARE_AXIS_ATTR, axis);
16714
+ const stop = () => {
16715
+ run.stop();
16716
+ observer?.disconnect();
16717
+ observer = null;
16718
+ for (const target of grab) {
16719
+ target.removeEventListener("pointerdown", stop);
16720
+ target.removeEventListener("focus", stop);
16721
+ }
16722
+ };
16723
+ for (const target of grab) {
16724
+ target.addEventListener("pointerdown", stop);
16725
+ target.addEventListener("focus", stop);
16726
+ }
16727
+ run.setOnscreen(mode === "always" || observer === null);
16728
+ return stop;
16729
+ }
16730
+ function sweepPlan(options) {
16731
+ const { write, homes, bounds, durationMs, reverse, loop: loop2 } = options;
16732
+ const order = homes.map((_, index) => index);
16733
+ if (reverse) order.reverse();
16734
+ const cycleMs = 2 * durationMs;
16735
+ const totalMs = cycleMs * order.length;
16736
+ const phases = homes.map((home, index) => {
16737
+ const [lo, hi] = bounds[index];
16738
+ return hi > lo ? startPhase(100 * (home - lo) / (hi - lo), reverse) : 0;
16739
+ });
16740
+ const park = (slot2) => write(order[slot2], homes[order[slot2]]);
16741
+ let slot = 0;
16742
+ return {
16743
+ show(elapsed) {
16744
+ if (!loop2 && elapsed >= totalMs) {
16745
+ park(slot);
16746
+ return false;
16747
+ }
16748
+ const into = elapsed % totalMs;
16749
+ const next = Math.floor(into / cycleMs);
16750
+ if (next !== slot) park(slot);
16751
+ slot = next;
16752
+ const index = order[slot];
16753
+ const [lo, hi] = bounds[index];
16754
+ const phase = phases[index] + 100 * (into - slot * cycleMs) / durationMs;
16755
+ write(index, lo + (hi - lo) * sweepPercent(phase) / 100);
16756
+ return true;
16757
+ }
16758
+ };
16759
+ }
16760
+ function observeOnscreen(win, host, onChange) {
16761
+ const Observer = win.IntersectionObserver;
16762
+ if (!Observer) return null;
16763
+ const observer = new Observer((entries) => {
16764
+ for (const entry of entries) onChange(entry.isIntersecting);
16765
+ });
16766
+ observer.observe(host);
16767
+ return observer;
16768
+ }
16769
+ function runAutoDrag(options, finish) {
16770
+ const win = options.ctx.win;
16771
+ const plan = sweepPlan(options);
16772
+ let elapsed = 0;
16773
+ let lastFrameAt = null;
16774
+ let frame = 0;
16775
+ let done = false;
16776
+ let onscreen = false;
16777
+ const update = () => {
16778
+ const active = !done && onscreen;
16779
+ if (active && frame === 0) {
16780
+ lastFrameAt = null;
16781
+ frame = win.requestAnimationFrame(tick);
16782
+ } else if (!active && frame !== 0) {
16783
+ win.cancelAnimationFrame(frame);
16784
+ frame = 0;
16785
+ }
16786
+ };
16787
+ function tick(now) {
16788
+ frame = 0;
16789
+ if (lastFrameAt !== null) elapsed += Math.min(now - lastFrameAt, MAX_FRAME_MS2);
16790
+ lastFrameAt = now;
16791
+ if (plan.show(elapsed)) frame = win.requestAnimationFrame(tick);
16792
+ else finish();
16793
+ }
16794
+ return {
16795
+ setOnscreen(next) {
16796
+ onscreen = next;
16797
+ update();
16798
+ },
16799
+ stop() {
16800
+ done = true;
16801
+ update();
16802
+ }
16803
+ };
16804
+ }
16805
+ function buildPair({ el, media, params, ctx, axis, timing: timing3 }) {
15043
16806
  const initialPercent = Math.round(params.num("position", 0.5) * 100);
15044
16807
  const style = createStyleLedger(el);
15045
16808
  style.set("--kui-compare", params.text("position", "50%"));
15046
- const { range, handle } = createCompareElements(
15047
- el.ownerDocument,
15048
- resolveCompareLabel(el, media),
16809
+ const doc = el.ownerDocument;
16810
+ const range = createRange(
16811
+ doc,
16812
+ { name: resolveCompareLabel(el, media), valueText: `${initialPercent}% after` },
15049
16813
  initialPercent,
15050
16814
  axis
15051
16815
  );
16816
+ const handle = createHandle(doc);
15052
16817
  const onInput = () => {
15053
16818
  const val = range.value;
15054
16819
  style.set("--kui-compare", `${val}%`);
15055
16820
  range.setAttribute("aria-valuetext", `${val}% after`);
15056
16821
  };
15057
- const onKeyDown = (event) => {
15058
- const delta = VERTICAL_KEY_DELTA[event.key];
15059
- if (axis !== "y" || delta === void 0) return;
15060
- event.preventDefault();
15061
- if (delta < 0) range.stepDown();
15062
- else range.stepUp();
15063
- onInput();
16822
+ const writeAuto = (_index, percent) => {
16823
+ range.value = String(Math.round(percent));
16824
+ style.set("--kui-compare", `${Number(percent.toFixed(3))}%`);
16825
+ range.setAttribute("aria-valuetext", `${range.value}% after`);
15064
16826
  };
16827
+ const onKeyDown = verticalKeys(range, axis, onInput);
15065
16828
  range.addEventListener("input", onInput);
15066
16829
  range.addEventListener("keydown", onKeyDown);
15067
16830
  el.appendChild(range);
15068
16831
  el.appendChild(handle);
15069
- return continuousSetup(() => {
16832
+ const stopAutoDrag = bindAutoDrag({
16833
+ ...timing3,
16834
+ host: el,
16835
+ grab: [range],
16836
+ ctx,
16837
+ write: writeAuto,
16838
+ homes: [params.num("position", 0.5) * 100],
16839
+ bounds: [[0, 100]]
16840
+ });
16841
+ return () => {
16842
+ stopAutoDrag();
15070
16843
  range.removeEventListener("input", onInput);
15071
16844
  range.removeEventListener("keydown", onKeyDown);
15072
16845
  range.remove();
15073
16846
  handle.remove();
15074
- attrs.restore();
15075
16847
  style.restore();
16848
+ };
16849
+ }
16850
+ var PERCENT_TOKEN = /^(\d+(?:\.\d+)?)%$/;
16851
+ function resolvePositions(authored, count, warn) {
16852
+ const even = Array.from({ length: count }, (_, k) => Math.round(100 * (k + 1) / (count + 1)));
16853
+ if (authored.trim() === "") return even;
16854
+ const tokens = authored.trim().split(/\s+/);
16855
+ const values = tokens.map((token) => PERCENT_TOKEN.exec(token)?.[1]);
16856
+ const numbers = values.map(Number);
16857
+ const usable2 = tokens.length === count && values.every((value) => value !== void 0) && numbers.every((value, k) => value <= 100 && (k === 0 || value >= numbers[k - 1]));
16858
+ if (usable2) return numbers.map(Math.round);
16859
+ const example = even.map((value) => String(value) + "%").join(" ");
16860
+ warn(
16861
+ `compare positions: expected ${count} ascending percentages between 0% and 100% for ${count + 1} media (e.g. positions:'${example}'); using an even split`
16862
+ );
16863
+ return even;
16864
+ }
16865
+ function buildMulti({ el, media, params, ctx, axis, timing: timing3 }) {
16866
+ if (params.text("position", "50%") !== "50%") {
16867
+ ctx.warn(`compare position: sets the one divider of two media; with ${media.length}, use positions:`);
16868
+ }
16869
+ const positions = resolvePositions(params.text("positions", ""), media.length - 1, ctx.warn);
16870
+ const homes = [...positions];
16871
+ const attrs = createAttributeLedger(el);
16872
+ attrs.set(COMPARE_DIVIDERS_ATTR, String(homes.length));
16873
+ const dividers = createDividers(el, media, axis, positions);
16874
+ const surface = bindSurface(el, axis, dividers);
16875
+ const stopAutoDrag = bindAutoDrag({
16876
+ ...timing3,
16877
+ host: el,
16878
+ grab: [surface.node, ...dividers.ranges],
16879
+ ctx,
16880
+ write: dividers.render,
16881
+ homes,
16882
+ bounds: homes.map((_, k) => boundsOf(homes, k))
16883
+ });
16884
+ return () => {
16885
+ stopAutoDrag();
16886
+ surface.destroy();
16887
+ dividers.destroy();
16888
+ attrs.restore();
16889
+ };
16890
+ }
16891
+ function boundsOf(positions, k) {
16892
+ return [k > 0 ? positions[k - 1] : 0, k < positions.length - 1 ? positions[k + 1] : 100];
16893
+ }
16894
+ function dividerLabel(el, media, k) {
16895
+ const count = media.length - 1;
16896
+ const authored = el.getAttribute("aria-label")?.trim();
16897
+ if (authored) return `${authored}: divider ${k + 1} of ${count}`;
16898
+ const before = getMediaName(media[k], `Image ${k + 1}`);
16899
+ const after = getMediaName(media[k + 1], `Image ${k + 2}`);
16900
+ return `Divider ${k + 1} of ${count}: ${before} / ${after}`;
16901
+ }
16902
+ function createDividers(el, media, axis, positions) {
16903
+ const doc = el.ownerDocument;
16904
+ const count = positions.length;
16905
+ const clip = axis === "y" ? "inset(var(--kui-compare) 0 0 0)" : "inset(0 0 0 var(--kui-compare))";
16906
+ const mediaStyles = media.slice(1).map((item) => createStyleLedger(item));
16907
+ for (const ledger of mediaStyles) ledger.set("clip-path", clip);
16908
+ const ranges = [];
16909
+ const handles = [];
16910
+ const cleanups = [];
16911
+ const render = (k, shown) => {
16912
+ positions[k] = Math.round(shown);
16913
+ const css = `${Number(shown.toFixed(3))}%`;
16914
+ handles[k].style.setProperty("--kui-compare", css);
16915
+ mediaStyles[k].set("--kui-compare", css);
16916
+ ranges[k].value = String(positions[k]);
16917
+ ranges[k].setAttribute("aria-valuetext", `${positions[k]}%`);
16918
+ if (k > 0) ranges[k - 1].max = String(positions[k]);
16919
+ if (k < count - 1) ranges[k + 1].min = String(positions[k]);
16920
+ };
16921
+ for (let k = 0; k < count; k++) {
16922
+ const range = createRange(doc, { name: dividerLabel(el, media, k), valueText: `${positions[k]}%` }, positions[k], axis);
16923
+ const handle = createHandle(doc);
16924
+ ranges.push(range);
16925
+ handles.push(handle);
16926
+ el.append(range, handle);
16927
+ const onInput = () => render(k, Number(range.value));
16928
+ const onKeyDown = verticalKeys(range, axis, onInput);
16929
+ range.addEventListener("input", onInput);
16930
+ range.addEventListener("keydown", onKeyDown);
16931
+ cleanups.push(() => {
16932
+ range.removeEventListener("input", onInput);
16933
+ range.removeEventListener("keydown", onKeyDown);
16934
+ });
16935
+ }
16936
+ for (let k = 0; k < count; k++) {
16937
+ const [lo, hi] = boundsOf(positions, k);
16938
+ ranges[k].min = String(lo);
16939
+ ranges[k].max = String(hi);
16940
+ render(k, positions[k]);
16941
+ }
16942
+ return {
16943
+ ranges,
16944
+ render,
16945
+ moveTo(k, percent) {
16946
+ const [lo, hi] = boundsOf(positions, k);
16947
+ render(k, Math.min(hi, Math.max(lo, percent)));
16948
+ },
16949
+ nearest: (percent) => nearestDivider(positions, percent),
16950
+ destroy() {
16951
+ for (const cleanup of cleanups) cleanup();
16952
+ for (const node of [...ranges, ...handles]) node.remove();
16953
+ for (const ledger of mediaStyles) ledger.restore();
16954
+ }
16955
+ };
16956
+ }
16957
+ function nearestDivider(positions, percent) {
16958
+ let best = 0;
16959
+ for (let k = 1; k < positions.length; k++) {
16960
+ const distance3 = Math.abs(positions[k] - percent);
16961
+ const bestDistance = Math.abs(positions[best] - percent);
16962
+ if (distance3 < bestDistance || distance3 === bestDistance && percent >= positions[k]) best = k;
16963
+ }
16964
+ return best;
16965
+ }
16966
+ function bindSurface(el, axis, dividers) {
16967
+ const surface = el.ownerDocument.createElement("div");
16968
+ surface.className = "kui-compare-surface";
16969
+ surface.setAttribute("aria-hidden", "true");
16970
+ el.appendChild(surface);
16971
+ let dragging = -1;
16972
+ let pointerId = null;
16973
+ const percentAt = (event) => {
16974
+ const rect = el.getBoundingClientRect();
16975
+ const along = axis === "y" ? event.clientY - rect.top : event.clientX - rect.left;
16976
+ const size = axis === "y" ? rect.height : rect.width;
16977
+ return size > 0 ? 100 * along / size : 0;
16978
+ };
16979
+ const onPointerDown = (event) => {
16980
+ if (event.button > 0 || pointerId !== null) return;
16981
+ pointerId = event.pointerId;
16982
+ const percent = percentAt(event);
16983
+ dragging = dividers.nearest(percent);
16984
+ event.preventDefault();
16985
+ surface.setPointerCapture?.(event.pointerId);
16986
+ dividers.ranges[dragging].focus({ preventScroll: true });
16987
+ dividers.moveTo(dragging, percent);
16988
+ };
16989
+ const onPointerMove = (event) => {
16990
+ if (dragging >= 0 && event.pointerId === pointerId) dividers.moveTo(dragging, percentAt(event));
16991
+ };
16992
+ const onPointerUp = (event) => {
16993
+ if (event.pointerId !== pointerId) return;
16994
+ dragging = -1;
16995
+ pointerId = null;
16996
+ };
16997
+ const onLostCapture = (event) => {
16998
+ if (event.target !== surface) return;
16999
+ onPointerUp(event);
17000
+ };
17001
+ const listeners = [
17002
+ ["pointerdown", onPointerDown],
17003
+ ["pointermove", onPointerMove],
17004
+ ["pointerup", onPointerUp],
17005
+ ["pointercancel", onPointerUp],
17006
+ ["lostpointercapture", onLostCapture]
17007
+ ];
17008
+ for (const [type, listener] of listeners) surface.addEventListener(type, listener);
17009
+ return {
17010
+ node: surface,
17011
+ destroy() {
17012
+ for (const [type, listener] of listeners) surface.removeEventListener(type, listener);
17013
+ surface.remove();
17014
+ }
17015
+ };
17016
+ }
17017
+ function prepareCompare(el, params, ctx) {
17018
+ const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
17019
+ if (media.length < 2) {
17020
+ ctx.warn("compare requires at least two media children (img, picture, or video)");
17021
+ }
17022
+ const axis = params.text("axis", "x");
17023
+ const attrs = createAttributeLedger(el);
17024
+ attrs.set(COMPARE_AXIS_ATTR, axis);
17025
+ const build2 = media.length > 2 ? buildMulti : buildPair;
17026
+ const teardown = build2({
17027
+ el,
17028
+ media,
17029
+ params,
17030
+ ctx,
17031
+ axis,
17032
+ timing: {
17033
+ mode: params.text("autoplay", "never"),
17034
+ durationMs: sweepDuration(params, ctx),
17035
+ reverse: params.is("reverse"),
17036
+ loop: params.is("loop")
17037
+ }
17038
+ });
17039
+ return continuousSetup(() => {
17040
+ teardown();
17041
+ attrs.restore();
15076
17042
  });
15077
17043
  }
15078
17044
  var COMPARE_PRIMITIVE = widgetPrimitive(
15079
17045
  "compare",
15080
17046
  {
15081
- channels: ["clip", "widget"],
17047
+ // The layers it stacks and the range, handle and surface it appends; the axis and divider
17048
+ // markers it writes on the host. Not `clip`: its `clip-path` is on the after layer, never the
17049
+ // host, so a `wipe-up` revealing the whole slider is not a fight over one property.
17050
+ channels: [SUBTREE_CHANNEL, attributeChannel(COMPARE_AXIS_ATTR), attributeChannel(COMPARE_DIVIDERS_ATTR)],
15082
17051
  parameters: compareParams,
15083
17052
  perfClass: "paint"
15084
17053
  },
15085
17054
  withTimingContract(
15086
17055
  "compare",
15087
17056
  {
15088
- because: "it builds an interactive before/after slider; there is no motion to time"
17057
+ honours: ["duration"],
17058
+ 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"
15089
17059
  },
15090
17060
  deferPrepare(prepareCompare)
15091
17061
  )
@@ -15259,7 +17229,8 @@ var kuinetic = (() => {
15259
17229
  var HOTSPOTS_PRIMITIVE = widgetPrimitive(
15260
17230
  "hotspots",
15261
17231
  {
15262
- channels: ["widget"],
17232
+ // The marker buttons it appends, placed in percent of the host, and the marker style it writes.
17233
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-hotspot-marker")],
15263
17234
  parameters: hotspotsParams,
15264
17235
  perfClass: "layout"
15265
17236
  },
@@ -15463,7 +17434,7 @@ var kuinetic = (() => {
15463
17434
  hovered = true;
15464
17435
  update();
15465
17436
  };
15466
- const leave = () => {
17437
+ const leave2 = () => {
15467
17438
  hovered = false;
15468
17439
  update();
15469
17440
  };
@@ -15480,7 +17451,7 @@ var kuinetic = (() => {
15480
17451
  update();
15481
17452
  };
15482
17453
  el.addEventListener("pointerenter", enter);
15483
- el.addEventListener("pointerleave", leave);
17454
+ el.addEventListener("pointerleave", leave2);
15484
17455
  el.addEventListener("focusin", focus);
15485
17456
  el.addEventListener("focusout", blur);
15486
17457
  ctx.doc.addEventListener("visibilitychange", update);
@@ -15489,7 +17460,7 @@ var kuinetic = (() => {
15489
17460
  return () => {
15490
17461
  if (timer !== null) clearTimeout(timer);
15491
17462
  el.removeEventListener("pointerenter", enter);
15492
- el.removeEventListener("pointerleave", leave);
17463
+ el.removeEventListener("pointerleave", leave2);
15493
17464
  el.removeEventListener("focusin", focus);
15494
17465
  el.removeEventListener("focusout", blur);
15495
17466
  ctx.doc.removeEventListener("visibilitychange", update);
@@ -15555,7 +17526,6 @@ var kuinetic = (() => {
15555
17526
  el,
15556
17527
  params,
15557
17528
  ctx,
15558
- scope,
15559
17529
  resolveSteps,
15560
17530
  name: "slideshow",
15561
17531
  clickFallback: false,
@@ -15583,7 +17553,19 @@ var kuinetic = (() => {
15583
17553
  }
15584
17554
  var SLIDESHOW_PRIMITIVE = widgetPrimitive(
15585
17555
  "slideshow",
15586
- { channels: ["widget"], parameters: slideshowParams, perfClass: "layout" },
17556
+ // The controls it inserts, then what it writes on the host: `createStepIndex`'s `data-kui-step`,
17557
+ // as every step deck does, and `decorateHost`'s mode and carousel roles.
17558
+ {
17559
+ channels: [
17560
+ SUBTREE_CHANNEL,
17561
+ attributeChannel("data-kui-step"),
17562
+ attributeChannel(SLIDE_MODE_ATTR),
17563
+ attributeChannel("role"),
17564
+ attributeChannel("aria-roledescription")
17565
+ ],
17566
+ parameters: slideshowParams,
17567
+ perfClass: "layout"
17568
+ },
15587
17569
  withTimingContract(
15588
17570
  "slideshow",
15589
17571
  { honours: ["duration", "ease"], because: "the slideshow has no delayed start" },
@@ -15745,7 +17727,14 @@ var kuinetic = (() => {
15745
17727
  }
15746
17728
  var SCROLL_STORY_PRIMITIVE = widgetPrimitive(
15747
17729
  "scroll-story",
15748
- { channels: ["widget"], parameters: scrollStoryParams, perfClass: "layout" },
17730
+ // Its stylesheet lays out the host's first and last child, so nothing else may insert one. The
17731
+ // step index on the host (`applyStep`) is claimed so a `carousel` or `scroll-progress` beside it
17732
+ // is refused rather than left to overwrite it.
17733
+ {
17734
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-step"), attributeChannel(STORY_SIDE_ATTR)],
17735
+ parameters: scrollStoryParams,
17736
+ perfClass: "layout"
17737
+ },
15749
17738
  withTimingContract(
15750
17739
  "scroll-story",
15751
17740
  { because: "scroll position" },
@@ -15848,7 +17837,8 @@ var kuinetic = (() => {
15848
17837
  }
15849
17838
  var SLOW_MO_PRIMITIVE = widgetPrimitive(
15850
17839
  "slow-mo",
15851
- { channels: ["widget"], parameters: slowMoParams, perfClass: "compositor" },
17840
+ // The toggle it prepends into the host, and the time scale it writes there.
17841
+ { channels: [SUBTREE_CHANNEL, attributeChannel(TIME_SCALE_ATTR)], parameters: slowMoParams, perfClass: "compositor" },
15852
17842
  withTimingContract(
15853
17843
  "slow-mo",
15854
17844
  { because: "it changes playback rate rather than defining an animation timeline" },
@@ -15877,6 +17867,7 @@ var kuinetic = (() => {
15877
17867
  ...SLOW_MO_PRESETS
15878
17868
  ];
15879
17869
  function registerShowcase(target) {
17870
+ provideDeckViewer(attachDeckLightbox);
15880
17871
  return registerInto(target, SHOWCASE_PRIMITIVES, SHOWCASE_PRESETS, "Showcase");
15881
17872
  }
15882
17873
 
@@ -15905,6 +17896,16 @@ var kuinetic = (() => {
15905
17896
  return new Animator({ registry: createRegistry(), ...options });
15906
17897
  }
15907
17898
  var src_default = kuinetic;
17899
+ var catalog;
17900
+ function describe2(name, options = {}) {
17901
+ return describeEffect(options.registry ?? (catalog ??= createRegistry()), name, options.notes);
17902
+ }
17903
+ function describeSteps(value, options = {}) {
17904
+ return describeAttribute(options.registry ?? (catalog ??= createRegistry()), value, options.notes);
17905
+ }
17906
+ function describeElement(value, options = {}) {
17907
+ return describeElementAttribute(options.registry ?? (catalog ??= createRegistry()), value, options.notes);
17908
+ }
15908
17909
  return __toCommonJS(src_exports);
15909
17910
  })();
15910
17911