kuinetic 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/dist/esm/{chunk-XDDJVANU.mjs → chunk-EHS45PIK.mjs} +2346 -536
  3. package/dist/esm/{chunk-K2M7O6W5.mjs → chunk-FASAEQDR.mjs} +32 -1
  4. package/dist/esm/{chunk-BBQ22D7A.mjs → chunk-MZQG2VMG.mjs} +202 -2
  5. package/dist/esm/core/index.mjs +21 -3
  6. package/dist/esm/effects/index.mjs +2 -2
  7. package/dist/esm/index.mjs +35 -4
  8. package/dist/esm/notes/index.mjs +1083 -0
  9. package/dist/kuinetic.all.js +2547 -509
  10. package/dist/kuinetic.all.min.js +2 -2
  11. package/dist/kuinetic.css +338 -76
  12. package/dist/kuinetic.js +2546 -508
  13. package/dist/kuinetic.min.css +1 -1
  14. package/dist/kuinetic.min.js +1 -1
  15. package/dist/kuinetic.notes.js +1 -0
  16. package/dist/types/3d/flattening.d.ts +8 -7
  17. package/dist/types/core/deck-viewer.d.ts +87 -0
  18. package/dist/types/core/describe.d.ts +174 -0
  19. package/dist/types/core/gesture.d.ts +10 -0
  20. package/dist/types/core/index.d.ts +6 -2
  21. package/dist/types/core/target.d.ts +28 -0
  22. package/dist/types/core/timing-contract.d.ts +61 -0
  23. package/dist/types/core/types.d.ts +59 -0
  24. package/dist/types/effects/auto-motion.d.ts +169 -0
  25. package/dist/types/effects/carousel/deck.d.ts +170 -0
  26. package/dist/types/effects/carousel/drag.d.ts +6 -0
  27. package/dist/types/effects/carousel/fit.d.ts +130 -0
  28. package/dist/types/effects/carousel/index.d.ts +13 -23
  29. package/dist/types/effects/carousel/stack.d.ts +77 -0
  30. package/dist/types/effects/catalog/interaction-reveal.d.ts +69 -9
  31. package/dist/types/effects/forms/primitives.d.ts +1 -2
  32. package/dist/types/effects/gestures/index.d.ts +3 -4
  33. package/dist/types/effects/shared.d.ts +3 -30
  34. package/dist/types/effects/step-index.d.ts +48 -10
  35. package/dist/types/effects/swipe-event.d.ts +75 -0
  36. package/dist/types/index.d.ts +26 -0
  37. package/dist/types/notes/catalog-core.d.ts +3 -0
  38. package/dist/types/notes/catalog.d.ts +3 -0
  39. package/dist/types/notes/index.d.ts +13 -0
  40. package/dist/types/notes/interaction-reveal.d.ts +3 -0
  41. package/dist/types/notes/interaction.d.ts +6 -0
  42. package/dist/types/notes/keys.d.ts +7 -0
  43. package/dist/types/notes/mechanics.d.ts +7 -0
  44. package/dist/types/notes/scroll-nav-forms.d.ts +6 -0
  45. package/dist/types/notes/shared.d.ts +6 -0
  46. package/dist/types/notes/showcase.d.ts +7 -0
  47. package/dist/types/notes/text.d.ts +3 -0
  48. package/dist/types/showcase/compare.d.ts +9 -0
  49. package/dist/types/showcase/index.d.ts +3 -0
  50. package/dist/types/showcase/lightbox.d.ts +24 -1
  51. package/dist/types/showcase/modal-shell.d.ts +11 -0
  52. package/package.json +7 -3
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,406 @@ 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
+ cloak: true
15122
+ }
15123
+ ];
15124
+
15125
+ // src/effects/carousel/index.ts
15126
+ var QUARTER_TURN_DEG = 90;
15127
+ var FLATTENING = [
15128
+ ["overflow", (value) => value !== "" && value !== "visible"],
15129
+ ["clip-path", (value) => value !== "" && value !== "none"],
15130
+ ["opacity", (value) => value !== "" && Number(value) < 1],
15131
+ ["filter", (value) => value !== "" && value !== "none"],
15132
+ ["backdrop-filter", (value) => value !== "" && value !== "none"],
15133
+ ["isolation", (value) => value === "isolate"],
15134
+ ["mix-blend-mode", (value) => value !== "" && value !== "normal"],
15135
+ ["mask-image", (value) => value !== "" && value !== "none"]
15136
+ ];
15137
+ function warnFlattenedHost(el, ctx) {
15138
+ const found = flatteningDeclaration(el, ctx);
15139
+ if (!found) return;
15140
+ ctx.warn(
15141
+ `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.`
15142
+ );
15143
+ }
15144
+ function flatteningDeclaration(node, ctx) {
15145
+ const style = ctx.win.getComputedStyle?.(node);
15146
+ if (!style) return null;
15147
+ for (const [property2, flattens] of FLATTENING) {
15148
+ const value = style.getPropertyValue(property2);
15149
+ if (flattens(value)) return { property: property2, value };
15150
+ }
15151
+ return null;
15152
+ }
15153
+ function faceAt(angleDeg) {
15154
+ return Math.abs(normaliseDegrees(angleDeg)) >= QUARTER_TURN_DEG ? "back" : "front";
15155
+ }
15156
+ function normaliseDegrees(angleDeg) {
15157
+ const wrapped2 = (angleDeg % 360 + 360) % 360;
15158
+ return wrapped2 > 180 ? wrapped2 - 360 : wrapped2;
15159
+ }
15160
+ var RING_PARAMETERS = {
15161
+ ...DECK_PARAMETERS,
15162
+ /*
15163
+ * A real CSS `<angle>`, not a normalised scalar.
15164
+ *
15165
+ * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
15166
+ * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
15167
+ * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
15168
+ * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
15169
+ * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
15170
+ * `clamp()` an author can see in devtools rather than a hidden rescale.
15171
+ *
15172
+ * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
15173
+ * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
15174
+ * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
15175
+ * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
15176
+ */
15177
+ tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
15178
+ /*
15179
+ * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
15180
+ *
15181
+ * This is the parameter the concave name exists around. From the centre of a *full* ring most of
15182
+ * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
15183
+ * two thirds of the content — the inside name defaults to a slice for that reason.
15184
+ */
15185
+ arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
15186
+ /*
15187
+ * The ring's radius, or unset for one derived from the content.
15188
+ *
15189
+ * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
15190
+ * `readParams` fills every declared default in unconditionally, so an empty one is the only way
15191
+ * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
15192
+ * the circumradius of a regular polygon with this many sides and this much side length —
15193
+ * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
15194
+ * Every consumer solving that by hand was the alternative, and they would all have solved it
15195
+ * differently.
15196
+ *
15197
+ * The width half of that has to come from JavaScript: CSS can compute with a measurement but
15198
+ * cannot take one. See `measureItem` in `deck.ts`.
15199
+ *
15200
+ * Authored or derived, a depth ring's radius is a ceiling: a ring that would push its page
15201
+ * sideways is pulled in exactly as far as it has to be (`fit.ts`, `--kui-ring-fit`), and one that
15202
+ * already fits — or sits inside an ancestor that clips it — keeps this radius to the pixel.
15203
+ */
15204
+ radius: { type: "length", default: "", cssProperty: "--kui-radius" },
15205
+ /** Breathing room between neighbours, spent by the derived radius above. */
15206
+ gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
15207
+ /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
15208
+ perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
15209
+ /*
15210
+ * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
15211
+ *
15212
+ * `radial` is the ring's natural state and needs nothing: a slot placed by
15213
+ * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
15214
+ * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
15215
+ * ring read as the far side.
15216
+ *
15217
+ * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
15218
+ * and the billboard have to stay on separate boxes or every later change to one silently
15219
+ * multiplies into the other — so text stays readable at every position, which is what a raised
15220
+ * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
15221
+ */
15222
+ facing: {
15223
+ type: "keyword",
15224
+ default: "radial",
15225
+ cssProperty: "--kui-facing",
15226
+ keywords: ["radial", "camera"]
15227
+ },
15228
+ /*
15229
+ * Which plane the ring lies in: `depth` (the default — a carousel you look *at*, turning about a
15230
+ * vertical pole) or `screen` (a clock face, turning about the axis that points at you).
15231
+ *
15232
+ * ## Why a parameter of this primitive, and not a new one
15233
+ *
15234
+ * A flat ring of cards around a centred word is the same object as the 3D ring seen from its
15235
+ * pole: N slots, evenly spaced by `arc / count`, placed from `--kui-offset` and the shared drift,
15236
+ * advanced by the same drag, keys, controls and `spin:`. Every number this primitive publishes
15237
+ * means the same thing in both planes; only the three transform functions that turn a place into
15238
+ * a position differ, and those live in `carousel.css` anyway. A second primitive would have
15239
+ * duplicated the whole index to change one stylesheet rule and one face test.
15240
+ *
15241
+ * It is a parameter rather than only a name (unlike convex/concave, which differ in every
15242
+ * default and so earned two names) because the runtime has to know it: a flat slot is never
15243
+ * turned away from the viewer, so the quarter-turn face test that stops the back of a 3D ring
15244
+ * swallowing clicks would, on a clock face, disable every card in the bottom half. The name that
15245
+ * ships it, `carousel-orbit`, sets it; the stylesheet gates on the published attribute rather
15246
+ * than the name, so `carousel-3d plane:screen` is equally honoured — same rule as `facing:`.
15247
+ *
15248
+ * `facing:` keeps its meaning here. `radial` turns each card with the ring, top edge outward like
15249
+ * the numerals on a watch bezel; `camera` keeps every card upright. In the screen plane upright
15250
+ * needs no child counter-rotation at all — the slot is *translated* to its place on the circle
15251
+ * instead of rotated there — so it costs no extra markup, unlike the 3D billboard.
15252
+ */
15253
+ plane: {
15254
+ type: "keyword",
15255
+ default: "depth",
15256
+ cssProperty: "--kui-ring-plane",
15257
+ keywords: ["depth", "screen"]
15258
+ }
15259
+ };
15260
+ function prepareSpatialRing(el, params, ctx) {
15261
+ const plane = params.text("plane", "depth");
15262
+ if (plane === "depth") warnFlattenedHost(el, ctx);
15263
+ const arcDeg = degreesOf(params.text("arc", "360deg"), 360);
15264
+ const faceOf = (place3, _offset, count) => plane === "screen" ? "front" : faceAt(place3 * (arcDeg / count));
15265
+ return prepareSpatialDeck(el, params, ctx, {
15266
+ id: "spatial-ring",
15267
+ label: "carousel",
15268
+ faceOf,
15269
+ /*
15270
+ * A derived radius needs a card width; a flat ring also needs a card's size to reserve its own
15271
+ * circle (the host is sized to it, since transformed slots take no layout space), so it measures
15272
+ * even when the author named a radius.
15273
+ */
15274
+ measure: params.text("radius") === "" || plane === "screen",
15275
+ hostAttributes: {
15276
+ /*
15277
+ * `facing:` and `plane:` reach CSS as attributes, not only as the custom properties their
15278
+ * specs declare. A stylesheet cannot select on the *value* of a custom property — `@container
15279
+ * style(--kui-facing: camera)` can, and is not supported widely enough to hang a documented
15280
+ * parameter on — so the one thing a selector needs is published in the one form a selector
15281
+ * can read. Same pairing, for the same reason, as the number/attribute pair `step-marking.ts`
15282
+ * publishes for the ring place.
15283
+ */
15284
+ "data-kui-ring-facing": params.text("facing", "radial"),
15285
+ "data-kui-ring-plane": plane
15286
+ },
15287
+ /*
15288
+ * The fit to the page (`fit.ts`). Depth plane only: a flat ring already reserves and clamps to
15289
+ * its own square (`carousel.css`, `[data-kui-ring-plane='screen']`), and its cards never leave
15290
+ * the plane that square is measured in.
15291
+ */
15292
+ attach: plane === "depth" ? (styles) => createRingFit({
15293
+ el,
15294
+ ctx,
15295
+ styles,
15296
+ arcDeg,
15297
+ tiltParamDeg: degreesOf(params.text("tilt", "0deg"), 0),
15298
+ facing: params.text("facing", "radial") === "camera" ? "camera" : "radial"
15299
+ }) : void 0
15300
+ });
15301
+ }
13909
15302
  function degreesOf(value, fallbackDeg) {
13910
15303
  const match = /^(-?[\d.]+)(deg|rad|turn|grad)?$/i.exec(value.trim());
13911
15304
  if (!match) return fallbackDeg;
@@ -13925,7 +15318,15 @@ var kuinetic = (() => {
13925
15318
  var SPATIAL_RING_PRIMITIVE = {
13926
15319
  id: "spatial-ring",
13927
15320
  renderer: "javascript",
13928
- channels: [CHANNEL.skew, "discrete"],
15321
+ // What `deck.ts` writes on the host (the cards, the step index, the drag), then the two
15322
+ // attributes only the ring's own layout publishes.
15323
+ channels: [
15324
+ CHANNEL.skew,
15325
+ "discrete",
15326
+ ...SPATIAL_DECK_CHANNELS,
15327
+ attributeChannel("data-kui-ring-facing"),
15328
+ attributeChannel("data-kui-ring-plane")
15329
+ ],
13929
15330
  parameters: RING_PARAMETERS,
13930
15331
  supportedTimelines: ["time"],
13931
15332
  // `load`, not `enter`: a carousel that only wires its arrows once scrolled into view is broken,
@@ -13936,28 +15337,31 @@ var kuinetic = (() => {
13936
15337
  reducedMotion: "shorten",
13937
15338
  prepare: deferPrepare(prepareSpatialRing)
13938
15339
  };
13939
- var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE];
15340
+ var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE, SPATIAL_STACK_PRIMITIVE];
13940
15341
  var CAROUSEL_PRESETS = [
13941
15342
  {
13942
15343
  name: "carousel-3d",
13943
15344
  primitive: "spatial-ring",
13944
15345
  params: { tilt: "12deg" },
13945
15346
  requiresOwnSubtree: true,
13946
- phase: "idle"
15347
+ phase: "idle",
15348
+ cloak: true
13947
15349
  },
13948
15350
  {
13949
15351
  name: "carousel-3d-high",
13950
15352
  primitive: "spatial-ring",
13951
15353
  params: { tilt: "30deg", perspective: "1200px" },
13952
15354
  requiresOwnSubtree: true,
13953
- phase: "idle"
15355
+ phase: "idle",
15356
+ cloak: true
13954
15357
  },
13955
15358
  {
13956
15359
  name: "carousel-3d-low",
13957
15360
  primitive: "spatial-ring",
13958
15361
  params: { tilt: "-18deg", perspective: "1800px" },
13959
15362
  requiresOwnSubtree: true,
13960
- phase: "idle"
15363
+ phase: "idle",
15364
+ cloak: true
13961
15365
  },
13962
15366
  /*
13963
15367
  * The concave name. `arc:120deg` is the default the outside names cannot want and this one cannot
@@ -13972,11 +15376,34 @@ var kuinetic = (() => {
13972
15376
  primitive: "spatial-ring",
13973
15377
  params: { arc: "120deg", facing: "camera", perspective: "900px" },
13974
15378
  requiresOwnSubtree: true,
13975
- phase: "idle"
15379
+ phase: "idle",
15380
+ cloak: true
15381
+ },
15382
+ /*
15383
+ * The flat ring: cards on a clock face around whatever sits in the middle of the host (a word, a
15384
+ * logo — anything the `target:` does not name stays centred and off the ring). `plane:screen` is
15385
+ * the whole difference; see the parameter's note for why that is a parameter of this primitive and
15386
+ * not a new one.
15387
+ *
15388
+ * Named for what it does rather than which way it goes — `orbit` is the motion, and it has no
15389
+ * direction to name (the directional-naming rule, "named by travel", is about entrances that
15390
+ * *have* one; a ring's direction is the sign of `spin:`). Not `carousel-3d-*`, because it is not
15391
+ * three-dimensional, and a name that promised depth would send people looking for a `tilt:`.
15392
+ *
15393
+ * `facing:camera` by default: upright cards read as a set of things; radially turned ones read as
15394
+ * a dial, which is the less common ask and one keyword away.
15395
+ */
15396
+ {
15397
+ name: "carousel-orbit",
15398
+ primitive: "spatial-ring",
15399
+ params: { plane: "screen", facing: "camera" },
15400
+ requiresOwnSubtree: true,
15401
+ phase: "idle",
15402
+ cloak: true
13976
15403
  }
13977
15404
  ];
13978
15405
  function registerCarousel(registry) {
13979
- return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS);
15406
+ return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS).registerPresets(STACK_PRESETS);
13980
15407
  }
13981
15408
 
13982
15409
  // src/effects/tween/properties.ts
@@ -14532,7 +15959,10 @@ var kuinetic = (() => {
14532
15959
  keywords: [...DEVICE_FRAME_KINDS]
14533
15960
  },
14534
15961
  color: { type: "color", default: "#111", cssProperty: "--kui-device-color" },
14535
- radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" }
15962
+ radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" },
15963
+ // Its own knob rather than a reading of `radius:`: the frame's corner and the screen's are two
15964
+ // shapes, and the derived screen value already follows `radius:` when this is left unwritten.
15965
+ "screen-radius": { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-screen-radius" }
14536
15966
  };
14537
15967
  function prepareDeviceFrame(el, params) {
14538
15968
  const state = createAttributeLedger(el);
@@ -14545,8 +15975,9 @@ var kuinetic = (() => {
14545
15975
  // Exclusive ownership of the frame chrome. Its static box-model declarations are outside
14546
15976
  // the channel-property audit, as they are for the existing catalog. The chrome also reaches
14547
15977
  // into the direct media child (`object-fit: cover` etc.); that is a `requiresOwnSubtree`
14548
- // question, as with the other showcase widgets.
14549
- channels: ["frame"],
15978
+ // question, as with the other showcase widgets. The frame kind it stamps on the host is an
15979
+ // attribute it writes, so it claims that too.
15980
+ channels: ["frame", attributeChannel("data-kui-device")],
14550
15981
  parameters: deviceFrameParams,
14551
15982
  // `'layout'`: the bezel's `border`+`padding` are box-model properties added once, at
14552
15983
  // activation, which is a real (if one-time) layout — not the "transform/opacity only" budget
@@ -14581,10 +16012,12 @@ var kuinetic = (() => {
14581
16012
  state.lock = ledger;
14582
16013
  }
14583
16014
  function clearContent(state) {
14584
- state.current?.onClose?.();
16015
+ const closing = state.current;
16016
+ closing?.onClose?.();
14585
16017
  state.current = void 0;
14586
16018
  state.currentOwner = void 0;
14587
16019
  state.slot?.replaceChildren();
16020
+ if (closing?.opener) announceViewer(state.doc, closing.opener, false);
14588
16021
  }
14589
16022
  function onClose(state) {
14590
16023
  state.dialog?.classList.remove("is-open");
@@ -14603,11 +16036,9 @@ var kuinetic = (() => {
14603
16036
  }, state.current.reducedMotion ? 0 : state.current.duration);
14604
16037
  }
14605
16038
  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");
16039
+ const { node, inside } = state.current;
16040
+ if (inside) return !inside(target);
16041
+ return !node.contains(target) && !target.closest("button");
14611
16042
  }
14612
16043
  function forceReflow(element) {
14613
16044
  return element.offsetWidth;
@@ -14657,6 +16088,7 @@ var kuinetic = (() => {
14657
16088
  dialog.scrollTop = 0;
14658
16089
  forceReflow(dialog);
14659
16090
  dialog.classList.add("is-open");
16091
+ if (content.opener) announceViewer(state.doc, content.opener, true);
14660
16092
  }
14661
16093
  function closeNow(state) {
14662
16094
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -14736,25 +16168,38 @@ var kuinetic = (() => {
14736
16168
  }
14737
16169
 
14738
16170
  // src/showcase/lightbox.ts
16171
+ var DEFAULT_DURATION_MS = 280;
16172
+ var DEFAULT_SCALE = "0.965";
16173
+ var DEFAULT_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
14739
16174
  var paramsSchema = {
14740
- media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video"] },
16175
+ media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video", "mixed"] },
14741
16176
  target: { type: "text", default: "", cssProperty: "--kui-target" },
14742
16177
  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" },
16178
+ scale: { type: "number", default: DEFAULT_SCALE, cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
16179
+ duration: { type: "time", default: `${DEFAULT_DURATION_MS}ms`, cssProperty: "--kui-lightbox-duration" },
16180
+ ease: { type: "easing", default: DEFAULT_EASE, cssProperty: "--kui-lightbox-ease" },
14746
16181
  loop: { type: "keyword", default: "true", cssProperty: "--kui-lightbox-loop", keywords: ["true", "false"] },
14747
16182
  aspect: { type: "keyword", default: "", cssProperty: "--kui-lightbox-aspect", keywords: ["wide", "tall", "square"] },
14748
- caption: { type: "keyword", default: "figcaption", cssProperty: "--kui-lightbox-caption", keywords: ["figcaption", "alt", "title", "none"] }
16183
+ caption: { type: "keyword", default: "figcaption", cssProperty: "--kui-lightbox-caption", keywords: ["figcaption", "alt", "title", "none"] },
16184
+ arrows: {
16185
+ type: "keyword",
16186
+ default: "bottom-right",
16187
+ cssProperty: "--kui-lightbox-arrows",
16188
+ keywords: ["bottom-right", "bottom-left", "bottom", "bottom-apart", "top-right", "top-left", "sides"]
16189
+ },
16190
+ "arrow-gap": { type: "length", default: "", cssProperty: "--kui-lightbox-arrow-gap" }
14749
16191
  };
14750
16192
  function defaultTargets(el, media) {
14751
- if (el.matches("a[href]") || media === "image" && el.matches("img")) return [el];
16193
+ if (el.matches("a[href]") || media !== "video" && el.matches("img")) return [el];
14752
16194
  return [...el.querySelectorAll(media === "video" ? ":scope a[href]" : ":scope a[href], :scope img")];
14753
16195
  }
16196
+ function effectName(media) {
16197
+ return media === "video" ? "video-lightbox" : "lightbox";
16198
+ }
14754
16199
  function targets(el, params, ctx, media) {
14755
16200
  const authored = params.text("target");
14756
16201
  if (!authored) return defaultTargets(el, media);
14757
- const selector = resolveTarget(authored, ctx, media === "video" ? "video-lightbox" : "lightbox");
16202
+ const selector = resolveTarget(authored, ctx, effectName(media));
14758
16203
  return selector ? queryScoped(el, ctx, selector, scopeParam(params, "self")) : [];
14759
16204
  }
14760
16205
  function imageTrigger(target) {
@@ -14765,52 +16210,137 @@ var kuinetic = (() => {
14765
16210
  }
14766
16211
  function captionFor(trigger, image, source) {
14767
16212
  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;
16213
+ const alt = image?.alt ?? "";
16214
+ if (source === "alt") return alt;
16215
+ if (source === "title") return titleOf(trigger, image);
16216
+ return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || alt;
16217
+ }
16218
+ function titleOf(trigger, image) {
16219
+ return (trigger.getAttribute("title") || image?.title || "").trim();
14771
16220
  }
14772
16221
  function imageItem(trigger, captionSource) {
14773
16222
  const image = imageIn(trigger);
14774
16223
  if (!image) return null;
14775
16224
  const link = trigger instanceof HTMLAnchorElement ? trigger : null;
14776
16225
  return {
16226
+ kind: "image",
14777
16227
  trigger,
14778
16228
  image,
14779
16229
  src: link?.href ?? (image.currentSrc || image.src),
14780
16230
  caption: captionFor(trigger, image, captionSource)
14781
16231
  };
14782
16232
  }
14783
- function imageItems(targets2, captionSource) {
16233
+ function videoTitle(link, poster) {
16234
+ return link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
16235
+ }
16236
+ function videoItem(link, captionSource) {
16237
+ const source = resolveMediaSource(link.getAttribute("href") ?? "");
16238
+ if (!source) return null;
16239
+ const poster = link.querySelector("img");
16240
+ return { kind: "video", trigger: link, source, title: videoTitle(link, poster), caption: captionFor(link, poster, captionSource) };
16241
+ }
16242
+ function itemFor(trigger, media, captionSource) {
16243
+ if (media !== "image" && trigger instanceof HTMLAnchorElement) {
16244
+ const video = videoItem(trigger, captionSource);
16245
+ if (video || media === "video") return video;
16246
+ }
16247
+ return imageItem(trigger, captionSource);
16248
+ }
16249
+ function triggersFor(found, media) {
14784
16250
  const seen = /* @__PURE__ */ new Set();
14785
- const items = [];
14786
- for (const target of targets2) {
14787
- const trigger = imageTrigger(target);
16251
+ const triggers = [];
16252
+ for (const target of found) {
16253
+ const trigger = media === "video" ? target : imageTrigger(target);
14788
16254
  if (seen.has(trigger)) continue;
14789
- const item = imageItem(trigger, captionSource);
14790
- if (!item) continue;
16255
+ if (!usable(trigger, media)) continue;
14791
16256
  seen.add(trigger);
14792
- items.push(item);
16257
+ triggers.push(trigger);
14793
16258
  }
14794
- return items;
16259
+ return triggers;
16260
+ }
16261
+ function usable(trigger, media) {
16262
+ const link = trigger instanceof HTMLAnchorElement;
16263
+ if (media === "video") return link;
16264
+ const image = imageIn(trigger) !== null;
16265
+ return media === "image" ? image : link || image;
14795
16266
  }
14796
16267
  function modalOptions(params, ctx) {
14797
16268
  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)"),
16269
+ duration: params.timing.durationMs ?? params.ms("duration", DEFAULT_DURATION_MS),
16270
+ scale: params.text("scale", DEFAULT_SCALE),
16271
+ ease: params.timing.easing ?? params.text("ease", DEFAULT_EASE),
14801
16272
  reducedMotion: ctx.reducedMotion
14802
16273
  };
14803
16274
  }
14804
- function showImage(item, view, caption) {
16275
+ function showImage(item, view) {
14805
16276
  view.src = item.src;
14806
16277
  view.alt = item.image.alt;
14807
- caption.textContent = item.caption;
14808
- caption.hidden = item.caption === "";
14809
16278
  const ratio = item.image.naturalWidth ? item.image.naturalHeight / item.image.naturalWidth : 1;
14810
16279
  view.className = ratio > 1.5 ? "is-tall" : "is-fit";
14811
16280
  }
16281
+ function videoFrame(item, aspect, doc) {
16282
+ const frame = doc.createElement("div");
16283
+ frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect || item.source.aspect}`;
16284
+ if (item.source.kind === "file") {
16285
+ const video = doc.createElement("video");
16286
+ video.src = item.source.embedUrl;
16287
+ video.controls = true;
16288
+ video.autoplay = true;
16289
+ video.playsInline = true;
16290
+ video.setAttribute("aria-label", item.title);
16291
+ frame.append(video);
16292
+ } else {
16293
+ const iframe = doc.createElement("iframe");
16294
+ iframe.src = item.source.embedUrl;
16295
+ iframe.title = item.title;
16296
+ iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
16297
+ iframe.allowFullscreen = true;
16298
+ frame.append(iframe);
16299
+ }
16300
+ return frame;
16301
+ }
16302
+ function pauseVideo(video) {
16303
+ if (video.readyState > 0) video.pause();
16304
+ }
16305
+ function stopMedia(frame) {
16306
+ const video = frame.querySelector("video");
16307
+ if (video) {
16308
+ pauseVideo(video);
16309
+ video.removeAttribute("src");
16310
+ }
16311
+ frame.replaceChildren();
16312
+ }
16313
+ function leave(state, node) {
16314
+ if (node === state.view) return;
16315
+ const video = node.querySelector("video");
16316
+ if (video) pauseVideo(video);
16317
+ else node.replaceChildren();
16318
+ }
16319
+ function land(state, item) {
16320
+ if (item.kind === "image") {
16321
+ showImage(item, state.view);
16322
+ return state.view;
16323
+ }
16324
+ const cached2 = state.players.get(item);
16325
+ if (cached2) {
16326
+ void cached2.querySelector("video").play().catch(() => {
16327
+ });
16328
+ return cached2;
16329
+ }
16330
+ const frame = videoFrame(item, state.aspect, state.figure.ownerDocument);
16331
+ if (item.source.kind === "file") state.players.set(item, frame);
16332
+ return frame;
16333
+ }
14812
16334
  function renderGallery(state, focus) {
14813
- showImage(state.items[state.index], state.view, state.caption);
16335
+ const item = state.items[state.index];
16336
+ if (state.shown?.item !== item) {
16337
+ if (state.shown) leave(state, state.shown.node);
16338
+ const node = land(state, item);
16339
+ state.shown = { item, node };
16340
+ state.caption.textContent = item.caption;
16341
+ state.caption.hidden = item.caption === "";
16342
+ state.figure.replaceChildren(node, state.caption);
16343
+ }
14814
16344
  state.counter.textContent = `${state.index + 1} of ${state.items.length}`;
14815
16345
  state.previous.disabled = !state.loop && state.index === 0;
14816
16346
  state.next.disabled = !state.loop && state.index === state.items.length - 1;
@@ -14828,6 +16358,7 @@ var kuinetic = (() => {
14828
16358
  }
14829
16359
  function galleryKey(state, event) {
14830
16360
  if (state.items.length < 2) return;
16361
+ if (event.target.closest("video")) return;
14831
16362
  if (event.key === "ArrowLeft") moveGallery(state, -1, true);
14832
16363
  else if (event.key === "ArrowRight") moveGallery(state, 1, true);
14833
16364
  else if (event.key === "Home") {
@@ -14839,8 +16370,51 @@ var kuinetic = (() => {
14839
16370
  } else return;
14840
16371
  event.preventDefault();
14841
16372
  }
14842
- function galleryContent(items, initial, loop2, options) {
14843
- const doc = items[initial].image.ownerDocument;
16373
+ function swipeGallery(state, box) {
16374
+ const viewport = box.ownerDocument.defaultView?.visualViewport ?? null;
16375
+ const zoomed = () => (viewport?.scale ?? 1) > 1.01;
16376
+ const sync = () => {
16377
+ box.classList.toggle("is-swipeable", !zoomed());
16378
+ };
16379
+ sync();
16380
+ viewport?.addEventListener("resize", sync);
16381
+ const stop = recognise(box, {
16382
+ onSwipe(direction) {
16383
+ if (direction === "left" || direction === "right") moveGallery(state, swipeStep(direction), false);
16384
+ }
16385
+ }, {
16386
+ // A press on a button still clicks it: capture is taken only once the finger has moved.
16387
+ capturePointer: "drag",
16388
+ accept: (event) => event.pointerType !== "mouse" && !zoomed() && event.target.closest("video") === null
16389
+ });
16390
+ return () => {
16391
+ stop();
16392
+ viewport?.removeEventListener("resize", sync);
16393
+ };
16394
+ }
16395
+ function closeViewer(state) {
16396
+ state.view.removeAttribute("src");
16397
+ for (const frame of state.players.values()) stopMedia(frame);
16398
+ if (state.shown && state.shown.node !== state.view) stopMedia(state.shown.node);
16399
+ }
16400
+ function vocabulary(items) {
16401
+ const kinds = new Set(items.map((item) => item.kind));
16402
+ if (kinds.size > 1) return { noun: "item", label: "Media viewer" };
16403
+ return kinds.has("video") ? { noun: "video", label: "Video viewer" } : { noun: "image", label: "Image viewer" };
16404
+ }
16405
+ var SVG_NS = "http://www.w3.org/2000/svg";
16406
+ function chevron(doc, path2) {
16407
+ const svg = doc.createElementNS(SVG_NS, "svg");
16408
+ svg.setAttribute("viewBox", "0 0 24 24");
16409
+ svg.setAttribute("aria-hidden", "true");
16410
+ svg.setAttribute("focusable", "false");
16411
+ const stroke = doc.createElementNS(SVG_NS, "path");
16412
+ stroke.setAttribute("d", path2);
16413
+ svg.append(stroke);
16414
+ return svg;
16415
+ }
16416
+ function galleryContent(items, initial, options) {
16417
+ const doc = items[initial].trigger.ownerDocument;
14844
16418
  const box = doc.createElement("div");
14845
16419
  box.className = "kui-lightbox-gallery";
14846
16420
  const figure = doc.createElement("figure");
@@ -14848,7 +16422,6 @@ var kuinetic = (() => {
14848
16422
  const view = doc.createElement("img");
14849
16423
  view.decoding = "async";
14850
16424
  const caption = doc.createElement("figcaption");
14851
- figure.append(view, caption);
14852
16425
  box.append(figure);
14853
16426
  const counter = doc.createElement("span");
14854
16427
  counter.className = "kui-lightbox-counter";
@@ -14856,151 +16429,259 @@ var kuinetic = (() => {
14856
16429
  const previous = doc.createElement("button");
14857
16430
  const next = doc.createElement("button");
14858
16431
  previous.type = next.type = "button";
14859
- previous.textContent = "\u2039";
14860
- next.textContent = "\u203A";
14861
- previous.setAttribute("aria-label", "Previous image");
14862
- next.setAttribute("aria-label", "Next image");
16432
+ previous.append(chevron(doc, "M15 5l-7 7 7 7"));
16433
+ next.append(chevron(doc, "M9 5l7 7-7 7"));
16434
+ const { noun, label } = vocabulary(items);
16435
+ previous.setAttribute("aria-label", `Previous ${noun}`);
16436
+ next.setAttribute("aria-label", `Next ${noun}`);
14863
16437
  previous.className = "kui-lightbox-prev";
14864
16438
  next.className = "kui-lightbox-next";
14865
- const state = { items, index: initial, loop: loop2, figure, view, caption, counter, previous, next };
16439
+ const state = {
16440
+ items,
16441
+ index: initial,
16442
+ loop: options.loop,
16443
+ aspect: options.aspect,
16444
+ figure,
16445
+ view,
16446
+ caption,
16447
+ counter,
16448
+ previous,
16449
+ next,
16450
+ players: /* @__PURE__ */ new Map()
16451
+ };
14866
16452
  if (items.length > 1) {
14867
- box.append(previous, next, counter);
16453
+ const nav = doc.createElement("div");
16454
+ nav.className = `kui-lightbox-nav kui-lightbox-nav--${options.arrows}`;
16455
+ if (options.arrowGap) nav.style.setProperty("--kui-lightbox-arrow-gap", options.arrowGap);
16456
+ nav.append(previous, next);
16457
+ box.append(nav, counter);
14868
16458
  previous.addEventListener("click", () => clickGallery(state, -1, previous));
14869
16459
  next.addEventListener("click", () => clickGallery(state, 1, next));
14870
16460
  }
14871
16461
  renderGallery(state, false);
16462
+ const unswipe = items.length > 1 ? swipeGallery(state, box) : void 0;
16463
+ const { duration, scale, ease, reducedMotion } = options;
14872
16464
  return {
14873
- ...options,
16465
+ duration,
16466
+ scale,
16467
+ ease,
16468
+ reducedMotion,
14874
16469
  node: box,
14875
- label: "Image viewer",
14876
- onClose: () => view.removeAttribute("src"),
16470
+ label,
16471
+ // The media and the controls (the arrow group included, so a click in the gap between the
16472
+ // arrows is not a close) keep the viewer open; the empty figure around them closes it.
16473
+ inside: (target) => box.contains(target) && target.closest("img, video, iframe, button, .kui-lightbox-nav") !== null,
16474
+ onClose: () => {
16475
+ unswipe?.();
16476
+ closeViewer(state);
16477
+ },
14877
16478
  onKey: (event) => galleryKey(state, event)
14878
16479
  };
14879
16480
  }
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
16481
  function primaryClick(event) {
14913
16482
  return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
14914
16483
  }
14915
- function wireImages(items, wiring, loop2) {
16484
+ function openAt(triggers, trigger, wiring) {
16485
+ const items = [];
16486
+ let initial = -1;
16487
+ for (const candidate of triggers) {
16488
+ const item = itemFor(candidate, wiring.media, wiring.captionSource);
16489
+ if (!item) continue;
16490
+ if (candidate === trigger) initial = items.length;
16491
+ items.push(item);
16492
+ }
16493
+ if (initial < 0) {
16494
+ const href = trigger.getAttribute("href") ?? "";
16495
+ if (!wiring.warned.has(href)) wiring.ctx.warn(`${effectName(wiring.media)} cannot embed "${href}"; the link will navigate normally`);
16496
+ wiring.warned.add(href);
16497
+ return false;
16498
+ }
16499
+ wiring.shell.open({ ...galleryContent(items, initial, wiring.options), opener: trigger });
16500
+ return true;
16501
+ }
16502
+ function wireTriggers(triggers, wiring) {
14916
16503
  const restore = [];
14917
- items.forEach((item, index) => {
14918
- const bare = item.trigger === item.image;
16504
+ for (const trigger of triggers) {
16505
+ const bare = trigger instanceof HTMLImageElement;
14919
16506
  if (bare) {
14920
- const attrs = createAttributeLedger(item.image);
16507
+ const attrs = createAttributeLedger(trigger);
14921
16508
  attrs.set("role", "button");
14922
16509
  attrs.set("tabindex", "0");
14923
- attrs.set("aria-label", item.image.alt ? `Open larger image: ${item.image.alt}` : "Open larger image");
16510
+ attrs.set("aria-label", trigger.alt ? `Open larger image: ${trigger.alt}` : "Open larger image");
14924
16511
  restore.push(() => attrs.restore());
14925
- item.image.addEventListener("keydown", (event) => {
16512
+ trigger.addEventListener("keydown", (event) => {
14926
16513
  if (event.key !== "Enter" && event.key !== " ") return;
14927
16514
  event.preventDefault();
14928
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
16515
+ openAt(triggers, trigger, wiring);
14929
16516
  }, { signal: wiring.signal });
14930
16517
  }
14931
- item.trigger.addEventListener("click", (event) => {
16518
+ trigger.addEventListener("click", (event) => {
14932
16519
  if (!primaryClick(event)) return;
14933
- if (!bare) event.preventDefault();
14934
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
16520
+ if (openAt(triggers, trigger, wiring)) event.preventDefault();
14935
16521
  }, { signal: wiring.signal });
14936
- });
16522
+ }
14937
16523
  return restore;
14938
16524
  }
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
- }
16525
+ function cardItem(card) {
16526
+ const link = card.matches("a[href]") ? card : card.querySelector("a[href]");
16527
+ return link && itemFor(link, "mixed", "figcaption") || itemFor(card, "mixed", "figcaption");
16528
+ }
16529
+ var OWN_CLICK = "button, input, select, textarea, label, summary, [contenteditable]";
16530
+ function attachDeckLightbox(request) {
16531
+ const { host, cards, doc, reducedMotion } = request;
16532
+ const shell = acquireModalShell(doc);
16533
+ const options = {
16534
+ duration: DEFAULT_DURATION_MS,
16535
+ scale: DEFAULT_SCALE,
16536
+ ease: DEFAULT_EASE,
16537
+ reducedMotion,
16538
+ loop: true,
16539
+ aspect: "",
16540
+ arrows: "bottom-right",
16541
+ arrowGap: ""
16542
+ };
16543
+ const turnedAway = (card) => card.getAttribute("data-kui-ring-face") === "back";
16544
+ const openAt2 = (pick, faceChecked = false) => {
16545
+ const all = [...cards()];
16546
+ const card = all.find(pick);
16547
+ if (!card || !faceChecked && turnedAway(card)) return false;
16548
+ const items = [];
16549
+ let initial = -1;
16550
+ for (const candidate of all) {
16551
+ const item = cardItem(candidate);
16552
+ if (!item) continue;
16553
+ if (candidate === card) initial = items.length;
16554
+ items.push(item);
16555
+ }
16556
+ if (initial < 0) return false;
16557
+ shell.open({ ...galleryContent(items, initial, options), opener: card });
16558
+ return true;
16559
+ };
16560
+ let pressed = null;
16561
+ const cardUnder = (event) => {
16562
+ const all = [...cards()];
16563
+ const hits = typeof doc.elementsFromPoint === "function" ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
16564
+ for (const node of [...hits, event.target]) {
16565
+ const card = all.find((candidate) => candidate.contains(node));
16566
+ if (card) return turnedAway(card) ? null : { card, node };
16567
+ }
16568
+ return null;
16569
+ };
16570
+ const onPress = (event) => {
16571
+ pressed = event.button === 0 ? cardUnder(event) : null;
16572
+ };
16573
+ const onClick = (event) => {
16574
+ const press = event.detail > 0 ? pressed : null;
16575
+ pressed = null;
16576
+ const target = event.target;
16577
+ if (event.defaultPrevented || !primaryClick(event) || target.closest(OWN_CLICK)) return;
16578
+ if (press?.node.closest(OWN_CLICK)) return;
16579
+ const opened = press ? openAt2((card) => card === press.card, true) : openAt2((card) => card.contains(target));
16580
+ if (opened) event.preventDefault();
16581
+ };
16582
+ const onKeyDown = (event) => {
16583
+ const key = event.key;
16584
+ if (event.target !== host || key !== "Enter" && key !== " ") return;
16585
+ if (openAt2((card) => card.getAttribute(STEP_STATE_ATTR) === "active")) event.preventDefault();
16586
+ };
16587
+ host.addEventListener("pointerdown", onPress);
16588
+ host.addEventListener("click", onClick);
16589
+ host.addEventListener("keydown", onKeyDown);
16590
+ return () => {
16591
+ host.removeEventListener("pointerdown", onPress);
16592
+ host.removeEventListener("click", onClick);
16593
+ host.removeEventListener("keydown", onKeyDown);
16594
+ shell.release();
16595
+ };
14956
16596
  }
14957
16597
  function prepareLightbox(el, params, ctx) {
14958
16598
  const media = params.text("media", "image");
14959
- const found = targets(el, params, ctx, media);
14960
- if (found.length === 0) return () => {
16599
+ const triggers = triggersFor(targets(el, params, ctx, media), media);
16600
+ if (triggers.length === 0) return () => {
14961
16601
  };
14962
16602
  const controller = new AbortController();
14963
16603
  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
- }
16604
+ const options = {
16605
+ ...modalOptions(params, ctx),
16606
+ loop: params.is("loop"),
16607
+ aspect: params.text("aspect"),
16608
+ arrows: params.text("arrows", "bottom-right"),
16609
+ arrowGap: params.text("arrow-gap")
16610
+ };
16611
+ const restore = wireTriggers(triggers, {
16612
+ shell,
16613
+ options,
16614
+ signal: controller.signal,
16615
+ media,
16616
+ captionSource: params.text("caption", "figcaption"),
16617
+ ctx,
16618
+ warned: /* @__PURE__ */ new Set()
16619
+ });
14972
16620
  return continuousSetup(() => {
14973
16621
  controller.abort();
14974
16622
  for (const undo of restore) undo();
14975
16623
  shell.release();
14976
16624
  });
14977
16625
  }
16626
+ var TRIGGERS_CHANNEL = "lightbox:triggers";
14978
16627
  var LIGHTBOX_PRIMITIVE = widgetPrimitive(
14979
16628
  "lightbox",
14980
- { channels: ["widget"], parameters: paramsSchema, perfClass: "paint" },
16629
+ { channels: [TRIGGERS_CHANNEL], parameters: paramsSchema, perfClass: "paint" },
14981
16630
  withTimingContract("lightbox", { honours: ["duration", "ease"], because: "the dialog opens immediately, so it has no delay phase" }, deferPrepare(prepareLightbox))
14982
16631
  );
14983
16632
  var LIGHTBOX_PRESETS = [
14984
16633
  { name: "lightbox", primitive: "lightbox", params: { media: "image" } },
14985
- { name: "video-lightbox", primitive: "lightbox", params: { media: "video" } }
16634
+ // A video row has never shown captions; `caption:figcaption` opts one in.
16635
+ { name: "video-lightbox", primitive: "lightbox", params: { media: "video", caption: "none" } }
14986
16636
  ];
14987
16637
 
14988
16638
  // src/showcase/compare.ts
14989
16639
  var COMPARE_AXES = ["x", "y"];
14990
16640
  var COMPARE_AXIS_ATTR = "data-kui-compare-axis";
16641
+ var COMPARE_DIVIDERS_ATTR = "data-kui-compare-dividers";
14991
16642
  var compareParams = {
14992
16643
  position: {
14993
16644
  type: "percentage",
14994
16645
  default: "50%",
14995
16646
  cssProperty: "--kui-compare"
14996
16647
  },
16648
+ /*
16649
+ * Where each divider of a three-or-more frame starts, as one quoted, space-separated list —
16650
+ * `positions:'20% 45% 80%'` — because a comma would start a new step, not a new value. Default
16651
+ * (empty) is an even split. `position:` stays the two-media spelling: it is a single percentage
16652
+ * and every existing page relies on it validating as one.
16653
+ */
16654
+ positions: { type: "text", default: "", cssProperty: "--kui-compare-positions" },
14997
16655
  axis: {
14998
16656
  type: "keyword",
14999
16657
  default: "x",
15000
16658
  cssProperty: "--kui-compare-axis",
15001
16659
  keywords: [...COMPARE_AXES]
15002
- }
16660
+ },
16661
+ /*
16662
+ * The auto-drag, in `background-media`'s words (`src/effects/catalog/media.ts`): `always` starts
16663
+ * at load, `in-view` the first time the slider is on screen, `never` (default) not at all.
16664
+ *
16665
+ * Not `on:load` / `on:enter`, because the activation grammar already has a job here: it is what
16666
+ * *builds* the slider, and a widget accepts only `on:load` (`src/showcase/shared.ts`) so the
16667
+ * control exists before a keyboard user tabs to it. `on:enter` would defer the widget, not the
16668
+ * motion — so the motion's start is its own parameter.
16669
+ */
16670
+ autoplay: {
16671
+ type: "keyword",
16672
+ default: "never",
16673
+ cssProperty: "--kui-compare-autoplay",
16674
+ keywords: ["never", "in-view", "always"]
16675
+ },
16676
+ /** One edge-to-edge sweep. The positional spelling (`compare 4s`) means the same thing. */
16677
+ duration: { type: "time", default: "3s", cssProperty: "--kui-compare-duration" },
16678
+ /** Head toward 0% first instead of 100%. */
16679
+ reverse: { type: "keyword", default: "false", cssProperty: "--kui-compare-reverse", keywords: ["true", "false"] },
16680
+ /** Sweep back and forth until someone takes the handle, instead of one pass. */
16681
+ loop: { type: "keyword", default: "false", cssProperty: "--kui-compare-loop", keywords: ["true", "false"] }
15003
16682
  };
16683
+ var MIN_SWEEP_MS = 250;
16684
+ var MAX_FRAME_MS2 = 100;
15004
16685
  var VERTICAL_KEY_DELTA = { ArrowUp: -1, ArrowDown: 1 };
15005
16686
  function getMediaName(item, fallback) {
15006
16687
  const alt = item.localName === "picture" ? item.querySelector("img")?.getAttribute("alt") : item.getAttribute("alt");
@@ -15016,76 +16697,402 @@ var kuinetic = (() => {
15016
16697
  }
15017
16698
  return "Compare";
15018
16699
  }
15019
- function createCompareElements(doc, label, initialPercent, axis) {
16700
+ function createRange(doc, label, value, axis) {
15020
16701
  const range = doc.createElement("input");
15021
16702
  range.type = "range";
15022
16703
  range.className = "kui-compare-range";
15023
16704
  range.min = "0";
15024
16705
  range.max = "100";
15025
16706
  range.step = "1";
15026
- range.value = String(initialPercent);
15027
- range.setAttribute("aria-label", label);
15028
- range.setAttribute("aria-valuetext", `${initialPercent}% after`);
16707
+ range.value = String(value);
16708
+ range.setAttribute("aria-label", label.name);
16709
+ range.setAttribute("aria-valuetext", label.valueText);
15029
16710
  if (axis === "y") range.setAttribute("aria-orientation", "vertical");
16711
+ return range;
16712
+ }
16713
+ function createHandle(doc) {
15030
16714
  const handle = doc.createElement("span");
15031
16715
  handle.className = "kui-compare-handle";
15032
16716
  handle.setAttribute("aria-hidden", "true");
15033
- return { range, handle };
16717
+ return handle;
15034
16718
  }
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)");
16719
+ function verticalKeys(range, axis, onInput) {
16720
+ return (event) => {
16721
+ const delta = VERTICAL_KEY_DELTA[event.key];
16722
+ if (axis !== "y" || delta === void 0) return;
16723
+ event.preventDefault();
16724
+ if (delta < 0) range.stepDown();
16725
+ else range.stepUp();
16726
+ onInput();
16727
+ };
16728
+ }
16729
+ function sweepPercent(phase) {
16730
+ return 50 - 50 * Math.cos(Math.PI * phase / 100);
16731
+ }
16732
+ function startPhase(percent, reverse) {
16733
+ const outbound = 100 / Math.PI * Math.acos(1 - percent / 50);
16734
+ return reverse ? 200 - outbound : outbound;
16735
+ }
16736
+ function sweepDuration(params, ctx) {
16737
+ const authored = params.timing.durationMs ?? params.ms("duration", 3e3);
16738
+ if (authored >= MIN_SWEEP_MS) return authored;
16739
+ ctx.warn(`compare duration: minimum is ${MIN_SWEEP_MS}ms; clamped to ${MIN_SWEEP_MS}ms`);
16740
+ return MIN_SWEEP_MS;
16741
+ }
16742
+ function bindAutoDrag(options) {
16743
+ const { host, grab, ctx, mode, loop: loop2 } = options;
16744
+ if (mode === "never" || ctx.reducedMotion) return () => {
16745
+ };
16746
+ const run = runAutoDrag(options, () => stop());
16747
+ let observer = null;
16748
+ if (mode === "in-view" || loop2) {
16749
+ observer = observeOnscreen(ctx.win, host, run.setOnscreen);
15039
16750
  }
15040
- const axis = params.text("axis", "x");
15041
- const attrs = createAttributeLedger(el);
15042
- attrs.set(COMPARE_AXIS_ATTR, axis);
16751
+ const stop = () => {
16752
+ run.stop();
16753
+ observer?.disconnect();
16754
+ observer = null;
16755
+ for (const target of grab) {
16756
+ target.removeEventListener("pointerdown", stop);
16757
+ target.removeEventListener("focus", stop);
16758
+ }
16759
+ };
16760
+ for (const target of grab) {
16761
+ target.addEventListener("pointerdown", stop);
16762
+ target.addEventListener("focus", stop);
16763
+ }
16764
+ run.setOnscreen(mode === "always" || observer === null);
16765
+ return stop;
16766
+ }
16767
+ function sweepPlan(options) {
16768
+ const { write, homes, bounds, durationMs, reverse, loop: loop2 } = options;
16769
+ const order = homes.map((_, index) => index);
16770
+ if (reverse) order.reverse();
16771
+ const cycleMs = 2 * durationMs;
16772
+ const totalMs = cycleMs * order.length;
16773
+ const phases = homes.map((home, index) => {
16774
+ const [lo, hi] = bounds[index];
16775
+ return hi > lo ? startPhase(100 * (home - lo) / (hi - lo), reverse) : 0;
16776
+ });
16777
+ const park = (slot2) => write(order[slot2], homes[order[slot2]]);
16778
+ let slot = 0;
16779
+ return {
16780
+ show(elapsed) {
16781
+ if (!loop2 && elapsed >= totalMs) {
16782
+ park(slot);
16783
+ return false;
16784
+ }
16785
+ const into = elapsed % totalMs;
16786
+ const next = Math.floor(into / cycleMs);
16787
+ if (next !== slot) park(slot);
16788
+ slot = next;
16789
+ const index = order[slot];
16790
+ const [lo, hi] = bounds[index];
16791
+ const phase = phases[index] + 100 * (into - slot * cycleMs) / durationMs;
16792
+ write(index, lo + (hi - lo) * sweepPercent(phase) / 100);
16793
+ return true;
16794
+ }
16795
+ };
16796
+ }
16797
+ function observeOnscreen(win, host, onChange) {
16798
+ const Observer = win.IntersectionObserver;
16799
+ if (!Observer) return null;
16800
+ const observer = new Observer((entries) => {
16801
+ for (const entry of entries) onChange(entry.isIntersecting);
16802
+ });
16803
+ observer.observe(host);
16804
+ return observer;
16805
+ }
16806
+ function runAutoDrag(options, finish) {
16807
+ const win = options.ctx.win;
16808
+ const plan = sweepPlan(options);
16809
+ let elapsed = 0;
16810
+ let lastFrameAt = null;
16811
+ let frame = 0;
16812
+ let done = false;
16813
+ let onscreen = false;
16814
+ const update = () => {
16815
+ const active = !done && onscreen;
16816
+ if (active && frame === 0) {
16817
+ lastFrameAt = null;
16818
+ frame = win.requestAnimationFrame(tick);
16819
+ } else if (!active && frame !== 0) {
16820
+ win.cancelAnimationFrame(frame);
16821
+ frame = 0;
16822
+ }
16823
+ };
16824
+ function tick(now) {
16825
+ frame = 0;
16826
+ if (lastFrameAt !== null) elapsed += Math.min(now - lastFrameAt, MAX_FRAME_MS2);
16827
+ lastFrameAt = now;
16828
+ if (plan.show(elapsed)) frame = win.requestAnimationFrame(tick);
16829
+ else finish();
16830
+ }
16831
+ return {
16832
+ setOnscreen(next) {
16833
+ onscreen = next;
16834
+ update();
16835
+ },
16836
+ stop() {
16837
+ done = true;
16838
+ update();
16839
+ }
16840
+ };
16841
+ }
16842
+ function buildPair({ el, media, params, ctx, axis, timing: timing3 }) {
15043
16843
  const initialPercent = Math.round(params.num("position", 0.5) * 100);
15044
16844
  const style = createStyleLedger(el);
15045
16845
  style.set("--kui-compare", params.text("position", "50%"));
15046
- const { range, handle } = createCompareElements(
15047
- el.ownerDocument,
15048
- resolveCompareLabel(el, media),
16846
+ const doc = el.ownerDocument;
16847
+ const range = createRange(
16848
+ doc,
16849
+ { name: resolveCompareLabel(el, media), valueText: `${initialPercent}% after` },
15049
16850
  initialPercent,
15050
16851
  axis
15051
16852
  );
16853
+ const handle = createHandle(doc);
15052
16854
  const onInput = () => {
15053
16855
  const val = range.value;
15054
16856
  style.set("--kui-compare", `${val}%`);
15055
16857
  range.setAttribute("aria-valuetext", `${val}% after`);
15056
16858
  };
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();
16859
+ const writeAuto = (_index, percent) => {
16860
+ range.value = String(Math.round(percent));
16861
+ style.set("--kui-compare", `${Number(percent.toFixed(3))}%`);
16862
+ range.setAttribute("aria-valuetext", `${range.value}% after`);
15064
16863
  };
16864
+ const onKeyDown = verticalKeys(range, axis, onInput);
15065
16865
  range.addEventListener("input", onInput);
15066
16866
  range.addEventListener("keydown", onKeyDown);
15067
16867
  el.appendChild(range);
15068
16868
  el.appendChild(handle);
15069
- return continuousSetup(() => {
16869
+ const stopAutoDrag = bindAutoDrag({
16870
+ ...timing3,
16871
+ host: el,
16872
+ grab: [range],
16873
+ ctx,
16874
+ write: writeAuto,
16875
+ homes: [params.num("position", 0.5) * 100],
16876
+ bounds: [[0, 100]]
16877
+ });
16878
+ return () => {
16879
+ stopAutoDrag();
15070
16880
  range.removeEventListener("input", onInput);
15071
16881
  range.removeEventListener("keydown", onKeyDown);
15072
16882
  range.remove();
15073
16883
  handle.remove();
15074
- attrs.restore();
15075
16884
  style.restore();
16885
+ };
16886
+ }
16887
+ var PERCENT_TOKEN = /^(\d+(?:\.\d+)?)%$/;
16888
+ function resolvePositions(authored, count, warn) {
16889
+ const even = Array.from({ length: count }, (_, k) => Math.round(100 * (k + 1) / (count + 1)));
16890
+ if (authored.trim() === "") return even;
16891
+ const tokens = authored.trim().split(/\s+/);
16892
+ const values = tokens.map((token) => PERCENT_TOKEN.exec(token)?.[1]);
16893
+ const numbers = values.map(Number);
16894
+ const usable2 = tokens.length === count && values.every((value) => value !== void 0) && numbers.every((value, k) => value <= 100 && (k === 0 || value >= numbers[k - 1]));
16895
+ if (usable2) return numbers.map(Math.round);
16896
+ const example = even.map((value) => String(value) + "%").join(" ");
16897
+ warn(
16898
+ `compare positions: expected ${count} ascending percentages between 0% and 100% for ${count + 1} media (e.g. positions:'${example}'); using an even split`
16899
+ );
16900
+ return even;
16901
+ }
16902
+ function buildMulti({ el, media, params, ctx, axis, timing: timing3 }) {
16903
+ if (params.text("position", "50%") !== "50%") {
16904
+ ctx.warn(`compare position: sets the one divider of two media; with ${media.length}, use positions:`);
16905
+ }
16906
+ const positions = resolvePositions(params.text("positions", ""), media.length - 1, ctx.warn);
16907
+ const homes = [...positions];
16908
+ const attrs = createAttributeLedger(el);
16909
+ attrs.set(COMPARE_DIVIDERS_ATTR, String(homes.length));
16910
+ const dividers = createDividers(el, media, axis, positions);
16911
+ const surface = bindSurface(el, axis, dividers);
16912
+ const stopAutoDrag = bindAutoDrag({
16913
+ ...timing3,
16914
+ host: el,
16915
+ grab: [surface.node, ...dividers.ranges],
16916
+ ctx,
16917
+ write: dividers.render,
16918
+ homes,
16919
+ bounds: homes.map((_, k) => boundsOf(homes, k))
16920
+ });
16921
+ return () => {
16922
+ stopAutoDrag();
16923
+ surface.destroy();
16924
+ dividers.destroy();
16925
+ attrs.restore();
16926
+ };
16927
+ }
16928
+ function boundsOf(positions, k) {
16929
+ return [k > 0 ? positions[k - 1] : 0, k < positions.length - 1 ? positions[k + 1] : 100];
16930
+ }
16931
+ function dividerLabel(el, media, k) {
16932
+ const count = media.length - 1;
16933
+ const authored = el.getAttribute("aria-label")?.trim();
16934
+ if (authored) return `${authored}: divider ${k + 1} of ${count}`;
16935
+ const before = getMediaName(media[k], `Image ${k + 1}`);
16936
+ const after = getMediaName(media[k + 1], `Image ${k + 2}`);
16937
+ return `Divider ${k + 1} of ${count}: ${before} / ${after}`;
16938
+ }
16939
+ function createDividers(el, media, axis, positions) {
16940
+ const doc = el.ownerDocument;
16941
+ const count = positions.length;
16942
+ const clip = axis === "y" ? "inset(var(--kui-compare) 0 0 0)" : "inset(0 0 0 var(--kui-compare))";
16943
+ const mediaStyles = media.slice(1).map((item) => createStyleLedger(item));
16944
+ for (const ledger of mediaStyles) ledger.set("clip-path", clip);
16945
+ const ranges = [];
16946
+ const handles = [];
16947
+ const cleanups = [];
16948
+ const render = (k, shown) => {
16949
+ positions[k] = Math.round(shown);
16950
+ const css = `${Number(shown.toFixed(3))}%`;
16951
+ handles[k].style.setProperty("--kui-compare", css);
16952
+ mediaStyles[k].set("--kui-compare", css);
16953
+ ranges[k].value = String(positions[k]);
16954
+ ranges[k].setAttribute("aria-valuetext", `${positions[k]}%`);
16955
+ if (k > 0) ranges[k - 1].max = String(positions[k]);
16956
+ if (k < count - 1) ranges[k + 1].min = String(positions[k]);
16957
+ };
16958
+ for (let k = 0; k < count; k++) {
16959
+ const range = createRange(doc, { name: dividerLabel(el, media, k), valueText: `${positions[k]}%` }, positions[k], axis);
16960
+ const handle = createHandle(doc);
16961
+ ranges.push(range);
16962
+ handles.push(handle);
16963
+ el.append(range, handle);
16964
+ const onInput = () => render(k, Number(range.value));
16965
+ const onKeyDown = verticalKeys(range, axis, onInput);
16966
+ range.addEventListener("input", onInput);
16967
+ range.addEventListener("keydown", onKeyDown);
16968
+ cleanups.push(() => {
16969
+ range.removeEventListener("input", onInput);
16970
+ range.removeEventListener("keydown", onKeyDown);
16971
+ });
16972
+ }
16973
+ for (let k = 0; k < count; k++) {
16974
+ const [lo, hi] = boundsOf(positions, k);
16975
+ ranges[k].min = String(lo);
16976
+ ranges[k].max = String(hi);
16977
+ render(k, positions[k]);
16978
+ }
16979
+ return {
16980
+ ranges,
16981
+ render,
16982
+ moveTo(k, percent) {
16983
+ const [lo, hi] = boundsOf(positions, k);
16984
+ render(k, Math.min(hi, Math.max(lo, percent)));
16985
+ },
16986
+ nearest: (percent) => nearestDivider(positions, percent),
16987
+ destroy() {
16988
+ for (const cleanup of cleanups) cleanup();
16989
+ for (const node of [...ranges, ...handles]) node.remove();
16990
+ for (const ledger of mediaStyles) ledger.restore();
16991
+ }
16992
+ };
16993
+ }
16994
+ function nearestDivider(positions, percent) {
16995
+ let best = 0;
16996
+ for (let k = 1; k < positions.length; k++) {
16997
+ const distance3 = Math.abs(positions[k] - percent);
16998
+ const bestDistance = Math.abs(positions[best] - percent);
16999
+ if (distance3 < bestDistance || distance3 === bestDistance && percent >= positions[k]) best = k;
17000
+ }
17001
+ return best;
17002
+ }
17003
+ function bindSurface(el, axis, dividers) {
17004
+ const surface = el.ownerDocument.createElement("div");
17005
+ surface.className = "kui-compare-surface";
17006
+ surface.setAttribute("aria-hidden", "true");
17007
+ el.appendChild(surface);
17008
+ let dragging = -1;
17009
+ let pointerId = null;
17010
+ const percentAt = (event) => {
17011
+ const rect = el.getBoundingClientRect();
17012
+ const along = axis === "y" ? event.clientY - rect.top : event.clientX - rect.left;
17013
+ const size = axis === "y" ? rect.height : rect.width;
17014
+ return size > 0 ? 100 * along / size : 0;
17015
+ };
17016
+ const onPointerDown = (event) => {
17017
+ if (event.button > 0 || pointerId !== null) return;
17018
+ pointerId = event.pointerId;
17019
+ const percent = percentAt(event);
17020
+ dragging = dividers.nearest(percent);
17021
+ event.preventDefault();
17022
+ surface.setPointerCapture?.(event.pointerId);
17023
+ dividers.ranges[dragging].focus({ preventScroll: true });
17024
+ dividers.moveTo(dragging, percent);
17025
+ };
17026
+ const onPointerMove = (event) => {
17027
+ if (dragging >= 0 && event.pointerId === pointerId) dividers.moveTo(dragging, percentAt(event));
17028
+ };
17029
+ const onPointerUp = (event) => {
17030
+ if (event.pointerId !== pointerId) return;
17031
+ dragging = -1;
17032
+ pointerId = null;
17033
+ };
17034
+ const onLostCapture = (event) => {
17035
+ if (event.target !== surface) return;
17036
+ onPointerUp(event);
17037
+ };
17038
+ const listeners = [
17039
+ ["pointerdown", onPointerDown],
17040
+ ["pointermove", onPointerMove],
17041
+ ["pointerup", onPointerUp],
17042
+ ["pointercancel", onPointerUp],
17043
+ ["lostpointercapture", onLostCapture]
17044
+ ];
17045
+ for (const [type, listener] of listeners) surface.addEventListener(type, listener);
17046
+ return {
17047
+ node: surface,
17048
+ destroy() {
17049
+ for (const [type, listener] of listeners) surface.removeEventListener(type, listener);
17050
+ surface.remove();
17051
+ }
17052
+ };
17053
+ }
17054
+ function prepareCompare(el, params, ctx) {
17055
+ const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
17056
+ if (media.length < 2) {
17057
+ ctx.warn("compare requires at least two media children (img, picture, or video)");
17058
+ }
17059
+ const axis = params.text("axis", "x");
17060
+ const attrs = createAttributeLedger(el);
17061
+ attrs.set(COMPARE_AXIS_ATTR, axis);
17062
+ const build2 = media.length > 2 ? buildMulti : buildPair;
17063
+ const teardown = build2({
17064
+ el,
17065
+ media,
17066
+ params,
17067
+ ctx,
17068
+ axis,
17069
+ timing: {
17070
+ mode: params.text("autoplay", "never"),
17071
+ durationMs: sweepDuration(params, ctx),
17072
+ reverse: params.is("reverse"),
17073
+ loop: params.is("loop")
17074
+ }
17075
+ });
17076
+ return continuousSetup(() => {
17077
+ teardown();
17078
+ attrs.restore();
15076
17079
  });
15077
17080
  }
15078
17081
  var COMPARE_PRIMITIVE = widgetPrimitive(
15079
17082
  "compare",
15080
17083
  {
15081
- channels: ["clip", "widget"],
17084
+ // The layers it stacks and the range, handle and surface it appends; the axis and divider
17085
+ // markers it writes on the host. Not `clip`: its `clip-path` is on the after layer, never the
17086
+ // host, so a `wipe-up` revealing the whole slider is not a fight over one property.
17087
+ channels: [SUBTREE_CHANNEL, attributeChannel(COMPARE_AXIS_ATTR), attributeChannel(COMPARE_DIVIDERS_ATTR)],
15082
17088
  parameters: compareParams,
15083
17089
  perfClass: "paint"
15084
17090
  },
15085
17091
  withTimingContract(
15086
17092
  "compare",
15087
17093
  {
15088
- because: "it builds an interactive before/after slider; there is no motion to time"
17094
+ honours: ["duration"],
17095
+ 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
17096
  },
15090
17097
  deferPrepare(prepareCompare)
15091
17098
  )
@@ -15259,7 +17266,8 @@ var kuinetic = (() => {
15259
17266
  var HOTSPOTS_PRIMITIVE = widgetPrimitive(
15260
17267
  "hotspots",
15261
17268
  {
15262
- channels: ["widget"],
17269
+ // The marker buttons it appends, placed in percent of the host, and the marker style it writes.
17270
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-hotspot-marker")],
15263
17271
  parameters: hotspotsParams,
15264
17272
  perfClass: "layout"
15265
17273
  },
@@ -15463,7 +17471,7 @@ var kuinetic = (() => {
15463
17471
  hovered = true;
15464
17472
  update();
15465
17473
  };
15466
- const leave = () => {
17474
+ const leave2 = () => {
15467
17475
  hovered = false;
15468
17476
  update();
15469
17477
  };
@@ -15480,7 +17488,7 @@ var kuinetic = (() => {
15480
17488
  update();
15481
17489
  };
15482
17490
  el.addEventListener("pointerenter", enter);
15483
- el.addEventListener("pointerleave", leave);
17491
+ el.addEventListener("pointerleave", leave2);
15484
17492
  el.addEventListener("focusin", focus);
15485
17493
  el.addEventListener("focusout", blur);
15486
17494
  ctx.doc.addEventListener("visibilitychange", update);
@@ -15489,7 +17497,7 @@ var kuinetic = (() => {
15489
17497
  return () => {
15490
17498
  if (timer !== null) clearTimeout(timer);
15491
17499
  el.removeEventListener("pointerenter", enter);
15492
- el.removeEventListener("pointerleave", leave);
17500
+ el.removeEventListener("pointerleave", leave2);
15493
17501
  el.removeEventListener("focusin", focus);
15494
17502
  el.removeEventListener("focusout", blur);
15495
17503
  ctx.doc.removeEventListener("visibilitychange", update);
@@ -15555,7 +17563,6 @@ var kuinetic = (() => {
15555
17563
  el,
15556
17564
  params,
15557
17565
  ctx,
15558
- scope,
15559
17566
  resolveSteps,
15560
17567
  name: "slideshow",
15561
17568
  clickFallback: false,
@@ -15583,7 +17590,19 @@ var kuinetic = (() => {
15583
17590
  }
15584
17591
  var SLIDESHOW_PRIMITIVE = widgetPrimitive(
15585
17592
  "slideshow",
15586
- { channels: ["widget"], parameters: slideshowParams, perfClass: "layout" },
17593
+ // The controls it inserts, then what it writes on the host: `createStepIndex`'s `data-kui-step`,
17594
+ // as every step deck does, and `decorateHost`'s mode and carousel roles.
17595
+ {
17596
+ channels: [
17597
+ SUBTREE_CHANNEL,
17598
+ attributeChannel("data-kui-step"),
17599
+ attributeChannel(SLIDE_MODE_ATTR),
17600
+ attributeChannel("role"),
17601
+ attributeChannel("aria-roledescription")
17602
+ ],
17603
+ parameters: slideshowParams,
17604
+ perfClass: "layout"
17605
+ },
15587
17606
  withTimingContract(
15588
17607
  "slideshow",
15589
17608
  { honours: ["duration", "ease"], because: "the slideshow has no delayed start" },
@@ -15745,7 +17764,14 @@ var kuinetic = (() => {
15745
17764
  }
15746
17765
  var SCROLL_STORY_PRIMITIVE = widgetPrimitive(
15747
17766
  "scroll-story",
15748
- { channels: ["widget"], parameters: scrollStoryParams, perfClass: "layout" },
17767
+ // Its stylesheet lays out the host's first and last child, so nothing else may insert one. The
17768
+ // step index on the host (`applyStep`) is claimed so a `carousel` or `scroll-progress` beside it
17769
+ // is refused rather than left to overwrite it.
17770
+ {
17771
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-step"), attributeChannel(STORY_SIDE_ATTR)],
17772
+ parameters: scrollStoryParams,
17773
+ perfClass: "layout"
17774
+ },
15749
17775
  withTimingContract(
15750
17776
  "scroll-story",
15751
17777
  { because: "scroll position" },
@@ -15848,7 +17874,8 @@ var kuinetic = (() => {
15848
17874
  }
15849
17875
  var SLOW_MO_PRIMITIVE = widgetPrimitive(
15850
17876
  "slow-mo",
15851
- { channels: ["widget"], parameters: slowMoParams, perfClass: "compositor" },
17877
+ // The toggle it prepends into the host, and the time scale it writes there.
17878
+ { channels: [SUBTREE_CHANNEL, attributeChannel(TIME_SCALE_ATTR)], parameters: slowMoParams, perfClass: "compositor" },
15852
17879
  withTimingContract(
15853
17880
  "slow-mo",
15854
17881
  { because: "it changes playback rate rather than defining an animation timeline" },
@@ -15877,6 +17904,7 @@ var kuinetic = (() => {
15877
17904
  ...SLOW_MO_PRESETS
15878
17905
  ];
15879
17906
  function registerShowcase(target) {
17907
+ provideDeckViewer(attachDeckLightbox);
15880
17908
  return registerInto(target, SHOWCASE_PRIMITIVES, SHOWCASE_PRESETS, "Showcase");
15881
17909
  }
15882
17910
 
@@ -15905,6 +17933,16 @@ var kuinetic = (() => {
15905
17933
  return new Animator({ registry: createRegistry(), ...options });
15906
17934
  }
15907
17935
  var src_default = kuinetic;
17936
+ var catalog;
17937
+ function describe2(name, options = {}) {
17938
+ return describeEffect(options.registry ?? (catalog ??= createRegistry()), name, options.notes);
17939
+ }
17940
+ function describeSteps(value, options = {}) {
17941
+ return describeAttribute(options.registry ?? (catalog ??= createRegistry()), value, options.notes);
17942
+ }
17943
+ function describeElement(value, options = {}) {
17944
+ return describeElementAttribute(options.registry ?? (catalog ??= createRegistry()), value, options.notes);
17945
+ }
15908
17946
  return __toCommonJS(src_exports);
15909
17947
  })();
15910
17948