kuinetic 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +15 -8
  2. package/dist/esm/advanced/index.mjs +5 -5
  3. package/dist/esm/{chunk-U43BX5O2.mjs → chunk-FASAEQDR.mjs} +54 -7
  4. package/dist/esm/{chunk-BN2Y4UKB.mjs → chunk-FYMZW5N2.mjs} +2373 -541
  5. package/dist/esm/{chunk-RVTYN6NZ.mjs → chunk-MZQG2VMG.mjs} +208 -2
  6. package/dist/esm/core/index.mjs +21 -3
  7. package/dist/esm/effects/index.mjs +2 -2
  8. package/dist/esm/index.mjs +35 -4
  9. package/dist/esm/notes/index.mjs +1075 -0
  10. package/dist/kuinetic.advanced.js +63 -25
  11. package/dist/kuinetic.advanced.min.js +3 -3
  12. package/dist/kuinetic.all.js +2658 -538
  13. package/dist/kuinetic.all.min.js +2 -2
  14. package/dist/kuinetic.css +238 -65
  15. package/dist/kuinetic.js +2657 -537
  16. package/dist/kuinetic.min.css +1 -1
  17. package/dist/kuinetic.min.js +2 -2
  18. package/dist/kuinetic.notes.js +1 -0
  19. package/dist/types/3d/flattening.d.ts +8 -7
  20. package/dist/types/advanced/camera-3d.d.ts +0 -4
  21. package/dist/types/advanced/fluid-cursor.d.ts +0 -4
  22. package/dist/types/advanced/particles.d.ts +0 -4
  23. package/dist/types/advanced/scenes.d.ts +0 -4
  24. package/dist/types/browser/boot.d.ts +9 -4
  25. package/dist/types/core/activation.d.ts +11 -0
  26. package/dist/types/core/compile.d.ts +7 -0
  27. package/dist/types/core/deck-viewer.d.ts +87 -0
  28. package/dist/types/core/derived/types.d.ts +0 -1
  29. package/dist/types/core/describe.d.ts +174 -0
  30. package/dist/types/core/gesture.d.ts +16 -1
  31. package/dist/types/core/index.d.ts +6 -2
  32. package/dist/types/core/stagger.d.ts +1 -1
  33. package/dist/types/core/target.d.ts +28 -0
  34. package/dist/types/core/timing-contract.d.ts +61 -0
  35. package/dist/types/core/types.d.ts +59 -0
  36. package/dist/types/effects/auto-motion.d.ts +169 -0
  37. package/dist/types/effects/carousel/deck.d.ts +170 -0
  38. package/dist/types/effects/carousel/drag.d.ts +6 -0
  39. package/dist/types/effects/carousel/fit.d.ts +130 -0
  40. package/dist/types/effects/carousel/index.d.ts +6 -23
  41. package/dist/types/effects/carousel/stack.d.ts +77 -0
  42. package/dist/types/effects/catalog/interaction-reveal.d.ts +69 -9
  43. package/dist/types/effects/forms/primitives.d.ts +1 -2
  44. package/dist/types/effects/gestures/index.d.ts +3 -4
  45. package/dist/types/effects/shared.d.ts +3 -30
  46. package/dist/types/effects/step-index.d.ts +48 -10
  47. package/dist/types/effects/swipe-event.d.ts +75 -0
  48. package/dist/types/index.d.ts +26 -0
  49. package/dist/types/notes/catalog-core.d.ts +3 -0
  50. package/dist/types/notes/catalog.d.ts +3 -0
  51. package/dist/types/notes/index.d.ts +13 -0
  52. package/dist/types/notes/interaction-reveal.d.ts +3 -0
  53. package/dist/types/notes/interaction.d.ts +6 -0
  54. package/dist/types/notes/keys.d.ts +7 -0
  55. package/dist/types/notes/mechanics.d.ts +7 -0
  56. package/dist/types/notes/scroll-nav-forms.d.ts +6 -0
  57. package/dist/types/notes/shared.d.ts +6 -0
  58. package/dist/types/notes/showcase.d.ts +7 -0
  59. package/dist/types/notes/text.d.ts +3 -0
  60. package/dist/types/showcase/compare.d.ts +9 -0
  61. package/dist/types/showcase/index.d.ts +3 -0
  62. package/dist/types/showcase/lightbox.d.ts +23 -1
  63. package/dist/types/showcase/modal-shell.d.ts +11 -0
  64. package/package.json +10 -4
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() {
@@ -589,18 +605,25 @@ var kuinetic = (() => {
589
605
  if (typeof IntersectionObserver === "undefined") return void 0;
590
606
  return (callback, init) => new IntersectionObserver(callback, init);
591
607
  }
608
+ function onlyBoundLate(name, claims) {
609
+ const authorising = authorisingActivations(name);
610
+ if (!authorising.includes("enter")) return false;
611
+ const suits = (claim2) => authorising.some((named) => claim2.supported.includes(named));
612
+ if (!claims.some((claim2) => claim2.entrance && suits(claim2))) return false;
613
+ return claims.every((claim2) => suits(claim2) || claim2.supported.includes("load"));
614
+ }
592
615
  function warnAboutActivation(request) {
593
616
  warnUnsupported(request);
594
617
  warnUnknownEvents(request);
595
618
  }
596
- function warnUnsupported({ el, spec, supported, reporter }) {
597
- if (supported.length === 0) return;
619
+ function warnUnsupported({ el, spec, supported, claims, reporter }) {
620
+ const declaredAny = supported.length > 0 || (claims?.some((claim2) => claim2.supported.length > 0) ?? false);
621
+ if (!declaredAny) return;
622
+ const shared = supported.length > 0 ? supported.join(", ") : "none in common across the composed effects";
598
623
  for (const name of spec.names) {
599
624
  if (authorisingActivations(name).some((named) => supported.includes(named))) continue;
600
- reporter.warn(
601
- `activation "${name}" is not supported by this effect (supports: ${supported.join(", ")})`,
602
- el
603
- );
625
+ if (claims && onlyBoundLate(name, claims)) continue;
626
+ reporter.warn(`activation "${name}" is not supported by this effect (supports: ${shared})`, el);
604
627
  }
605
628
  }
606
629
  function warnUnknownEvents({ el, spec, reporter }) {
@@ -1248,6 +1271,10 @@ var kuinetic = (() => {
1248
1271
  */
1249
1272
  skew: "skew"
1250
1273
  };
1274
+ function attributeChannel(attribute) {
1275
+ return `attr:${attribute}`;
1276
+ }
1277
+ var SUBTREE_CHANNEL = "subtree";
1251
1278
  function inertInstance(destroy = () => {
1252
1279
  }) {
1253
1280
  return {
@@ -3427,6 +3454,7 @@ var kuinetic = (() => {
3427
3454
  unknown,
3428
3455
  reducedMotion: "shorten",
3429
3456
  supportedActivations: [],
3457
+ activationClaims: [],
3430
3458
  supportedTimelines: [],
3431
3459
  channels: [],
3432
3460
  warnings
@@ -3560,6 +3588,10 @@ var kuinetic = (() => {
3560
3588
  plan.fxNames.push(preset.name);
3561
3589
  plan.reducedMotion = strictestPolicy(plan.reducedMotion, primitive.reducedMotion);
3562
3590
  activations = intersect(activations, primitive.supportedActivations);
3591
+ plan.activationClaims.push({
3592
+ supported: primitive.supportedActivations,
3593
+ entrance: phaseOf(entry) === "entrance"
3594
+ });
3563
3595
  timelines = intersect(timelines, primitive.supportedTimelines);
3564
3596
  for (const channel of channelsFor(entry)) channels.add(channel);
3565
3597
  warnUnsupportedTimeline(preset.name, primitive.supportedTimelines, timeline, warnings);
@@ -5125,11 +5157,20 @@ var kuinetic = (() => {
5125
5157
  const windowRoot = windowScrollRoot(win);
5126
5158
  return (el) => {
5127
5159
  for (let node = el.parentElement; node; node = node.parentElement) {
5160
+ if (scrollsTheViewport(node, win)) return windowRoot;
5128
5161
  if (isScrollable(node)) return elementScrollRoot(node, win);
5129
5162
  }
5130
5163
  return windowRoot;
5131
5164
  };
5132
5165
  }
5166
+ function scrollsTheViewport(node, win) {
5167
+ const root = win.document.documentElement;
5168
+ if (node === root) return true;
5169
+ if (node !== win.document.body) return false;
5170
+ const rootStyle = win.getComputedStyle(root);
5171
+ return !CLIPPING_OVERFLOW.has(rootStyle.overflowX) && !CLIPPING_OVERFLOW.has(rootStyle.overflowY);
5172
+ }
5173
+ var CLIPPING_OVERFLOW = /* @__PURE__ */ new Set(["auto", "scroll", "overlay", "hidden", "clip"]);
5133
5174
  var SCROLLABLE_OVERFLOW = /* @__PURE__ */ new Set(["auto", "scroll", "overlay"]);
5134
5175
  function defaultIsScrollable(win) {
5135
5176
  return (el) => {
@@ -5632,6 +5673,7 @@ var kuinetic = (() => {
5632
5673
  el,
5633
5674
  spec: resolveActivationSpec(config.activation),
5634
5675
  supported: plan.supportedActivations,
5676
+ claims: plan.activationClaims,
5635
5677
  reporter: this.reporter
5636
5678
  });
5637
5679
  return config.activation;
@@ -6517,8 +6559,8 @@ var kuinetic = (() => {
6517
6559
  shouldObserve: options.observe ?? false
6518
6560
  };
6519
6561
  }
6520
- function resolveJsEffectPreparer(provided, deps) {
6521
- return provided ?? createJsEffectPreparer(deps);
6562
+ function resolveJsEffectPreparer(provided2, deps) {
6563
+ return provided2 ?? createJsEffectPreparer(deps);
6522
6564
  }
6523
6565
  function defaultRootResolver(root) {
6524
6566
  const doc = isElementNode(root) ? root.ownerDocument : root;
@@ -6535,11 +6577,214 @@ var kuinetic = (() => {
6535
6577
  }
6536
6578
  }
6537
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
+
6538
6784
  // src/effects/shared.ts
6539
6785
  var TRIGGER_DELAY_PARAM = {
6540
6786
  delay: { type: "time", default: "0ms", cssProperty: "--kui-delay" }
6541
6787
  };
6542
- var ALL_TIMING_TOKENS = ["duration", "delay", "ease"];
6543
6788
  function authoredTiming(params, token) {
6544
6789
  if (token === "ease") return params.timing.easing;
6545
6790
  const ms = token === "duration" ? params.timing.durationMs : params.timing.delayMs;
@@ -6567,20 +6812,20 @@ var kuinetic = (() => {
6567
6812
  }
6568
6813
  }
6569
6814
  function withTimingContract(id, contract, prepare) {
6570
- return (el, params, ctx) => {
6815
+ return declareTimingContract((el, params, ctx) => {
6571
6816
  warnUnhonouredTiming(id, contract, params, ctx.warn);
6572
6817
  return prepare(el, params, ctx);
6573
- };
6818
+ }, contract);
6574
6819
  }
6575
6820
  function stylesheetTimingPrepare(id, contract) {
6576
- return (el, params, ctx) => {
6821
+ return declareTimingContract((el, params, ctx) => {
6577
6822
  warnUnhonouredTiming(id, contract, params, ctx.warn);
6578
6823
  return deferredInstance(() => {
6579
6824
  mirrorTimingToCss(id, contract.honours ?? [], params, ctx);
6580
6825
  return () => {
6581
6826
  };
6582
6827
  });
6583
- };
6828
+ }, contract);
6584
6829
  }
6585
6830
  var TIMELINE_AGNOSTIC = ["time", "view", "scroll", "pin"];
6586
6831
  var COMMON = {
@@ -6657,12 +6902,12 @@ var kuinetic = (() => {
6657
6902
  * What the ring rules actually write (`ambient.css`) beyond the gradient: `mask` +
6658
6903
  * `mask-composite`, which subtract the element's own content box to leave a ring — the same
6659
6904
  * physical property `media-mask` claims under the `'mask'` channel — and `position: relative`
6660
- * plus a `padding` that *is* the ring's thickness, which is a claim on the host's box in the
6661
- * sense `pin` and `background-media` already use `'layout'` for. Declared only as
6662
- * `background`, a `gradient-border, pin-section` pair composed silently while both decided the
6663
- * 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`.
6664
6909
  */
6665
- cssPrimitive("ambient-gradient-ring", [CHANNEL.background, "mask", "layout"], {
6910
+ cssPrimitive("ambient-gradient-ring", [CHANNEL.background, "mask", "position", "padding"], {
6666
6911
  parameters: drift,
6667
6912
  defaultActivation: "load",
6668
6913
  reducedMotion: "disable",
@@ -7340,22 +7585,287 @@ var kuinetic = (() => {
7340
7585
  var STATE_PRIMITIVES = [...PRESS_PRIMITIVES, ...GROUP_PRIMITIVES];
7341
7586
  var STATE_PRESETS = [...PRESS_PRESETS, ...GROUP_PRESETS];
7342
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
+
7343
7634
  // src/effects/catalog/interaction-reveal.ts
7344
- 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
+ };
7345
7640
  return {
7346
7641
  id,
7347
7642
  renderer: "javascript",
7348
- 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,
7349
7645
  parameters,
7350
7646
  supportedTimelines: ["time"],
7351
7647
  supportedActivations: ["load"],
7352
7648
  defaultActivation: "load",
7353
7649
  perfClass,
7354
7650
  reducedMotion: "shorten",
7355
- prepare: stylesheetTimingPrepare(id, {
7356
- honours: ALL_TIMING_TOKENS,
7357
- because: "interaction.css pins that value on this effect"
7358
- })
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
+ }
7359
7869
  };
7360
7870
  }
7361
7871
  var labelSwapParams = {
@@ -7388,10 +7898,34 @@ var kuinetic = (() => {
7388
7898
  duration: { type: "time", default: "160ms", cssProperty: "--kui-duration" },
7389
7899
  delay: { type: "time", default: "1000ms", cssProperty: "--kui-delay" },
7390
7900
  ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" },
7391
- 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" }
7392
7917
  };
7393
7918
  var HOVER_INTENT_PRIMITIVES = [
7394
- 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
+ })
7395
7929
  ];
7396
7930
  var HOVER_INTENT_PRESETS = [
7397
7931
  { name: "hover-intent", phase: "state", primitive: "hover-intent" }
@@ -7409,16 +7943,65 @@ var kuinetic = (() => {
7409
7943
  finite: true,
7410
7944
  minimum: 0,
7411
7945
  maximum: 1
7412
- }
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" }
7413
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
+ }
7414
7973
  var ANCHORED_PREVIEW_PRIMITIVES = [
7415
- 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
+ })
7416
7984
  ];
7417
7985
  var ANCHORED_PREVIEW_PRESETS = [
7418
7986
  { name: "anchored-preview", phase: "state", primitive: "anchored-preview" },
7419
- { name: "anchored-preview-bottom", phase: "state", primitive: "anchored-preview" },
7420
- { name: "anchored-preview-left", phase: "state", primitive: "anchored-preview" },
7421
- { 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
+ }
7422
8005
  ];
7423
8006
  var searchExpandParams = {
7424
8007
  duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
@@ -7436,7 +8019,7 @@ var kuinetic = (() => {
7436
8019
  "search-expand",
7437
8020
  ["expand", "discrete", "search-field"],
7438
8021
  searchExpandParams,
7439
- "layout"
8022
+ { perfClass: "layout" }
7440
8023
  )
7441
8024
  ];
7442
8025
  var SEARCH_EXPAND_PRESETS = [
@@ -7852,7 +8435,10 @@ var kuinetic = (() => {
7852
8435
  label: { type: "text", default: "", cssProperty: "--kui-label" }
7853
8436
  };
7854
8437
  var POINTER_PRIMITIVES = [
7855
- 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)),
7856
8442
  pointerPrimitive("tilt-parallax", ["translate"], parallaxParams, deferPrepare(prepareTiltParallax)),
7857
8443
  pointerPrimitive("cursor-follow", ["translate"], cursorDotParams, deferPrepare(prepareCursorFollow)),
7858
8444
  pointerPrimitive("cursor-lag", ["translate"], cursorDotParams, deferPrepare(prepareCursorLag)),
@@ -7861,7 +8447,13 @@ var kuinetic = (() => {
7861
8447
  // `pseudo-before`: the glow overlay is a `::before` rule in `interaction.css`, so this primitive
7862
8448
  // owns that box the same way the border family does. See `border-draw`'s comment in
7863
8449
  // `HOVER_PRIMITIVES` for what the token means and why it was added here in the same change.
7864
- 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
+ )
7865
8457
  ];
7866
8458
  function prepareCursorFollow(el, params, ctx) {
7867
8459
  return prepareCursorDot(el, params, ctx, "kui-cursor-dot-follow");
@@ -8341,52 +8933,6 @@ var kuinetic = (() => {
8341
8933
  };
8342
8934
  }
8343
8935
 
8344
- // src/core/element-size.ts
8345
- function watchElementSize(win, onChange) {
8346
- const ResizeObserverCtor = win.ResizeObserver;
8347
- const observer = ResizeObserverCtor ? new ResizeObserverCtor(onChange) : void 0;
8348
- const elements = /* @__PURE__ */ new Set();
8349
- win.addEventListener("resize", onChange, { passive: true });
8350
- return {
8351
- observe(element) {
8352
- if (elements.has(element)) return;
8353
- elements.add(element);
8354
- observer?.observe(element);
8355
- },
8356
- unobserve(element) {
8357
- elements.delete(element);
8358
- observer?.unobserve(element);
8359
- },
8360
- disconnect() {
8361
- win.removeEventListener("resize", onChange);
8362
- observer?.disconnect();
8363
- elements.clear();
8364
- }
8365
- };
8366
- }
8367
- function frameScheduler(win, callback) {
8368
- let frame;
8369
- let timeout;
8370
- const run = () => {
8371
- frame = void 0;
8372
- timeout = void 0;
8373
- callback();
8374
- };
8375
- return {
8376
- request() {
8377
- if (frame !== void 0 || timeout !== void 0) return;
8378
- if (win.requestAnimationFrame) frame = win.requestAnimationFrame(run);
8379
- else timeout = win.setTimeout(run, 16);
8380
- },
8381
- cancel() {
8382
- if (frame !== void 0) win.cancelAnimationFrame(frame);
8383
- if (timeout !== void 0) win.clearTimeout(timeout);
8384
- frame = void 0;
8385
- timeout = void 0;
8386
- }
8387
- };
8388
- }
8389
-
8390
8936
  // src/effects/catalog/media-shared.ts
8391
8937
  var AXIS_DEGREES = { vertical: 0, horizontal: 90 };
8392
8938
  var UNIT_DEGREES = { deg: 1, d: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 };
@@ -8745,14 +9291,14 @@ var kuinetic = (() => {
8745
9291
  settle2 = resolve;
8746
9292
  });
8747
9293
  let landed = false;
8748
- const land = () => {
9294
+ const land2 = () => {
8749
9295
  if (landed) return;
8750
9296
  landed = true;
8751
9297
  stage.classList.remove("kui-slat-animating");
8752
9298
  built.restore();
8753
9299
  settle2();
8754
9300
  };
8755
- const timer = ctx.win.setTimeout(land, slatAssembleFinishMs(params, count));
9301
+ const timer = ctx.win.setTimeout(land2, slatAssembleFinishMs(params, count));
8756
9302
  return {
8757
9303
  cleanup: () => {
8758
9304
  ctx.win.clearTimeout(timer);
@@ -8763,7 +9309,7 @@ var kuinetic = (() => {
8763
9309
  finished,
8764
9310
  finish: () => {
8765
9311
  ctx.win.clearTimeout(timer);
8766
- land();
9312
+ land2();
8767
9313
  }
8768
9314
  };
8769
9315
  }
@@ -8898,7 +9444,9 @@ var kuinetic = (() => {
8898
9444
  {
8899
9445
  id: "slat-assemble",
8900
9446
  renderer: "javascript",
8901
- 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],
8902
9450
  parameters: slatParams,
8903
9451
  supportedTimelines: ["time"],
8904
9452
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -8922,12 +9470,15 @@ var kuinetic = (() => {
8922
9470
  * and it is what makes `background-media, video-scrub` on one element a reported conflict
8923
9471
  * rather than two effects silently fighting over the same picture.
8924
9472
  *
8925
- * `layout` is claimed for the same reason `pin` claims it: preparation writes `position` and
8926
- * `isolation` on the *host*, which is a stacking-context claim on someone else's element. Left
8927
- * undeclared, `background-media, pin-section` composed silently while both decided what
8928
- * `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.
8929
9480
  */
8930
- channels: ["media", "layout"],
9481
+ channels: ["media", "position", "isolation", SUBTREE_CHANNEL],
8931
9482
  renderer: "javascript",
8932
9483
  parameters: backgroundMediaParams,
8933
9484
  // Not a claim to support four timelines — an abstention. A backdrop is not driven by progress
@@ -9456,7 +10007,9 @@ var kuinetic = (() => {
9456
10007
  return {
9457
10008
  id,
9458
10009
  renderer: "javascript",
9459
- 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],
9460
10013
  parameters,
9461
10014
  supportedTimelines: ["time"],
9462
10015
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -9844,7 +10397,9 @@ var kuinetic = (() => {
9844
10397
  return {
9845
10398
  id,
9846
10399
  renderer: "javascript",
9847
- 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],
9848
10403
  parameters: options.parameters,
9849
10404
  supportedTimelines: ["time"],
9850
10405
  supportedActivations: ["load", "enter", "hover", "focus", "click", "manual"],
@@ -9974,21 +10529,21 @@ var kuinetic = (() => {
9974
10529
  const finished = new Promise((resolve) => {
9975
10530
  settle2 = resolve;
9976
10531
  });
9977
- const land = () => {
10532
+ const land2 = () => {
9978
10533
  ctx.win.clearTimeout(timer);
9979
10534
  timer = void 0;
9980
10535
  layers.decorative.removeAttribute("aria-hidden");
9981
10536
  layers.srOnly.remove();
9982
10537
  settle2();
9983
10538
  };
9984
- let timer = ctx.win.setTimeout(land, splitRevealFinishMs(params, count));
10539
+ let timer = ctx.win.setTimeout(land2, splitRevealFinishMs(params, count));
9985
10540
  return {
9986
10541
  finished,
9987
- land,
10542
+ land: land2,
9988
10543
  rearm(nextCount) {
9989
10544
  if (timer === void 0) return;
9990
10545
  ctx.win.clearTimeout(timer);
9991
- timer = ctx.win.setTimeout(land, splitRevealFinishMs(params, nextCount));
10546
+ timer = ctx.win.setTimeout(land2, splitRevealFinishMs(params, nextCount));
9992
10547
  },
9993
10548
  cancel() {
9994
10549
  if (timer !== void 0) ctx.win.clearTimeout(timer);
@@ -10227,7 +10782,8 @@ var kuinetic = (() => {
10227
10782
  prepare: deferPrepare(prepareTypewriter),
10228
10783
  perfClass: "continuous"
10229
10784
  }),
10230
- 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"], {
10231
10787
  parameters: scrambleParams,
10232
10788
  prepare: deferPrepare(prepareScramble),
10233
10789
  perfClass: "continuous"
@@ -10636,6 +11192,36 @@ var kuinetic = (() => {
10636
11192
  return registry;
10637
11193
  }
10638
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
+
10639
11225
  // src/effects/step-marking.ts
10640
11226
  var STEP_STATE_ATTR = "data-kui-step-state";
10641
11227
  var STEP_OFFSET_ATTR = "data-kui-step-offset";
@@ -10724,28 +11310,49 @@ var kuinetic = (() => {
10724
11310
  }
10725
11311
  return Math.max(1, ...groups.values());
10726
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
+ }
10727
11320
  function delegateControls(request) {
10728
- const { el, ctx, scope, groups } = request;
11321
+ const { el, ctx, groups } = request;
10729
11322
  if (groups.length === 0) return () => {
10730
11323
  };
10731
- const root = scope === "page" ? ctx.doc : el;
10732
- const onClick = (event) => {
10733
- const from = event.target;
10734
- if (typeof from?.closest !== "function") return;
11324
+ const press = (from, inside) => {
10735
11325
  for (const { selector, run } of groups) {
10736
- const matches = queryScoped(el, ctx, selector, scope);
11326
+ const matches = queryControls(el, ctx, selector);
10737
11327
  const matched = new Set(matches);
11328
+ const stop = inside ? el : null;
10738
11329
  let node = from;
10739
- while (node && !matched.has(node)) node = node.parentElement;
10740
- if (!node) continue;
11330
+ while (node && node !== stop && !matched.has(node)) node = node.parentElement;
11331
+ if (!node || node === stop) continue;
10741
11332
  run(node, matches.indexOf(node));
10742
11333
  }
10743
11334
  };
10744
- root.addEventListener("click", onClick);
10745
- 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
+ };
10746
11353
  }
10747
11354
  function createStepIndex(options) {
10748
- const { el, params, ctx, scope, resolveSteps, onRender } = options;
11355
+ const { el, params, ctx, resolveSteps, onRender } = options;
10749
11356
  const name = options.name ?? "step-progress";
10750
11357
  const marker = createStepMarker(resolveSteps, (message) => ctx.warn(`${name} ${message}`));
10751
11358
  const self = createAttributeLedger(el);
@@ -10764,26 +11371,23 @@ var kuinetic = (() => {
10764
11371
  };
10765
11372
  const next = () => goTo(nextStep(step, total()));
10766
11373
  const prev = () => goTo(prevStep(step, total()));
10767
- const groups = [];
10768
- const bindControl = (param, run) => {
10769
- const selector = resolveTarget(params.text(param), ctx, `${name} ${param}`);
10770
- if (!selector) return false;
10771
- if (queryScoped(el, ctx, selector, scope).length === 0) {
10772
- ctx.warn(`${name} ${param} "${selector}" matched nothing`);
10773
- }
10774
- groups.push({ selector, run });
10775
- return true;
11374
+ const byHand = (move) => {
11375
+ options.onInput?.();
11376
+ move();
10776
11377
  };
10777
- const named = [
10778
- bindControl("next", next),
10779
- bindControl("prev", prev),
10780
- bindControl("jump", (_node, position) => {
10781
- if (position >= 0) goTo(position);
10782
- })
10783
- ].some(Boolean);
10784
- const fallback = options.clickFallback !== false && !named;
10785
- if (fallback) el.addEventListener("click", next);
10786
- 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 });
10787
11391
  render();
10788
11392
  return {
10789
11393
  goTo,
@@ -10792,7 +11396,7 @@ var kuinetic = (() => {
10792
11396
  current: () => step,
10793
11397
  total,
10794
11398
  release: () => {
10795
- if (fallback) el.removeEventListener("click", next);
11399
+ if (fallback) el.removeEventListener("click", onContainerClick);
10796
11400
  releaseControls();
10797
11401
  self.restore();
10798
11402
  selfStyle.restore();
@@ -10801,18 +11405,294 @@ var kuinetic = (() => {
10801
11405
  };
10802
11406
  }
10803
11407
 
10804
- // src/effects/forms/primitives.ts
10805
- var timing = {
10806
- duration: { type: "time", default: "400ms", cssProperty: "--kui-duration" },
10807
- ...TRIGGER_DELAY_PARAM,
10808
- ease: { type: "easing", default: "ease-out", cssProperty: "--kui-ease" }
10809
- };
10810
- var FORM_STATE_TIMING = {
10811
- because: "forms.css pins its timing literally \u2014 the motion lands on a sibling that inline custom properties cannot reach"
10812
- };
10813
- var NATIVE_STATE_PRIMITIVE = {
10814
- id: "native-state",
10815
- renderer: "javascript",
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",
10816
11696
  channels: [CHANNEL.translate, CHANNEL.scale, CHANNEL.opacity, CHANNEL.stroke, CHANNEL.color],
10817
11697
  parameters: {},
10818
11698
  supportedTimelines: ["time"],
@@ -10921,7 +11801,7 @@ var kuinetic = (() => {
10921
11801
  }
10922
11802
  var STRENGTH_METER_PRIMITIVE = jsInputPrimitive(
10923
11803
  "strength-meter",
10924
- ["meter"],
11804
+ ["meter", attributeChannel("data-kui-strength-level")],
10925
11805
  {},
10926
11806
  deferPrepare(prepareStrengthMeter)
10927
11807
  );
@@ -10932,14 +11812,55 @@ var kuinetic = (() => {
10932
11812
  deferPrepare(prepareRangeFill)
10933
11813
  );
10934
11814
  function prepareStepProgress(el, params, ctx) {
10935
- const selector = resolveTarget(params.text("target"), ctx, "step-progress");
11815
+ const label = "step-progress";
11816
+ const selector = resolveTarget(params.text("target"), ctx, label);
10936
11817
  const scope = scopeParam(params, "page");
10937
11818
  const resolveSteps = () => selector ? queryScoped(el, ctx, selector, scope) : el.children;
10938
- 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
+ };
10939
11858
  }
10940
11859
  var STEP_PROGRESS_BASE = jsInputPrimitive(
10941
11860
  "step-progress",
10942
- ["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")],
10943
11864
  {
10944
11865
  // Empty default, not '4': `readParams` fills every declared default in unconditionally and
10945
11866
  // does not validate it, so an empty one is how `prepareStepProgress` tells "unauthored" from
@@ -10986,6 +11907,16 @@ var kuinetic = (() => {
10986
11907
  * at 390px and at 1440px, and the page keeps owning the box. Same reason `rest:` is a number.
10987
11908
  */
10988
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,
10989
11920
  // Which tree `target:` is searched in. Unset means this primitive's own historical answer —
10990
11921
  // see `prepareStepProgress`. One declaration, shared: `effects/step-marking.ts`.
10991
11922
  scope: SCOPE_PARAM
@@ -11032,7 +11963,7 @@ var kuinetic = (() => {
11032
11963
  }
11033
11964
  var SUBMIT_FLOW_PRIMITIVE = jsInputPrimitive(
11034
11965
  "submit-flow",
11035
- ["state"],
11966
+ [attributeChannel("data-kui-stage")],
11036
11967
  {
11037
11968
  load: { type: "time", default: "1200ms", cssProperty: "--kui-load" },
11038
11969
  hold: { type: "time", default: "1500ms", cssProperty: "--kui-hold" }
@@ -11082,9 +12013,12 @@ var kuinetic = (() => {
11082
12013
  * two readings is the stylesheet. `step-progress` was the wrong word to type on a deck of
11083
12014
  * slides, which is the only reason this alias exists; nothing behaves differently under it.
11084
12015
  *
11085
- * It is still an *index*, not a carousel component: no ARIA, no roving focus, no autoplay. That
11086
- * boundary is the one section H states — the library animates elements you control and does not
11087
- * 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`).
11088
12022
  *
11089
12023
  * No `requiresOwnSubtree` here, unlike `step-progress` beside it. That flag means "this name's
11090
12024
  * shipped CSS reaches past the element into its descendants, so the universal `target:` must
@@ -11146,6 +12080,10 @@ var kuinetic = (() => {
11146
12080
  let active = false;
11147
12081
  let longPressTimer = null;
11148
12082
  let longPressFired = false;
12083
+ let captured = false;
12084
+ let pointerId = null;
12085
+ let lastHandled = null;
12086
+ let documentListening = null;
11149
12087
  function sampleOf(event) {
11150
12088
  return { x: event.clientX, y: event.clientY, time: deps.now() };
11151
12089
  }
@@ -11159,11 +12097,24 @@ var kuinetic = (() => {
11159
12097
  longPressTimer = null;
11160
12098
  }
11161
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
+ }
11162
12111
  origin = sampleOf(event);
11163
12112
  samples = [origin];
11164
12113
  active = false;
11165
12114
  longPressFired = false;
11166
- if (capturePointer) el.setPointerCapture?.(event.pointerId);
12115
+ pointerId = event.pointerId;
12116
+ captured = capturePointer === true && takeCapture(event.pointerId);
12117
+ if (!captured) listenOnDocument();
11167
12118
  if (longPressMs > 0) {
11168
12119
  longPressTimer = deps.setTimer(() => {
11169
12120
  longPressFired = true;
@@ -11171,8 +12122,43 @@ var kuinetic = (() => {
11171
12122
  }, longPressMs);
11172
12123
  }
11173
12124
  }
12125
+ function takeCapture(id) {
12126
+ if (typeof el.setPointerCapture !== "function") return false;
12127
+ try {
12128
+ el.setPointerCapture(id);
12129
+ return true;
12130
+ } catch {
12131
+ return false;
12132
+ }
12133
+ }
12134
+ function captureOnDrag(event) {
12135
+ if (capturePointer !== "drag") return;
12136
+ captured = takeCapture(event.pointerId);
12137
+ if (captured) stopListeningOnDocument();
12138
+ }
12139
+ function listenOnDocument() {
12140
+ const doc = el.ownerDocument;
12141
+ if (!doc || documentListening) return;
12142
+ documentListening = doc;
12143
+ doc.addEventListener("pointermove", onMove, { passive: true });
12144
+ doc.addEventListener("pointerup", onUp, { passive: true });
12145
+ doc.addEventListener("pointercancel", onUp, { passive: true });
12146
+ }
12147
+ function stopListeningOnDocument() {
12148
+ const doc = documentListening;
12149
+ if (!doc) return;
12150
+ documentListening = null;
12151
+ doc.removeEventListener("pointermove", onMove);
12152
+ doc.removeEventListener("pointerup", onUp);
12153
+ doc.removeEventListener("pointercancel", onUp);
12154
+ }
12155
+ function firstHearing(event) {
12156
+ if (event === lastHandled) return false;
12157
+ lastHandled = event;
12158
+ return true;
12159
+ }
11174
12160
  function onMove(event) {
11175
- if (!origin) return;
12161
+ if (!origin || !firstHearing(event)) return;
11176
12162
  const sample = sampleOf(event);
11177
12163
  samples.push(sample);
11178
12164
  if (samples.length > MAX_SAMPLES2) samples.shift();
@@ -11181,17 +12167,17 @@ var kuinetic = (() => {
11181
12167
  if (!active) {
11182
12168
  active = true;
11183
12169
  clearLongPress();
12170
+ captureOnDrag(event);
11184
12171
  handlers.onStart?.(origin);
11185
12172
  }
11186
12173
  handlers.onMove?.(vector, sample);
11187
12174
  }
11188
- function onUp(event) {
11189
- clearLongPress();
11190
- if (capturePointer) el.releasePointerCapture?.(event.pointerId);
11191
- if (!origin) return;
11192
- const sample = sampleOf(event);
11193
- samples.push(sample);
11194
- const vector = vectorNow(sample);
12175
+ function releaseCapture(id) {
12176
+ if (captured) el.releasePointerCapture?.(id);
12177
+ captured = false;
12178
+ pointerId = null;
12179
+ }
12180
+ function reportEnd(vector, sample) {
11195
12181
  if (active) {
11196
12182
  handlers.onEnd?.(vector, sample);
11197
12183
  const direction = swipeDirection(vector, swipeVelocity);
@@ -11199,21 +12185,41 @@ var kuinetic = (() => {
11199
12185
  } else if (longPressFired) {
11200
12186
  handlers.onEnd?.(vector, sample);
11201
12187
  }
12188
+ }
12189
+ function onUp(event) {
12190
+ clearLongPress();
12191
+ stopListeningOnDocument();
12192
+ releaseCapture(event.pointerId);
12193
+ if (!origin) return;
12194
+ finish(sampleOf(event));
12195
+ }
12196
+ function finish(sample) {
12197
+ samples.push(sample);
12198
+ reportEnd(vectorNow(sample), sample);
11202
12199
  origin = null;
11203
12200
  active = false;
11204
12201
  longPressFired = false;
11205
12202
  samples = [];
11206
12203
  }
12204
+ function onLostCapture(event) {
12205
+ if (event.target !== el) return;
12206
+ if (!captured || event.pointerId !== pointerId) return;
12207
+ captured = false;
12208
+ onUp(event);
12209
+ }
11207
12210
  el.addEventListener("pointerdown", onDown);
11208
12211
  el.addEventListener("pointermove", onMove, { passive: true });
11209
12212
  el.addEventListener("pointerup", onUp, { passive: true });
11210
12213
  el.addEventListener("pointercancel", onUp, { passive: true });
12214
+ el.addEventListener("lostpointercapture", onLostCapture, { passive: true });
11211
12215
  return () => {
11212
12216
  clearLongPress();
12217
+ stopListeningOnDocument();
11213
12218
  el.removeEventListener("pointerdown", onDown);
11214
12219
  el.removeEventListener("pointermove", onMove);
11215
12220
  el.removeEventListener("pointerup", onUp);
11216
12221
  el.removeEventListener("pointercancel", onUp);
12222
+ el.removeEventListener("lostpointercapture", onLostCapture);
11217
12223
  };
11218
12224
  }
11219
12225
  function defaultGestureDeps() {
@@ -11351,22 +12357,26 @@ var kuinetic = (() => {
11351
12357
  runners.x.to(offset.x + vector.vx * carry);
11352
12358
  runners.y.to(offset.y + vector.vy * carry);
11353
12359
  }
11354
- 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`);
11355
12363
  const stop = recognise(
11356
12364
  el,
11357
12365
  {
11358
12366
  onSwipe(direction) {
11359
12367
  el.setAttribute("data-kui-swipe", direction);
12368
+ announceSwipe(ctx.win, el, direction);
11360
12369
  }
11361
12370
  },
11362
12371
  {
11363
- axis: params.text("axis", "both"),
12372
+ axis,
11364
12373
  swipeVelocity: params.num("velocity", 300),
11365
- // This primitive publishes an attribute and moves nothing, so it has no reason to hold the
11366
- // pointer — and holding it retargets the following `click` at this element, which silently
11367
- // breaks every interactive child. A `swipe-x` on a carousel shell killed its own dots and
11368
- // buttons that way. See `capturePointer` in `core/gesture.ts`.
11369
- capturePointer: false
12374
+ // This primitive moves nothing, so it must not hold the pointer from `pointerdown` — that
12375
+ // retargets the following `click` at this element and kills every interactive child (a
12376
+ // `swipe-x` on a carousel shell killed its own dots and buttons). But a flick that leaves a
12377
+ // small element never delivers `pointerup` to it unless captured, so capture only once the
12378
+ // drag starts. See `capturePointer` in `core/gesture.ts`.
12379
+ capturePointer: "drag"
11370
12380
  }
11371
12381
  );
11372
12382
  return () => {
@@ -11425,7 +12435,7 @@ var kuinetic = (() => {
11425
12435
  var GESTURE_PRIMITIVES = [
11426
12436
  gesturePrimitive(
11427
12437
  "draggable",
11428
- ["translate"],
12438
+ ["translate", attributeChannel("data-kui-dragging")],
11429
12439
  {
11430
12440
  ...springParams2,
11431
12441
  axis: { type: "keyword", default: "both", cssProperty: "--kui-axis", keywords: ["x", "y", "both"] },
@@ -11456,7 +12466,7 @@ var kuinetic = (() => {
11456
12466
  ),
11457
12467
  gesturePrimitive(
11458
12468
  "swipeable",
11459
- ["state"],
12469
+ [attributeChannel("data-kui-swipe")],
11460
12470
  {
11461
12471
  axis: { type: "keyword", default: "both", cssProperty: "--kui-axis", keywords: ["x", "y", "both"] },
11462
12472
  velocity: { type: "number", default: "300", cssProperty: "--kui-velocity" }
@@ -11465,7 +12475,7 @@ var kuinetic = (() => {
11465
12475
  ),
11466
12476
  gesturePrimitive(
11467
12477
  "pressable",
11468
- ["state"],
12478
+ [attributeChannel("data-kui-pressed")],
11469
12479
  { duration: { type: "time", default: "500ms", cssProperty: "--kui-duration" } },
11470
12480
  // `duration` here is the hold threshold, not a span of motion — `long-press 800ms` means
11471
12481
  // "count it once the finger has been down for 800ms". It is read (see `preparePressable`), so
@@ -11516,6 +12526,7 @@ var kuinetic = (() => {
11516
12526
  },
11517
12527
  { name: "swipe", primitive: "swipeable", phase: "state" },
11518
12528
  { name: "swipe-x", primitive: "swipeable", params: { axis: "x" }, phase: "state" },
12529
+ { name: "swipe-y", primitive: "swipeable", params: { axis: "y" }, phase: "state" },
11519
12530
  { name: "long-press", primitive: "pressable", phase: "state" },
11520
12531
  { name: "magnetic", primitive: "magnetic", phase: "state" },
11521
12532
  {
@@ -11811,13 +12822,15 @@ var kuinetic = (() => {
11811
12822
  ),
11812
12823
  layoutPrimitive(
11813
12824
  "auto-height",
11814
- ["layout"],
12825
+ // The two properties it writes on the panel: `overflow: hidden`, and the `height` it animates.
12826
+ ["overflow", "height"],
11815
12827
  { attribute: { type: "text", default: "data-open", cssProperty: "--kui-attribute" } },
11816
12828
  deferPrepare(prepareAutoHeight)
11817
12829
  ),
11818
12830
  layoutPrimitive(
11819
12831
  "flip-indicator",
11820
- ["translate", "layout"],
12832
+ // The bar's landing spot (`translate`) and its `width`, matched to the selected tab.
12833
+ ["translate", "width"],
11821
12834
  {
11822
12835
  follow: { type: "text", default: "", cssProperty: "--kui-follow" },
11823
12836
  attribute: { type: "text", default: "aria-selected", cssProperty: "--kui-attribute" }
@@ -12066,26 +13079,25 @@ var kuinetic = (() => {
12066
13079
  var NAV_JS_PRIMITIVES = [
12067
13080
  navPrimitive(
12068
13081
  "header-shrink",
12069
- // `'shadow'` alongside `'layout'`: `header-shrink`'s host rule also transitions `box-shadow`
12070
- // (navigation.css), which `layout` alone does not cover — see `header-shrink`'s own
12071
- // `Preset.transitions` below and `channel-properties.ts`'s `layout` entry for why the two
12072
- // interpolated properties (`padding-block`/`font-size`) stay on `layout` while this one moves
12073
- // to the channel that already owns every other box-shadow writer (`lift-shadow`,
12074
- // `border-glow`). Composing `header-shrink` with either of those is now a refused conflict
12075
- // instead of a silent clobber on the same property — the self-consistency bug this closes.
12076
- ["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")],
12077
13089
  { offset: { type: "number", default: "120", cssProperty: "--kui-offset" } },
12078
13090
  deferPrepare(prepareHeaderShrink)
12079
13091
  ),
12080
13092
  navPrimitive(
12081
13093
  "header-hide-on-scroll",
12082
- [CHANNEL.translate],
13094
+ [CHANNEL.translate, attributeChannel("data-kui-hidden")],
12083
13095
  { offset: { type: "number", default: "8", cssProperty: "--kui-offset" } },
12084
13096
  deferPrepare(prepareHeaderHide)
12085
13097
  ),
12086
13098
  navPrimitive(
12087
13099
  "back-to-top-fade",
12088
- [CHANNEL.opacity, CHANNEL.translate],
13100
+ [CHANNEL.opacity, CHANNEL.translate, attributeChannel("data-kui-visible")],
12089
13101
  { offset: { type: "number", default: "400", cssProperty: "--kui-offset" } },
12090
13102
  deferPrepare(prepareBackToTop)
12091
13103
  )
@@ -12648,7 +13660,10 @@ var kuinetic = (() => {
12648
13660
  var SCROLL_PRIMITIVES = [
12649
13661
  scrollPrimitive({
12650
13662
  id: "pin",
12651
- 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")],
12652
13667
  parameters: {
12653
13668
  ...distanceParam2,
12654
13669
  ...stickyParams
@@ -12658,7 +13673,13 @@ var kuinetic = (() => {
12658
13673
  }),
12659
13674
  scrollPrimitive({
12660
13675
  id: "scroll-progress",
12661
- 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")],
12662
13683
  parameters: {
12663
13684
  ...distanceParam2,
12664
13685
  steps: { type: "number", default: "0", cssProperty: "--kui-steps" },
@@ -12694,7 +13715,8 @@ var kuinetic = (() => {
12694
13715
  }),
12695
13716
  scrollPrimitive({
12696
13717
  id: "media-scrub",
12697
- channels: ["media", "progress"],
13718
+ // `position`: it holds its host the way a pin does (`position: sticky` and its `top`).
13719
+ channels: ["media", "progress", "position"],
12698
13720
  parameters: {
12699
13721
  ...distanceParam2,
12700
13722
  // A scrub is a hold, so it needs the same two knobs a pin does. Declaring them here is what
@@ -12713,7 +13735,7 @@ var kuinetic = (() => {
12713
13735
  }),
12714
13736
  scrollPrimitive({
12715
13737
  id: "scroll-spy",
12716
- channels: ["state"],
13738
+ channels: [attributeChannel("data-kui-active")],
12717
13739
  parameters: {
12718
13740
  // `distance`: the per-section form only. `offset-top`: the container form only. Each is a
12719
13741
  // no-op — warned, not silent — in the other; see `prepareScrollSpySingle` and
@@ -12739,8 +13761,8 @@ var kuinetic = (() => {
12739
13761
  scrollPrimitive({
12740
13762
  id: "smooth-scroll",
12741
13763
  /*
12742
- * Its own channel, not the `'layout'` it used to share with `pin`, `stacking-cards` and
12743
- * `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
12744
13766
  * property, and this one writes exactly `scroll-behavior` — a property that describes how a
12745
13767
  * *user-or-script-initiated* scroll is performed, and that no other primitive touches.
12746
13768
  *
@@ -12761,7 +13783,11 @@ var kuinetic = (() => {
12761
13783
  }),
12762
13784
  scrollPrimitive({
12763
13785
  id: "scroll-snap",
12764
- 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],
12765
13791
  parameters: {
12766
13792
  axis: { type: "keyword", default: "y", cssProperty: "--kui-axis", keywords: ["x", "y"] },
12767
13793
  strictness: {
@@ -13103,7 +14129,7 @@ var kuinetic = (() => {
13103
14129
  return children.filter((child) => child.textContent.trim() === "");
13104
14130
  }
13105
14131
  function withIconParts(inner) {
13106
- return (el, params, ctx) => {
14132
+ return inheritTimingContract(inner, (el, params, ctx) => {
13107
14133
  if (!el.querySelector(".kui-bar")) {
13108
14134
  const bars = findBars(el);
13109
14135
  if (bars.length > 0) {
@@ -13122,7 +14148,7 @@ var kuinetic = (() => {
13122
14148
  }
13123
14149
  }
13124
14150
  return inner(el, params, ctx);
13125
- };
14151
+ });
13126
14152
  }
13127
14153
 
13128
14154
  // src/effects/svg/index.ts
@@ -13161,18 +14187,18 @@ var kuinetic = (() => {
13161
14187
  };
13162
14188
  let current = 0;
13163
14189
  const enter = () => drive(1);
13164
- const leave = () => drive(0);
14190
+ const leave2 = () => drive(0);
13165
14191
  el.addEventListener("pointerenter", enter, { passive: true });
13166
14192
  el.addEventListener("focusin", enter, { passive: true });
13167
- el.addEventListener("pointerleave", leave, { passive: true });
13168
- el.addEventListener("focusout", leave, { passive: true });
14193
+ el.addEventListener("pointerleave", leave2, { passive: true });
14194
+ el.addEventListener("focusout", leave2, { passive: true });
13169
14195
  return () => {
13170
14196
  cancelled = true;
13171
14197
  cancelAnimationFrame(frame);
13172
14198
  el.removeEventListener("pointerenter", enter);
13173
14199
  el.removeEventListener("focusin", enter);
13174
- el.removeEventListener("pointerleave", leave);
13175
- el.removeEventListener("focusout", leave);
14200
+ el.removeEventListener("pointerleave", leave2);
14201
+ el.removeEventListener("focusout", leave2);
13176
14202
  path2.setAttribute("d", startPath);
13177
14203
  };
13178
14204
  }
@@ -13193,7 +14219,16 @@ var kuinetic = (() => {
13193
14219
  var ICON_TOGGLE_PRIMITIVE = {
13194
14220
  id: "icon-toggle",
13195
14221
  renderer: "javascript",
13196
- 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
+ ],
13197
14232
  parameters: {
13198
14233
  duration: { type: "time", default: "260ms", cssProperty: "--kui-duration" },
13199
14234
  // The state flip is the start moment: `aria-expanded` goes true and the bars begin to move.
@@ -13391,8 +14426,10 @@ var kuinetic = (() => {
13391
14426
  // `'discrete'` alongside `rotate`: the unconditional `[data-kui-fx~='flip-card']` rule pins
13392
14427
  // `display: grid` so the two faces stack in one cell instead of flowing as normal block
13393
14428
  // siblings — unrelated to `catalog/discrete.ts`'s show/hide use of the same physical property,
13394
- // but `display` is tracked as one channel regardless of the value written into it.
13395
- 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],
13396
14433
  parameters: {
13397
14434
  duration: { type: "time", default: "700ms", cssProperty: "--kui-duration" },
13398
14435
  // The turn has a start moment — the click, or the pointer arriving — so a delay before it is
@@ -13530,8 +14567,8 @@ var kuinetic = (() => {
13530
14567
  function snapTo(projected) {
13531
14568
  return Math.round(projected) + 0;
13532
14569
  }
13533
- function wrapPlace(place2, total) {
13534
- return total > 0 ? (place2 % total + total) % total : 0;
14570
+ function wrapPlace(place3, total) {
14571
+ return total > 0 ? (place3 % total + total) % total : 0;
13535
14572
  }
13536
14573
  var KEY_DELTAS = {
13537
14574
  ArrowRight: 1,
@@ -13543,8 +14580,26 @@ var kuinetic = (() => {
13543
14580
  Home: null,
13544
14581
  End: null
13545
14582
  };
13546
- function createRingDrag(request) {
13547
- 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) {
13548
14603
  let suppressClick = false;
13549
14604
  const onClickCapture = (event) => {
13550
14605
  if (!suppressClick) return;
@@ -13552,19 +14607,49 @@ var kuinetic = (() => {
13552
14607
  event.stopPropagation();
13553
14608
  event.preventDefault();
13554
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);
13555
14636
  const stopRecognising = enabled ? recognise(
13556
14637
  el,
13557
14638
  {
13558
14639
  onStart() {
14640
+ dragFrom = positionOf();
14641
+ sign = request.travelSign();
14642
+ selection.hold();
13559
14643
  setDragging(true);
13560
14644
  },
13561
14645
  onMove(vector) {
13562
- moveTo(positionOf() - vector.dx / travelPx);
14646
+ moveTo(dragFrom - sign * vector.dx / travelPx);
13563
14647
  },
13564
14648
  onEnd(vector) {
14649
+ selection.release();
13565
14650
  setDragging(false);
13566
- suppressClick = true;
13567
- const settled = snapTo(projectRelease(positionOf(), vector.vx, travelPx));
14651
+ clicks.arm();
14652
+ const settled = snapTo(projectRelease(positionOf(), sign * vector.vx, travelPx));
13568
14653
  moveTo(wrapPlace(settled, total()));
13569
14654
  }
13570
14655
  },
@@ -13573,7 +14658,15 @@ var kuinetic = (() => {
13573
14658
  // threshold than `recognise`'s own default of 4px is what buys that — a ring occupies a
13574
14659
  // large slab of a phone screen, and a scroll that begins with a two-pixel horizontal wobble
13575
14660
  // should not capture the pointer away from the page.
13576
- { 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" }
13577
14670
  ) : () => {
13578
14671
  };
13579
14672
  if (enabled) ctx.style.set("touch-action", "pan-y");
@@ -13592,216 +14685,213 @@ var kuinetic = (() => {
13592
14685
  const grantedFocus = el.getAttribute("tabindex") === null;
13593
14686
  if (grantedFocus) el.setAttribute("tabindex", "0");
13594
14687
  el.addEventListener("keydown", onKeyDown);
13595
- el.addEventListener("click", onClickCapture, true);
13596
14688
  return () => {
13597
14689
  stopRecognising();
14690
+ selection.release();
13598
14691
  el.removeEventListener("keydown", onKeyDown);
13599
- el.removeEventListener("click", onClickCapture, true);
14692
+ clicks.release();
13600
14693
  if (grantedFocus) el.removeAttribute("tabindex");
13601
14694
  };
13602
14695
  }
13603
14696
 
13604
- // src/effects/carousel/index.ts
14697
+ // src/effects/carousel/deck.ts
13605
14698
  var DRAGGING_ATTR = "data-kui-ring-dragging";
13606
- var FACE_ATTR = "data-kui-ring-face";
13607
- var QUARTER_TURN_DEG = 90;
13608
- var FLATTENING = [
13609
- ["overflow", (value) => value !== "" && value !== "visible"],
13610
- ["clip-path", (value) => value !== "" && value !== "none"],
13611
- ["opacity", (value) => value !== "" && Number(value) < 1],
13612
- ["filter", (value) => value !== "" && value !== "none"],
13613
- ["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")
13614
14704
  ];
13615
- function warnFlatteningAncestor(el, ctx) {
13616
- const body = el.ownerDocument.body;
13617
- let node = el.parentElement;
13618
- while (node && node !== body) {
13619
- const found = flatteningDeclaration(node, ctx);
13620
- if (found) {
13621
- ctx.warn(
13622
- `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.`
13623
- );
13624
- return;
13625
- }
13626
- node = node.parentElement;
13627
- }
13628
- }
13629
- function flatteningDeclaration(node, ctx) {
13630
- const style = ctx.win.getComputedStyle?.(node);
13631
- if (!style) return null;
13632
- for (const [property2, flattens] of FLATTENING) {
13633
- const value = style.getPropertyValue(property2);
13634
- if (flattens(value)) return { property: property2, value };
13635
- }
13636
- return null;
13637
- }
13638
- function ledgersFor(store, node) {
13639
- let attributes = store.attributes.get(node);
13640
- if (!attributes) {
13641
- attributes = createAttributeLedger(node);
13642
- store.attributes.set(node, attributes);
13643
- }
13644
- let styles = store.styles.get(node);
13645
- if (!styles) {
13646
- styles = createStyleLedger(node);
13647
- store.styles.set(node, styles);
13648
- }
13649
- 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;
13650
14710
  }
13651
- function faceAt(angleDeg) {
13652
- 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;
13653
14715
  }
13654
- function normaliseDegrees(angleDeg) {
13655
- const wrapped2 = (angleDeg % 360 + 360) % 360;
13656
- return wrapped2 > 180 ? wrapped2 - 360 : wrapped2;
14716
+ function offsetWrapped(previous, next, count) {
14717
+ return Math.abs(next - previous) > count / 2;
13657
14718
  }
13658
14719
  function snapPosition(position, total) {
13659
14720
  if (total <= 0) return { step: 0, drift: 0 };
13660
14721
  const nearest = Math.round(position);
13661
14722
  return { step: (nearest % total + total) % total, drift: position - nearest };
13662
14723
  }
13663
- var RING_PARAMETERS = {
14724
+ var DECK_PARAMETERS = {
13664
14725
  duration: { type: "time", default: "620ms", cssProperty: "--kui-duration" },
13665
14726
  ...TRIGGER_DELAY_PARAM,
13666
14727
  ease: { type: "easing", default: "cubic-bezier(0.22, 1, 0.36, 1)", cssProperty: "--kui-ease" },
14728
+ /** Whether a pointer can move the deck by hand. */
14729
+ grab: { type: "keyword", default: "true", cssProperty: "--kui-grab", keywords: ["true", "false"] },
13667
14730
  /*
13668
- * A real CSS `<angle>`, not a normalised scalar.
13669
- *
13670
- * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
13671
- * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
13672
- * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
13673
- * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
13674
- * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
13675
- * `clamp()` an author can see in devtools rather than a hidden rescale.
14731
+ * How far the pointer travels, in pixels, to move the deck one place.
13676
14732
  *
13677
- * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
13678
- * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
13679
- * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
13680
- * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
14733
+ * A pixels-per-step mapping rather than pixels-per-degree, because a step is the unit everything
14734
+ * else here is in — the index, the offset, the snap — and a degree is not: the same drag would
14735
+ * move a six-item ring one place and a sixty-item ring ten, purely because the spacing changed.
13681
14736
  */
13682
- tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
14737
+ travel: { type: "number", default: "220", cssProperty: "--kui-travel", finite: true, minimum: 1 },
14738
+ /** Which elements are the slides. Unset means this element's own children. */
14739
+ target: { type: "text", default: "", cssProperty: "--kui-target" },
14740
+ /** Optional controls, resolved exactly as `step-progress` resolves its own. */
14741
+ next: { type: "text", default: "", cssProperty: "--kui-next" },
14742
+ prev: { type: "text", default: "", cssProperty: "--kui-prev" },
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,
13683
14748
  /*
13684
- * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
14749
+ * Continuous rotation: how long one full cycle of the deck takes. `0s` is off.
13685
14750
  *
13686
- * This is the parameter the concave name exists around. From the centre of a *full* ring most of
13687
- * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
13688
- * two thirds of the content — the inside name defaults to a slice for that reason.
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.
13689
14757
  */
13690
- arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
14758
+ spin: { type: "time", default: "0s", cssProperty: "--kui-spin" },
13691
14759
  /*
13692
- * The ring's radius, or unset for one derived from the content.
13693
- *
13694
- * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
13695
- * `readParams` fills every declared default in unconditionally, so an empty one is the only way
13696
- * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
13697
- * the circumradius of a regular polygon with this many sides and this much side length —
13698
- * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
13699
- * Every consumer solving that by hand was the alternative, and they would all have solved it
13700
- * differently.
13701
- *
13702
- * The width half of that has to come from JavaScript: CSS can compute with a measurement but
13703
- * cannot take one. See `measureItemWidth`.
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`).
13704
14762
  */
13705
- radius: { type: "length", default: "", cssProperty: "--kui-radius" },
13706
- /** Breathing room between neighbours, spent by the derived radius above. */
13707
- gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
13708
- /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
13709
- perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
14763
+ autoplay: AUTOPLAY_PARAM,
13710
14764
  /*
13711
- * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
13712
- *
13713
- * `radial` is the ring's natural state and needs nothing: a slot placed by
13714
- * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
13715
- * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
13716
- * ring read as the far side.
13717
- *
13718
- * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
13719
- * and the billboard have to stay on separate boxes or every later change to one silently
13720
- * multiplies into the other — so text stays readable at every position, which is what a raised
13721
- * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
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.
13722
14769
  */
13723
- facing: {
13724
- type: "keyword",
13725
- default: "radial",
13726
- cssProperty: "--kui-facing",
13727
- keywords: ["radial", "camera"]
13728
- },
13729
- /** Whether a pointer can spin the ring by hand. */
13730
- grab: { type: "keyword", default: "true", cssProperty: "--kui-grab", keywords: ["true", "false"] },
13731
- /*
13732
- * How far the pointer travels, in pixels, to move the ring one place.
13733
- *
13734
- * A pixels-per-step mapping rather than pixels-per-degree, because a step is the unit everything
13735
- * else here is in — the index, the offset, the snap — and a degree is not: the same drag would
13736
- * move a six-item ring one place and a sixty-item ring ten, purely because the spacing changed.
13737
- */
13738
- travel: { type: "number", default: "220", cssProperty: "--kui-travel", finite: true, minimum: 1 },
13739
- /** Which elements sit on the ring. Unset means this element's own children. */
13740
- target: { type: "text", default: "", cssProperty: "--kui-target" },
13741
- /** Optional controls, resolved exactly as `step-progress` resolves its own. */
13742
- next: { type: "text", default: "", cssProperty: "--kui-next" },
13743
- prev: { type: "text", default: "", cssProperty: "--kui-prev" },
13744
- jump: { type: "text", default: "", cssProperty: "--kui-jump" },
14770
+ lightbox: DECK_LIGHTBOX_PARAM,
13745
14771
  scope: SCOPE_PARAM
13746
14772
  };
13747
- function measureItemWidth(styles, slots) {
13748
- for (const slot of slots) {
13749
- const width = slot.offsetWidth;
13750
- if (width > 0) styles.set("--kui-item-width", `${width}px`);
13751
- return;
13752
- }
13753
- }
13754
- function prepareSpatialRing(el, params, ctx) {
13755
- warnFlatteningAncestor(el, ctx);
13756
- mirrorTimingToCss("spatial-ring", ALL_TIMING_TOKENS, params, ctx);
13757
- 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);
13758
14791
  const scope = scopeParam(params, "self");
13759
14792
  const resolveSlots = () => selector ? queryScoped(el, ctx, selector, scope) : el.children;
13760
- const marker = createStepMarker(resolveSlots, (message) => ctx.warn(`carousel ${message}`));
14793
+ const marker = createStepMarker(resolveSlots, (message) => ctx.warn(`${label} ${message}`));
13761
14794
  const total = () => countSteps(params, resolveSlots);
13762
14795
  const hostAttributes = createAttributeLedger(el);
13763
14796
  const hostStyles = createStyleLedger(el);
13764
- hostAttributes.set("data-kui-ring-facing", params.text("facing", "radial"));
13765
- const slots = { attributes: /* @__PURE__ */ new Map(), styles: /* @__PURE__ */ new Map() };
13766
- 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);
13767
14800
  let position = 0;
13768
- 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
+ };
13769
14826
  const render = () => {
13770
14827
  const count = total();
13771
14828
  const { step, drift: drift2 } = snapPosition(position, count);
13772
14829
  hostAttributes.set("data-kui-step", String(step));
13773
14830
  hostStyles.set("--kui-step", String(step));
13774
- hostStyles.set("--kui-step-position", (step + drift2).toFixed(4));
14831
+ hostStyles.set("--kui-item-count", String(count));
14832
+ publishPosition(step, drift2);
13775
14833
  marker.mark(step);
13776
- const slotNodes = [...resolveSlots()];
13777
- if (derivesRadius) measureItemWidth(hostStyles, slotNodes);
13778
- const spacing = arcDeg / count;
13779
- for (const node of slotNodes) {
13780
- const offset = Number(node.getAttribute("data-kui-step-offset"));
13781
- const angle = (offset - drift2) * spacing;
13782
- 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;
13783
14846
  }
14847
+ publishPosition(step, drift2);
14848
+ markFaces(rendered.nodes, rendered.count, drift2);
13784
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
+ });
13785
14870
  const goTo = (next) => {
14871
+ motion.interrupt();
13786
14872
  position = next;
13787
14873
  render();
13788
14874
  };
13789
- const groups = [];
13790
- const bindControl = (param, run) => {
13791
- const control2 = resolveTarget(params.text(param), ctx, `carousel ${param}`);
13792
- if (!control2) return false;
13793
- if (queryScoped(el, ctx, control2, scope).length === 0) {
13794
- 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);
13795
14892
  }
13796
- groups.push({ selector: control2, run });
13797
- return true;
14893
+ return travelSign(centres.get(0) ?? 0, centres.get(1) ?? null, centres.get(-1) ?? null);
13798
14894
  };
13799
- bindControl("next", () => goTo(nextStep(snapPosition(position, total()).step, total())));
13800
- bindControl("prev", () => goTo(prevStep(snapPosition(position, total()).step, total())));
13801
- bindControl("jump", (_node, at) => {
13802
- if (at >= 0) goTo(at);
13803
- });
13804
- const releaseControls = delegateControls({ el, ctx, scope, groups });
13805
14895
  const releaseDrag = createRingDrag({
13806
14896
  el,
13807
14897
  ctx,
@@ -13809,22 +14899,405 @@ var kuinetic = (() => {
13809
14899
  travelPx: params.num("travel", 220),
13810
14900
  total,
13811
14901
  positionOf: () => position,
14902
+ travelSign: screenTravel,
13812
14903
  moveTo: goTo,
13813
- setDragging: (dragging) => hostAttributes.set(DRAGGING_ATTR, String(dragging))
14904
+ setDragging: (dragging) => {
14905
+ hostAttributes.set(DRAGGING_ATTR, String(dragging));
14906
+ motion.hold(dragging);
14907
+ }
13814
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
+ );
13815
14914
  render();
13816
14915
  return () => {
14916
+ releaseViewer();
14917
+ releaseMotion();
13817
14918
  releaseDrag();
13818
14919
  releaseControls();
13819
14920
  marker.restore();
13820
14921
  for (const ledger of slots.attributes.values()) ledger.restore();
13821
- for (const ledger of slots.styles.values()) ledger.restore();
13822
14922
  slots.attributes.clear();
13823
- slots.styles.clear();
14923
+ attachment?.release();
13824
14924
  hostAttributes.restore();
13825
14925
  hostStyles.restore();
13826
14926
  };
13827
14927
  }
14928
+
14929
+ // src/effects/carousel/fit.ts
14930
+ var RING_FIT_PROPERTY = "--kui-ring-fit";
14931
+ var SAMPLE_DEG = 0.25;
14932
+ var SEARCH_CEILING_PX = 1e5;
14933
+ var SEARCH_STEPS = 34;
14934
+ var RAD = Math.PI / 180;
14935
+ function reachableAngles(arcDeg, count) {
14936
+ if (Math.abs(arcDeg) >= 360) return { fromDeg: -180, toDeg: 180 };
14937
+ const places = Math.max(1, count);
14938
+ const spacing = arcDeg / places;
14939
+ const high = Math.floor(places / 2);
14940
+ const low = high - places + 1;
14941
+ const ends = [(low - 0.5) * spacing, (high + 0.5) * spacing];
14942
+ return { fromDeg: Math.min(...ends), toDeg: Math.max(...ends) };
14943
+ }
14944
+ function place2(point, { sinA, cosA, sinT, cosT }) {
14945
+ const [x, y, z] = point;
14946
+ const x1 = x * cosA + z * sinA;
14947
+ const z1 = -x * sinA + z * cosA;
14948
+ return [x1, y * cosT - z1 * sinT, y * sinT + z1 * cosT];
14949
+ }
14950
+ function cardFits(g, r, rotation) {
14951
+ const z0 = (g.inside ? -1 : 1) * r;
14952
+ const centre = place2([0, 0, z0], rotation);
14953
+ const hw = g.width / 2;
14954
+ const hh = g.height / 2;
14955
+ for (const [cx, cy] of [[-hw, -hh], [hw, -hh], [-hw, hh], [hw, hh]]) {
14956
+ const [x, , z] = g.facing === "camera" ? [centre[0] + cx, centre[1] + cy, centre[2]] : place2([cx, cy, z0], rotation);
14957
+ if (z >= g.perspective - 1) return false;
14958
+ const scale = Number.isFinite(g.perspective) ? g.perspective / (g.perspective - z) : 1;
14959
+ const screen2 = (g.originOffset + x) * scale;
14960
+ if (screen2 < g.minX || screen2 > g.maxX) return false;
14961
+ }
14962
+ return true;
14963
+ }
14964
+ function fitsAt(g, r, angles) {
14965
+ const sinT = Math.sin(g.tiltDeg * RAD);
14966
+ const cosT = Math.cos(g.tiltDeg * RAD);
14967
+ return angles.every((angle) => cardFits(g, r, { sinA: Math.sin(angle * RAD), cosA: Math.cos(angle * RAD), sinT, cosT }));
14968
+ }
14969
+ function sampleAngles(g) {
14970
+ const from = g.inside ? Math.max(g.fromDeg, -90) : g.fromDeg;
14971
+ const to = g.inside ? Math.min(g.toDeg, 90) : g.toDeg;
14972
+ const steps = Math.max(1, Math.ceil((to - from) / SAMPLE_DEG));
14973
+ return Array.from({ length: steps + 1 }, (_, i) => from + (to - from) * i / steps);
14974
+ }
14975
+ function solveRingFit(g) {
14976
+ const angles = sampleAngles(g);
14977
+ if (!fitsAt(g, 0, angles)) return 0;
14978
+ if (fitsAt(g, SEARCH_CEILING_PX, angles)) return Infinity;
14979
+ let low = 0;
14980
+ let high = SEARCH_CEILING_PX;
14981
+ for (let i = 0; i < SEARCH_STEPS; i++) {
14982
+ const mid = (low + high) / 2;
14983
+ if (fitsAt(g, mid, angles)) low = mid;
14984
+ else high = mid;
14985
+ }
14986
+ return Math.floor(low * 100) / 100;
14987
+ }
14988
+ function fitBounds(el, win) {
14989
+ const doc = el.ownerDocument;
14990
+ for (let node = el.parentElement; node && node !== doc.body && node !== doc.documentElement; node = node.parentElement) {
14991
+ const overflow = win.getComputedStyle(node).overflowX;
14992
+ if (overflow === "clip" || overflow === "hidden") return null;
14993
+ if (overflow === "auto" || overflow === "scroll") {
14994
+ const left2 = node.getBoundingClientRect().left + node.clientLeft;
14995
+ return { left: left2, right: left2 + node.clientWidth };
14996
+ }
14997
+ }
14998
+ const left = -win.scrollX;
14999
+ return { left, right: left + doc.documentElement.clientWidth };
15000
+ }
15001
+ function pixels(value, fallback) {
15002
+ const parsed = Number.parseFloat(value);
15003
+ return Number.isFinite(parsed) ? parsed : fallback;
15004
+ }
15005
+ function createRingFit(options) {
15006
+ const { el, ctx, styles } = options;
15007
+ if (typeof ctx.win.getComputedStyle !== "function" || typeof ctx.win.addEventListener !== "function") {
15008
+ return { render() {
15009
+ }, release() {
15010
+ } };
15011
+ }
15012
+ const tiltDeg = Math.min(80, Math.max(-80, -options.tiltParamDeg));
15013
+ let card;
15014
+ let count = 1;
15015
+ const solve = () => {
15016
+ const bounds = fitBounds(el, ctx.win);
15017
+ if (!bounds || !card || card.offsetWidth === 0) {
15018
+ styles.set(RING_FIT_PROPERTY, "");
15019
+ return;
15020
+ }
15021
+ const style = ctx.win.getComputedStyle(el);
15022
+ const host = el.getBoundingClientRect();
15023
+ const originX = host.left + pixels(style.perspectiveOrigin, host.width / 2);
15024
+ const fit = solveRingFit({
15025
+ width: card.offsetWidth,
15026
+ height: card.offsetHeight,
15027
+ perspective: pixels(style.perspective, Infinity),
15028
+ tiltDeg,
15029
+ // The stylesheet's own selector for the concave placement, read rather than threaded through
15030
+ // as a parameter: the name is what `carousel.css` branches on, so it is what this must too.
15031
+ inside: el.matches("[data-kui-fx~='carousel-3d-inside']"),
15032
+ // `facing:camera` counter-rotates the slot's element *children* (`carousel.css`); a slot with
15033
+ // none — bare text — is drawn at its radial angle, and is fitted as the radial card it is.
15034
+ facing: card.firstElementChild ? options.facing : "radial",
15035
+ ...reachableAngles(options.arcDeg, count),
15036
+ originOffset: host.left + host.width / 2 - originX,
15037
+ minX: bounds.left - originX,
15038
+ maxX: bounds.right - originX
15039
+ });
15040
+ styles.set(RING_FIT_PROPERTY, Number.isFinite(fit) ? `${fit}px` : "");
15041
+ };
15042
+ const frame = frameScheduler(ctx.win, solve);
15043
+ const watch = watchElementSize(ctx.win, frame.request);
15044
+ watch.observe(el);
15045
+ watch.observe(el.ownerDocument.documentElement);
15046
+ return {
15047
+ render(nodes, total) {
15048
+ const first = nodes[0];
15049
+ if (first === card && total === count) return;
15050
+ card = first;
15051
+ count = total;
15052
+ solve();
15053
+ },
15054
+ release() {
15055
+ watch.disconnect();
15056
+ frame.cancel();
15057
+ }
15058
+ };
15059
+ }
15060
+
15061
+ // src/effects/carousel/stack.ts
15062
+ function stackFace(offset, drift2, count, depth) {
15063
+ const ahead = stackPlace(offset, count) - drift2;
15064
+ return ahead > -0.5 && ahead < depth + 1 ? "front" : "back";
15065
+ }
15066
+ function stackPlace(offset, count) {
15067
+ return wrapPlace(offset + 1, count) - 1;
15068
+ }
15069
+ var STACK_PARAMETERS = {
15070
+ ...DECK_PARAMETERS,
15071
+ /*
15072
+ * How far each card behind the front one steps sideways. Positive is to the right, negative to
15073
+ * the left. A length rather than a percentage because the host reserves `depth × shift` of room
15074
+ * on that side (cards moved by a transform take no layout space) and a padding percentage would
15075
+ * resolve against a different box than a translate percentage does.
15076
+ */
15077
+ shift: { type: "length", default: "48px", cssProperty: "--kui-stack-shift" },
15078
+ /** How far each card behind steps upward. Negative recedes downward instead. */
15079
+ rise: { type: "length", default: "36px", cssProperty: "--kui-stack-rise" },
15080
+ /** Scale lost per place behind the front: `0.08` makes the third card back 76% size. */
15081
+ shrink: { type: "number", default: "0.08", cssProperty: "--kui-stack-shrink", finite: true, minimum: 0, maximum: 0.3 },
15082
+ /** Blur added per place behind the front. The front card is always sharp. */
15083
+ blur: { type: "length", default: "2px", cssProperty: "--kui-stack-blur" },
15084
+ /** Opacity lost per place behind the front. */
15085
+ fade: { type: "number", default: "0.2", cssProperty: "--kui-stack-fade", finite: true, minimum: 0, maximum: 1 },
15086
+ /*
15087
+ * How many cards show behind the front one. The rest wait, hidden, at the back — `visibility`,
15088
+ * not only `opacity`, so a card nobody can see is also not a tab stop or a click target.
15089
+ */
15090
+ depth: { type: "number", default: "3", cssProperty: "--kui-stack-depth", integer: true, minimum: 1, maximum: 8 }
15091
+ };
15092
+ function prepareSpatialStack(el, params, ctx) {
15093
+ const depth = params.num("depth", 3);
15094
+ return prepareSpatialDeck(el, params, ctx, {
15095
+ id: "spatial-stack",
15096
+ label: "carousel-stack",
15097
+ faceOf: (place3, offset, count) => stackFace(offset, offset - place3, count, depth),
15098
+ placeOf: stackPlace,
15099
+ measure: false
15100
+ });
15101
+ }
15102
+ var SPATIAL_STACK_PRIMITIVE = {
15103
+ id: "spatial-stack",
15104
+ renderer: "javascript",
15105
+ // What `deck.ts` writes on the host: the cards (the subtree), the step index, the drag.
15106
+ channels: [CHANNEL.skew, "discrete", CHANNEL.opacity, CHANNEL.filter, ...SPATIAL_DECK_CHANNELS],
15107
+ parameters: STACK_PARAMETERS,
15108
+ supportedTimelines: ["time"],
15109
+ supportedActivations: ["load", "enter", "click", "manual"],
15110
+ defaultActivation: "load",
15111
+ perfClass: "compositor",
15112
+ reducedMotion: "shorten",
15113
+ prepare: deferPrepare(prepareSpatialStack)
15114
+ };
15115
+ var STACK_PRESETS = [
15116
+ {
15117
+ name: "carousel-stack",
15118
+ primitive: "spatial-stack",
15119
+ requiresOwnSubtree: true,
15120
+ phase: "idle"
15121
+ }
15122
+ ];
15123
+
15124
+ // src/effects/carousel/index.ts
15125
+ var QUARTER_TURN_DEG = 90;
15126
+ var FLATTENING = [
15127
+ ["overflow", (value) => value !== "" && value !== "visible"],
15128
+ ["clip-path", (value) => value !== "" && value !== "none"],
15129
+ ["opacity", (value) => value !== "" && Number(value) < 1],
15130
+ ["filter", (value) => value !== "" && value !== "none"],
15131
+ ["backdrop-filter", (value) => value !== "" && value !== "none"],
15132
+ ["isolation", (value) => value === "isolate"],
15133
+ ["mix-blend-mode", (value) => value !== "" && value !== "normal"],
15134
+ ["mask-image", (value) => value !== "" && value !== "none"]
15135
+ ];
15136
+ function warnFlattenedHost(el, ctx) {
15137
+ const found = flatteningDeclaration(el, ctx);
15138
+ if (!found) return;
15139
+ ctx.warn(
15140
+ `carousel: the ring's own <${el.localName}> has ${found.property}: ${found.value}, which flattens transform-style: preserve-3d \u2014 the ring will render as flat overlapping cards. Move that property to a wrapper around the ring: an ancestor does not flatten it.`
15141
+ );
15142
+ }
15143
+ function flatteningDeclaration(node, ctx) {
15144
+ const style = ctx.win.getComputedStyle?.(node);
15145
+ if (!style) return null;
15146
+ for (const [property2, flattens] of FLATTENING) {
15147
+ const value = style.getPropertyValue(property2);
15148
+ if (flattens(value)) return { property: property2, value };
15149
+ }
15150
+ return null;
15151
+ }
15152
+ function faceAt(angleDeg) {
15153
+ return Math.abs(normaliseDegrees(angleDeg)) >= QUARTER_TURN_DEG ? "back" : "front";
15154
+ }
15155
+ function normaliseDegrees(angleDeg) {
15156
+ const wrapped2 = (angleDeg % 360 + 360) % 360;
15157
+ return wrapped2 > 180 ? wrapped2 - 360 : wrapped2;
15158
+ }
15159
+ var RING_PARAMETERS = {
15160
+ ...DECK_PARAMETERS,
15161
+ /*
15162
+ * A real CSS `<angle>`, not a normalised scalar.
15163
+ *
15164
+ * A designer specifies fifteen degrees; nobody specifies 0.375 of an unstated maximum. A
15165
+ * normalised knob also bakes taste into a coordinate — the maximum *is* an opinion about how much
15166
+ * tilt is too much, expressed as a unit — and it cannot be typed in the spelling the rest of the
15167
+ * attribute grammar uses (`0.05turn` is a legal angle and means something exact). Legibility is
15168
+ * still bounded, but the bound is applied to the angle, in `carousel.css`, where it is one
15169
+ * `clamp()` an author can see in devtools rather than a hidden rescale.
15170
+ *
15171
+ * Sign convention: **positive tilts the camera up and over the ring**, looking down at it. That
15172
+ * is the opposite sign from the `rotateX()` it compiles to — see `carousel.css` for the
15173
+ * derivation — and the inversion is deliberate: "18 degrees above" is the sentence a designer
15174
+ * says, and it should not be spelled `-18deg` because of which way a matrix happens to turn.
15175
+ */
15176
+ tilt: { type: "angle", default: "0deg", cssProperty: "--kui-tilt" },
15177
+ /*
15178
+ * How far the ring spreads. `360deg` is a full ring; anything less is a slice.
15179
+ *
15180
+ * This is the parameter the concave name exists around. From the centre of a *full* ring most of
15181
+ * it is behind your head, so a 360-degree spread there is not a design choice but a way of hiding
15182
+ * two thirds of the content — the inside name defaults to a slice for that reason.
15183
+ */
15184
+ arc: { type: "angle", default: "360deg", cssProperty: "--kui-arc" },
15185
+ /*
15186
+ * The ring's radius, or unset for one derived from the content.
15187
+ *
15188
+ * Empty default, not a number, and for the same reason `step-progress`'s `steps` is empty:
15189
+ * `readParams` fills every declared default in unconditionally, so an empty one is the only way
15190
+ * to tell "the author said nothing" from "the author said 320px". Unset, `carousel.css` computes
15191
+ * the circumradius of a regular polygon with this many sides and this much side length —
15192
+ * `(width + gap) / (2 * tan(step / 2))` — which is the arrangement where neighbours just touch.
15193
+ * Every consumer solving that by hand was the alternative, and they would all have solved it
15194
+ * differently.
15195
+ *
15196
+ * The width half of that has to come from JavaScript: CSS can compute with a measurement but
15197
+ * cannot take one. See `measureItem` in `deck.ts`.
15198
+ *
15199
+ * Authored or derived, a depth ring's radius is a ceiling: a ring that would push its page
15200
+ * sideways is pulled in exactly as far as it has to be (`fit.ts`, `--kui-ring-fit`), and one that
15201
+ * already fits — or sits inside an ancestor that clips it — keeps this radius to the pixel.
15202
+ */
15203
+ radius: { type: "length", default: "", cssProperty: "--kui-radius" },
15204
+ /** Breathing room between neighbours, spent by the derived radius above. */
15205
+ gap: { type: "length", default: "24px", cssProperty: "--kui-gap" },
15206
+ /** Camera distance. Shorter is a wider lens: more foreshortening, more drama, more distortion. */
15207
+ perspective: { type: "length", default: "1600px", cssProperty: "--kui-perspective" },
15208
+ /*
15209
+ * Whether a slot keeps its own outward orientation, or turns to keep facing the viewer.
15210
+ *
15211
+ * `radial` is the ring's natural state and needs nothing: a slot placed by
15212
+ * `rotateY(θ) translateZ(R)` already faces outward along its own radius, which is why the live
15213
+ * one faces you. It is what you want for a cover-flow, and it is what makes the far side of the
15214
+ * ring read as the far side.
15215
+ *
15216
+ * `camera` counter-rotates each slot's *child* — never the slot itself; the placement transform
15217
+ * and the billboard have to stay on separate boxes or every later change to one silently
15218
+ * multiplies into the other — so text stays readable at every position, which is what a raised
15219
+ * camera needs. Costs one element of markup per slide; see `docs/catalog.md`.
15220
+ */
15221
+ facing: {
15222
+ type: "keyword",
15223
+ default: "radial",
15224
+ cssProperty: "--kui-facing",
15225
+ keywords: ["radial", "camera"]
15226
+ },
15227
+ /*
15228
+ * Which plane the ring lies in: `depth` (the default — a carousel you look *at*, turning about a
15229
+ * vertical pole) or `screen` (a clock face, turning about the axis that points at you).
15230
+ *
15231
+ * ## Why a parameter of this primitive, and not a new one
15232
+ *
15233
+ * A flat ring of cards around a centred word is the same object as the 3D ring seen from its
15234
+ * pole: N slots, evenly spaced by `arc / count`, placed from `--kui-offset` and the shared drift,
15235
+ * advanced by the same drag, keys, controls and `spin:`. Every number this primitive publishes
15236
+ * means the same thing in both planes; only the three transform functions that turn a place into
15237
+ * a position differ, and those live in `carousel.css` anyway. A second primitive would have
15238
+ * duplicated the whole index to change one stylesheet rule and one face test.
15239
+ *
15240
+ * It is a parameter rather than only a name (unlike convex/concave, which differ in every
15241
+ * default and so earned two names) because the runtime has to know it: a flat slot is never
15242
+ * turned away from the viewer, so the quarter-turn face test that stops the back of a 3D ring
15243
+ * swallowing clicks would, on a clock face, disable every card in the bottom half. The name that
15244
+ * ships it, `carousel-orbit`, sets it; the stylesheet gates on the published attribute rather
15245
+ * than the name, so `carousel-3d plane:screen` is equally honoured — same rule as `facing:`.
15246
+ *
15247
+ * `facing:` keeps its meaning here. `radial` turns each card with the ring, top edge outward like
15248
+ * the numerals on a watch bezel; `camera` keeps every card upright. In the screen plane upright
15249
+ * needs no child counter-rotation at all — the slot is *translated* to its place on the circle
15250
+ * instead of rotated there — so it costs no extra markup, unlike the 3D billboard.
15251
+ */
15252
+ plane: {
15253
+ type: "keyword",
15254
+ default: "depth",
15255
+ cssProperty: "--kui-ring-plane",
15256
+ keywords: ["depth", "screen"]
15257
+ }
15258
+ };
15259
+ function prepareSpatialRing(el, params, ctx) {
15260
+ const plane = params.text("plane", "depth");
15261
+ if (plane === "depth") warnFlattenedHost(el, ctx);
15262
+ const arcDeg = degreesOf(params.text("arc", "360deg"), 360);
15263
+ const faceOf = (place3, _offset, count) => plane === "screen" ? "front" : faceAt(place3 * (arcDeg / count));
15264
+ return prepareSpatialDeck(el, params, ctx, {
15265
+ id: "spatial-ring",
15266
+ label: "carousel",
15267
+ faceOf,
15268
+ /*
15269
+ * A derived radius needs a card width; a flat ring also needs a card's size to reserve its own
15270
+ * circle (the host is sized to it, since transformed slots take no layout space), so it measures
15271
+ * even when the author named a radius.
15272
+ */
15273
+ measure: params.text("radius") === "" || plane === "screen",
15274
+ hostAttributes: {
15275
+ /*
15276
+ * `facing:` and `plane:` reach CSS as attributes, not only as the custom properties their
15277
+ * specs declare. A stylesheet cannot select on the *value* of a custom property — `@container
15278
+ * style(--kui-facing: camera)` can, and is not supported widely enough to hang a documented
15279
+ * parameter on — so the one thing a selector needs is published in the one form a selector
15280
+ * can read. Same pairing, for the same reason, as the number/attribute pair `step-marking.ts`
15281
+ * publishes for the ring place.
15282
+ */
15283
+ "data-kui-ring-facing": params.text("facing", "radial"),
15284
+ "data-kui-ring-plane": plane
15285
+ },
15286
+ /*
15287
+ * The fit to the page (`fit.ts`). Depth plane only: a flat ring already reserves and clamps to
15288
+ * its own square (`carousel.css`, `[data-kui-ring-plane='screen']`), and its cards never leave
15289
+ * the plane that square is measured in.
15290
+ */
15291
+ attach: plane === "depth" ? (styles) => createRingFit({
15292
+ el,
15293
+ ctx,
15294
+ styles,
15295
+ arcDeg,
15296
+ tiltParamDeg: degreesOf(params.text("tilt", "0deg"), 0),
15297
+ facing: params.text("facing", "radial") === "camera" ? "camera" : "radial"
15298
+ }) : void 0
15299
+ });
15300
+ }
13828
15301
  function degreesOf(value, fallbackDeg) {
13829
15302
  const match = /^(-?[\d.]+)(deg|rad|turn|grad)?$/i.exec(value.trim());
13830
15303
  if (!match) return fallbackDeg;
@@ -13844,7 +15317,15 @@ var kuinetic = (() => {
13844
15317
  var SPATIAL_RING_PRIMITIVE = {
13845
15318
  id: "spatial-ring",
13846
15319
  renderer: "javascript",
13847
- channels: [CHANNEL.skew, "discrete"],
15320
+ // What `deck.ts` writes on the host (the cards, the step index, the drag), then the two
15321
+ // attributes only the ring's own layout publishes.
15322
+ channels: [
15323
+ CHANNEL.skew,
15324
+ "discrete",
15325
+ ...SPATIAL_DECK_CHANNELS,
15326
+ attributeChannel("data-kui-ring-facing"),
15327
+ attributeChannel("data-kui-ring-plane")
15328
+ ],
13848
15329
  parameters: RING_PARAMETERS,
13849
15330
  supportedTimelines: ["time"],
13850
15331
  // `load`, not `enter`: a carousel that only wires its arrows once scrolled into view is broken,
@@ -13855,7 +15336,7 @@ var kuinetic = (() => {
13855
15336
  reducedMotion: "shorten",
13856
15337
  prepare: deferPrepare(prepareSpatialRing)
13857
15338
  };
13858
- var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE];
15339
+ var CAROUSEL_PRIMITIVES = [SPATIAL_RING_PRIMITIVE, SPATIAL_STACK_PRIMITIVE];
13859
15340
  var CAROUSEL_PRESETS = [
13860
15341
  {
13861
15342
  name: "carousel-3d",
@@ -13892,10 +15373,31 @@ var kuinetic = (() => {
13892
15373
  params: { arc: "120deg", facing: "camera", perspective: "900px" },
13893
15374
  requiresOwnSubtree: true,
13894
15375
  phase: "idle"
15376
+ },
15377
+ /*
15378
+ * The flat ring: cards on a clock face around whatever sits in the middle of the host (a word, a
15379
+ * logo — anything the `target:` does not name stays centred and off the ring). `plane:screen` is
15380
+ * the whole difference; see the parameter's note for why that is a parameter of this primitive and
15381
+ * not a new one.
15382
+ *
15383
+ * Named for what it does rather than which way it goes — `orbit` is the motion, and it has no
15384
+ * direction to name (the directional-naming rule, "named by travel", is about entrances that
15385
+ * *have* one; a ring's direction is the sign of `spin:`). Not `carousel-3d-*`, because it is not
15386
+ * three-dimensional, and a name that promised depth would send people looking for a `tilt:`.
15387
+ *
15388
+ * `facing:camera` by default: upright cards read as a set of things; radially turned ones read as
15389
+ * a dial, which is the less common ask and one keyword away.
15390
+ */
15391
+ {
15392
+ name: "carousel-orbit",
15393
+ primitive: "spatial-ring",
15394
+ params: { plane: "screen", facing: "camera" },
15395
+ requiresOwnSubtree: true,
15396
+ phase: "idle"
13895
15397
  }
13896
15398
  ];
13897
15399
  function registerCarousel(registry) {
13898
- return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS);
15400
+ return registry.registerPrimitives(CAROUSEL_PRIMITIVES).registerPresets(CAROUSEL_PRESETS).registerPresets(STACK_PRESETS);
13899
15401
  }
13900
15402
 
13901
15403
  // src/effects/tween/properties.ts
@@ -14451,7 +15953,10 @@ var kuinetic = (() => {
14451
15953
  keywords: [...DEVICE_FRAME_KINDS]
14452
15954
  },
14453
15955
  color: { type: "color", default: "#111", cssProperty: "--kui-device-color" },
14454
- radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" }
15956
+ radius: { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-radius" },
15957
+ // Its own knob rather than a reading of `radius:`: the frame's corner and the screen's are two
15958
+ // shapes, and the derived screen value already follows `radius:` when this is left unwritten.
15959
+ "screen-radius": { type: "length", default: RADIUS_UNSET, cssProperty: "--kui-device-screen-radius" }
14455
15960
  };
14456
15961
  function prepareDeviceFrame(el, params) {
14457
15962
  const state = createAttributeLedger(el);
@@ -14464,8 +15969,9 @@ var kuinetic = (() => {
14464
15969
  // Exclusive ownership of the frame chrome. Its static box-model declarations are outside
14465
15970
  // the channel-property audit, as they are for the existing catalog. The chrome also reaches
14466
15971
  // into the direct media child (`object-fit: cover` etc.); that is a `requiresOwnSubtree`
14467
- // question, as with the other showcase widgets.
14468
- channels: ["frame"],
15972
+ // question, as with the other showcase widgets. The frame kind it stamps on the host is an
15973
+ // attribute it writes, so it claims that too.
15974
+ channels: ["frame", attributeChannel("data-kui-device")],
14469
15975
  parameters: deviceFrameParams,
14470
15976
  // `'layout'`: the bezel's `border`+`padding` are box-model properties added once, at
14471
15977
  // activation, which is a real (if one-time) layout — not the "transform/opacity only" budget
@@ -14500,10 +16006,12 @@ var kuinetic = (() => {
14500
16006
  state.lock = ledger;
14501
16007
  }
14502
16008
  function clearContent(state) {
14503
- state.current?.onClose?.();
16009
+ const closing = state.current;
16010
+ closing?.onClose?.();
14504
16011
  state.current = void 0;
14505
16012
  state.currentOwner = void 0;
14506
16013
  state.slot?.replaceChildren();
16014
+ if (closing?.opener) announceViewer(state.doc, closing.opener, false);
14507
16015
  }
14508
16016
  function onClose(state) {
14509
16017
  state.dialog?.classList.remove("is-open");
@@ -14522,11 +16030,9 @@ var kuinetic = (() => {
14522
16030
  }, state.current.reducedMotion ? 0 : state.current.duration);
14523
16031
  }
14524
16032
  function clickedOutside(state, target) {
14525
- const content = state.current.node;
14526
- if (content.classList.contains("kui-lightbox-gallery")) {
14527
- return !content.contains(target) || !target.matches("img") && !target.closest("button");
14528
- }
14529
- return !content.contains(target) && !target.closest("button");
16033
+ const { node, inside } = state.current;
16034
+ if (inside) return !inside(target);
16035
+ return !node.contains(target) && !target.closest("button");
14530
16036
  }
14531
16037
  function forceReflow(element) {
14532
16038
  return element.offsetWidth;
@@ -14576,6 +16082,7 @@ var kuinetic = (() => {
14576
16082
  dialog.scrollTop = 0;
14577
16083
  forceReflow(dialog);
14578
16084
  dialog.classList.add("is-open");
16085
+ if (content.opener) announceViewer(state.doc, content.opener, true);
14579
16086
  }
14580
16087
  function closeNow(state) {
14581
16088
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -14655,25 +16162,31 @@ var kuinetic = (() => {
14655
16162
  }
14656
16163
 
14657
16164
  // src/showcase/lightbox.ts
16165
+ var DEFAULT_DURATION_MS = 280;
16166
+ var DEFAULT_SCALE = "0.965";
16167
+ var DEFAULT_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
14658
16168
  var paramsSchema = {
14659
- media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video"] },
16169
+ media: { type: "keyword", default: "image", cssProperty: "--kui-lightbox-media", keywords: ["image", "video", "mixed"] },
14660
16170
  target: { type: "text", default: "", cssProperty: "--kui-target" },
14661
16171
  scope: SCOPE_PARAM,
14662
- scale: { type: "number", default: "0.965", cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
14663
- duration: { type: "time", default: "280ms", cssProperty: "--kui-lightbox-duration" },
14664
- ease: { type: "easing", default: "cubic-bezier(0.22, 1, 0.36, 1)", cssProperty: "--kui-lightbox-ease" },
16172
+ scale: { type: "number", default: DEFAULT_SCALE, cssProperty: "--kui-from-scale", finite: true, minimum: 0 },
16173
+ duration: { type: "time", default: `${DEFAULT_DURATION_MS}ms`, cssProperty: "--kui-lightbox-duration" },
16174
+ ease: { type: "easing", default: DEFAULT_EASE, cssProperty: "--kui-lightbox-ease" },
14665
16175
  loop: { type: "keyword", default: "true", cssProperty: "--kui-lightbox-loop", keywords: ["true", "false"] },
14666
16176
  aspect: { type: "keyword", default: "", cssProperty: "--kui-lightbox-aspect", keywords: ["wide", "tall", "square"] },
14667
16177
  caption: { type: "keyword", default: "figcaption", cssProperty: "--kui-lightbox-caption", keywords: ["figcaption", "alt", "title", "none"] }
14668
16178
  };
14669
16179
  function defaultTargets(el, media) {
14670
- if (el.matches("a[href]") || media === "image" && el.matches("img")) return [el];
16180
+ if (el.matches("a[href]") || media !== "video" && el.matches("img")) return [el];
14671
16181
  return [...el.querySelectorAll(media === "video" ? ":scope a[href]" : ":scope a[href], :scope img")];
14672
16182
  }
16183
+ function effectName(media) {
16184
+ return media === "video" ? "video-lightbox" : "lightbox";
16185
+ }
14673
16186
  function targets(el, params, ctx, media) {
14674
16187
  const authored = params.text("target");
14675
16188
  if (!authored) return defaultTargets(el, media);
14676
- const selector = resolveTarget(authored, ctx, media === "video" ? "video-lightbox" : "lightbox");
16189
+ const selector = resolveTarget(authored, ctx, effectName(media));
14677
16190
  return selector ? queryScoped(el, ctx, selector, scopeParam(params, "self")) : [];
14678
16191
  }
14679
16192
  function imageTrigger(target) {
@@ -14684,52 +16197,137 @@ var kuinetic = (() => {
14684
16197
  }
14685
16198
  function captionFor(trigger, image, source) {
14686
16199
  if (source === "none") return "";
14687
- if (source === "alt") return image.alt;
14688
- if (source === "title") return (trigger.getAttribute("title") || image.title).trim();
14689
- return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || image.alt;
16200
+ const alt = image?.alt ?? "";
16201
+ if (source === "alt") return alt;
16202
+ if (source === "title") return titleOf(trigger, image);
16203
+ return trigger.closest("figure")?.querySelector("figcaption")?.textContent?.trim() || alt;
16204
+ }
16205
+ function titleOf(trigger, image) {
16206
+ return (trigger.getAttribute("title") || image?.title || "").trim();
14690
16207
  }
14691
16208
  function imageItem(trigger, captionSource) {
14692
16209
  const image = imageIn(trigger);
14693
16210
  if (!image) return null;
14694
16211
  const link = trigger instanceof HTMLAnchorElement ? trigger : null;
14695
16212
  return {
16213
+ kind: "image",
14696
16214
  trigger,
14697
16215
  image,
14698
16216
  src: link?.href ?? (image.currentSrc || image.src),
14699
16217
  caption: captionFor(trigger, image, captionSource)
14700
16218
  };
14701
16219
  }
14702
- function imageItems(targets2, captionSource) {
16220
+ function videoTitle(link, poster) {
16221
+ return link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
16222
+ }
16223
+ function videoItem(link, captionSource) {
16224
+ const source = resolveMediaSource(link.getAttribute("href") ?? "");
16225
+ if (!source) return null;
16226
+ const poster = link.querySelector("img");
16227
+ return { kind: "video", trigger: link, source, title: videoTitle(link, poster), caption: captionFor(link, poster, captionSource) };
16228
+ }
16229
+ function itemFor(trigger, media, captionSource) {
16230
+ if (media !== "image" && trigger instanceof HTMLAnchorElement) {
16231
+ const video = videoItem(trigger, captionSource);
16232
+ if (video || media === "video") return video;
16233
+ }
16234
+ return imageItem(trigger, captionSource);
16235
+ }
16236
+ function triggersFor(found, media) {
14703
16237
  const seen = /* @__PURE__ */ new Set();
14704
- const items = [];
14705
- for (const target of targets2) {
14706
- const trigger = imageTrigger(target);
16238
+ const triggers = [];
16239
+ for (const target of found) {
16240
+ const trigger = media === "video" ? target : imageTrigger(target);
14707
16241
  if (seen.has(trigger)) continue;
14708
- const item = imageItem(trigger, captionSource);
14709
- if (!item) continue;
16242
+ if (!usable(trigger, media)) continue;
14710
16243
  seen.add(trigger);
14711
- items.push(item);
16244
+ triggers.push(trigger);
14712
16245
  }
14713
- return items;
16246
+ return triggers;
16247
+ }
16248
+ function usable(trigger, media) {
16249
+ const link = trigger instanceof HTMLAnchorElement;
16250
+ if (media === "video") return link;
16251
+ const image = imageIn(trigger) !== null;
16252
+ return media === "image" ? image : link || image;
14714
16253
  }
14715
16254
  function modalOptions(params, ctx) {
14716
16255
  return {
14717
- duration: params.timing.durationMs ?? params.ms("duration", 280),
14718
- scale: params.text("scale", "0.965"),
14719
- ease: params.timing.easing ?? params.text("ease", "cubic-bezier(0.22, 1, 0.36, 1)"),
16256
+ duration: params.timing.durationMs ?? params.ms("duration", DEFAULT_DURATION_MS),
16257
+ scale: params.text("scale", DEFAULT_SCALE),
16258
+ ease: params.timing.easing ?? params.text("ease", DEFAULT_EASE),
14720
16259
  reducedMotion: ctx.reducedMotion
14721
16260
  };
14722
16261
  }
14723
- function showImage(item, view, caption) {
16262
+ function showImage(item, view) {
14724
16263
  view.src = item.src;
14725
16264
  view.alt = item.image.alt;
14726
- caption.textContent = item.caption;
14727
- caption.hidden = item.caption === "";
14728
16265
  const ratio = item.image.naturalWidth ? item.image.naturalHeight / item.image.naturalWidth : 1;
14729
16266
  view.className = ratio > 1.5 ? "is-tall" : "is-fit";
14730
16267
  }
16268
+ function videoFrame(item, aspect, doc) {
16269
+ const frame = doc.createElement("div");
16270
+ frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect || item.source.aspect}`;
16271
+ if (item.source.kind === "file") {
16272
+ const video = doc.createElement("video");
16273
+ video.src = item.source.embedUrl;
16274
+ video.controls = true;
16275
+ video.autoplay = true;
16276
+ video.playsInline = true;
16277
+ video.setAttribute("aria-label", item.title);
16278
+ frame.append(video);
16279
+ } else {
16280
+ const iframe = doc.createElement("iframe");
16281
+ iframe.src = item.source.embedUrl;
16282
+ iframe.title = item.title;
16283
+ iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
16284
+ iframe.allowFullscreen = true;
16285
+ frame.append(iframe);
16286
+ }
16287
+ return frame;
16288
+ }
16289
+ function pauseVideo(video) {
16290
+ if (video.readyState > 0) video.pause();
16291
+ }
16292
+ function stopMedia(frame) {
16293
+ const video = frame.querySelector("video");
16294
+ if (video) {
16295
+ pauseVideo(video);
16296
+ video.removeAttribute("src");
16297
+ }
16298
+ frame.replaceChildren();
16299
+ }
16300
+ function leave(state, node) {
16301
+ if (node === state.view) return;
16302
+ const video = node.querySelector("video");
16303
+ if (video) pauseVideo(video);
16304
+ else node.replaceChildren();
16305
+ }
16306
+ function land(state, item) {
16307
+ if (item.kind === "image") {
16308
+ showImage(item, state.view);
16309
+ return state.view;
16310
+ }
16311
+ const cached2 = state.players.get(item);
16312
+ if (cached2) {
16313
+ void cached2.querySelector("video").play().catch(() => {
16314
+ });
16315
+ return cached2;
16316
+ }
16317
+ const frame = videoFrame(item, state.aspect, state.figure.ownerDocument);
16318
+ if (item.source.kind === "file") state.players.set(item, frame);
16319
+ return frame;
16320
+ }
14731
16321
  function renderGallery(state, focus) {
14732
- showImage(state.items[state.index], state.view, state.caption);
16322
+ const item = state.items[state.index];
16323
+ if (state.shown?.item !== item) {
16324
+ if (state.shown) leave(state, state.shown.node);
16325
+ const node = land(state, item);
16326
+ state.shown = { item, node };
16327
+ state.caption.textContent = item.caption;
16328
+ state.caption.hidden = item.caption === "";
16329
+ state.figure.replaceChildren(node, state.caption);
16330
+ }
14733
16331
  state.counter.textContent = `${state.index + 1} of ${state.items.length}`;
14734
16332
  state.previous.disabled = !state.loop && state.index === 0;
14735
16333
  state.next.disabled = !state.loop && state.index === state.items.length - 1;
@@ -14747,6 +16345,7 @@ var kuinetic = (() => {
14747
16345
  }
14748
16346
  function galleryKey(state, event) {
14749
16347
  if (state.items.length < 2) return;
16348
+ if (event.target.closest("video")) return;
14750
16349
  if (event.key === "ArrowLeft") moveGallery(state, -1, true);
14751
16350
  else if (event.key === "ArrowRight") moveGallery(state, 1, true);
14752
16351
  else if (event.key === "Home") {
@@ -14758,8 +16357,40 @@ var kuinetic = (() => {
14758
16357
  } else return;
14759
16358
  event.preventDefault();
14760
16359
  }
14761
- function galleryContent(items, initial, loop2, options) {
14762
- const doc = items[initial].image.ownerDocument;
16360
+ function swipeGallery(state, box) {
16361
+ const viewport = box.ownerDocument.defaultView?.visualViewport ?? null;
16362
+ const zoomed = () => (viewport?.scale ?? 1) > 1.01;
16363
+ const sync = () => {
16364
+ box.classList.toggle("is-swipeable", !zoomed());
16365
+ };
16366
+ sync();
16367
+ viewport?.addEventListener("resize", sync);
16368
+ const stop = recognise(box, {
16369
+ onSwipe(direction) {
16370
+ if (direction === "left" || direction === "right") moveGallery(state, swipeStep(direction), false);
16371
+ }
16372
+ }, {
16373
+ // A press on a button still clicks it: capture is taken only once the finger has moved.
16374
+ capturePointer: "drag",
16375
+ accept: (event) => event.pointerType !== "mouse" && !zoomed() && event.target.closest("video") === null
16376
+ });
16377
+ return () => {
16378
+ stop();
16379
+ viewport?.removeEventListener("resize", sync);
16380
+ };
16381
+ }
16382
+ function closeViewer(state) {
16383
+ state.view.removeAttribute("src");
16384
+ for (const frame of state.players.values()) stopMedia(frame);
16385
+ if (state.shown && state.shown.node !== state.view) stopMedia(state.shown.node);
16386
+ }
16387
+ function vocabulary(items) {
16388
+ const kinds = new Set(items.map((item) => item.kind));
16389
+ if (kinds.size > 1) return { noun: "item", label: "Media viewer" };
16390
+ return kinds.has("video") ? { noun: "video", label: "Video viewer" } : { noun: "image", label: "Image viewer" };
16391
+ }
16392
+ function galleryContent(items, initial, options) {
16393
+ const doc = items[initial].trigger.ownerDocument;
14763
16394
  const box = doc.createElement("div");
14764
16395
  box.className = "kui-lightbox-gallery";
14765
16396
  const figure = doc.createElement("figure");
@@ -14767,7 +16398,6 @@ var kuinetic = (() => {
14767
16398
  const view = doc.createElement("img");
14768
16399
  view.decoding = "async";
14769
16400
  const caption = doc.createElement("figcaption");
14770
- figure.append(view, caption);
14771
16401
  box.append(figure);
14772
16402
  const counter = doc.createElement("span");
14773
16403
  counter.className = "kui-lightbox-counter";
@@ -14777,149 +16407,244 @@ var kuinetic = (() => {
14777
16407
  previous.type = next.type = "button";
14778
16408
  previous.textContent = "\u2039";
14779
16409
  next.textContent = "\u203A";
14780
- previous.setAttribute("aria-label", "Previous image");
14781
- next.setAttribute("aria-label", "Next image");
16410
+ const { noun, label } = vocabulary(items);
16411
+ previous.setAttribute("aria-label", `Previous ${noun}`);
16412
+ next.setAttribute("aria-label", `Next ${noun}`);
14782
16413
  previous.className = "kui-lightbox-prev";
14783
16414
  next.className = "kui-lightbox-next";
14784
- const state = { items, index: initial, loop: loop2, figure, view, caption, counter, previous, next };
16415
+ const state = {
16416
+ items,
16417
+ index: initial,
16418
+ loop: options.loop,
16419
+ aspect: options.aspect,
16420
+ figure,
16421
+ view,
16422
+ caption,
16423
+ counter,
16424
+ previous,
16425
+ next,
16426
+ players: /* @__PURE__ */ new Map()
16427
+ };
14785
16428
  if (items.length > 1) {
14786
16429
  box.append(previous, next, counter);
14787
16430
  previous.addEventListener("click", () => clickGallery(state, -1, previous));
14788
16431
  next.addEventListener("click", () => clickGallery(state, 1, next));
14789
16432
  }
14790
16433
  renderGallery(state, false);
16434
+ const unswipe = items.length > 1 ? swipeGallery(state, box) : void 0;
16435
+ const { duration, scale, ease, reducedMotion } = options;
14791
16436
  return {
14792
- ...options,
16437
+ duration,
16438
+ scale,
16439
+ ease,
16440
+ reducedMotion,
14793
16441
  node: box,
14794
- label: "Image viewer",
14795
- onClose: () => view.removeAttribute("src"),
16442
+ label,
16443
+ // The media and the controls keep the viewer open; the empty figure around them closes it.
16444
+ inside: (target) => box.contains(target) && target.closest("img, video, iframe, button") !== null,
16445
+ onClose: () => {
16446
+ unswipe?.();
16447
+ closeViewer(state);
16448
+ },
14796
16449
  onKey: (event) => galleryKey(state, event)
14797
16450
  };
14798
16451
  }
14799
- function stopMedia(frame) {
14800
- const video = frame.querySelector("video");
14801
- if (video) {
14802
- if (video.readyState > 0) video.pause();
14803
- video.removeAttribute("src");
14804
- }
14805
- frame.replaceChildren();
14806
- }
14807
- function videoContent(link, source, options, aspect) {
14808
- const doc = link.ownerDocument;
14809
- const frame = doc.createElement("div");
14810
- frame.className = `kui-lightbox-frame kui-lightbox-frame--${aspect}`;
14811
- const poster = link.querySelector("img");
14812
- const title = link.title || poster?.getAttribute("alt") || link.getAttribute("aria-label")?.trim() || link.textContent?.trim() || "Video";
14813
- if (source.kind === "file") {
14814
- const video = doc.createElement("video");
14815
- video.src = source.embedUrl;
14816
- video.controls = true;
14817
- video.autoplay = true;
14818
- video.playsInline = true;
14819
- video.setAttribute("aria-label", title);
14820
- frame.append(video);
14821
- } else {
14822
- const iframe = doc.createElement("iframe");
14823
- iframe.src = source.embedUrl;
14824
- iframe.title = title;
14825
- iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
14826
- iframe.allowFullscreen = true;
14827
- frame.append(iframe);
14828
- }
14829
- return { ...options, node: frame, label: "Video viewer", onClose: () => stopMedia(frame) };
14830
- }
14831
16452
  function primaryClick(event) {
14832
16453
  return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
14833
16454
  }
14834
- function wireImages(items, wiring, loop2) {
16455
+ function openAt(triggers, trigger, wiring) {
16456
+ const items = [];
16457
+ let initial = -1;
16458
+ for (const candidate of triggers) {
16459
+ const item = itemFor(candidate, wiring.media, wiring.captionSource);
16460
+ if (!item) continue;
16461
+ if (candidate === trigger) initial = items.length;
16462
+ items.push(item);
16463
+ }
16464
+ if (initial < 0) {
16465
+ const href = trigger.getAttribute("href") ?? "";
16466
+ if (!wiring.warned.has(href)) wiring.ctx.warn(`${effectName(wiring.media)} cannot embed "${href}"; the link will navigate normally`);
16467
+ wiring.warned.add(href);
16468
+ return false;
16469
+ }
16470
+ wiring.shell.open({ ...galleryContent(items, initial, wiring.options), opener: trigger });
16471
+ return true;
16472
+ }
16473
+ function wireTriggers(triggers, wiring) {
14835
16474
  const restore = [];
14836
- items.forEach((item, index) => {
14837
- const bare = item.trigger === item.image;
16475
+ for (const trigger of triggers) {
16476
+ const bare = trigger instanceof HTMLImageElement;
14838
16477
  if (bare) {
14839
- const attrs = createAttributeLedger(item.image);
16478
+ const attrs = createAttributeLedger(trigger);
14840
16479
  attrs.set("role", "button");
14841
16480
  attrs.set("tabindex", "0");
14842
- attrs.set("aria-label", item.image.alt ? `Open larger image: ${item.image.alt}` : "Open larger image");
16481
+ attrs.set("aria-label", trigger.alt ? `Open larger image: ${trigger.alt}` : "Open larger image");
14843
16482
  restore.push(() => attrs.restore());
14844
- item.image.addEventListener("keydown", (event) => {
16483
+ trigger.addEventListener("keydown", (event) => {
14845
16484
  if (event.key !== "Enter" && event.key !== " ") return;
14846
16485
  event.preventDefault();
14847
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
16486
+ openAt(triggers, trigger, wiring);
14848
16487
  }, { signal: wiring.signal });
14849
16488
  }
14850
- item.trigger.addEventListener("click", (event) => {
16489
+ trigger.addEventListener("click", (event) => {
14851
16490
  if (!primaryClick(event)) return;
14852
- if (!bare) event.preventDefault();
14853
- wiring.shell.open(galleryContent(items, index, loop2, wiring.options));
16491
+ if (openAt(triggers, trigger, wiring)) event.preventDefault();
14854
16492
  }, { signal: wiring.signal });
14855
- });
16493
+ }
14856
16494
  return restore;
14857
16495
  }
14858
- function wireVideos(targets2, wiring, aspect, ctx) {
14859
- const warned = /* @__PURE__ */ new Set();
14860
- for (const target of targets2) {
14861
- if (!(target instanceof HTMLAnchorElement)) continue;
14862
- target.addEventListener("click", (event) => {
14863
- if (!primaryClick(event)) return;
14864
- const href = target.getAttribute("href") ?? "";
14865
- const source = resolveMediaSource(href);
14866
- if (!source) {
14867
- if (!warned.has(href)) ctx.warn(`video-lightbox cannot embed "${href}"; the link will navigate normally`);
14868
- warned.add(href);
14869
- return;
14870
- }
14871
- event.preventDefault();
14872
- wiring.shell.open(videoContent(target, source, wiring.options, aspect || source.aspect));
14873
- }, { signal: wiring.signal });
14874
- }
16496
+ function cardItem(card) {
16497
+ const link = card.matches("a[href]") ? card : card.querySelector("a[href]");
16498
+ return link && itemFor(link, "mixed", "figcaption") || itemFor(card, "mixed", "figcaption");
16499
+ }
16500
+ var OWN_CLICK = "button, input, select, textarea, label, summary, [contenteditable]";
16501
+ function attachDeckLightbox(request) {
16502
+ const { host, cards, doc, reducedMotion } = request;
16503
+ const shell = acquireModalShell(doc);
16504
+ const options = {
16505
+ duration: DEFAULT_DURATION_MS,
16506
+ scale: DEFAULT_SCALE,
16507
+ ease: DEFAULT_EASE,
16508
+ reducedMotion,
16509
+ loop: true,
16510
+ aspect: ""
16511
+ };
16512
+ const turnedAway = (card) => card.getAttribute("data-kui-ring-face") === "back";
16513
+ const openAt2 = (pick, faceChecked = false) => {
16514
+ const all = [...cards()];
16515
+ const card = all.find(pick);
16516
+ if (!card || !faceChecked && turnedAway(card)) return false;
16517
+ const items = [];
16518
+ let initial = -1;
16519
+ for (const candidate of all) {
16520
+ const item = cardItem(candidate);
16521
+ if (!item) continue;
16522
+ if (candidate === card) initial = items.length;
16523
+ items.push(item);
16524
+ }
16525
+ if (initial < 0) return false;
16526
+ shell.open({ ...galleryContent(items, initial, options), opener: card });
16527
+ return true;
16528
+ };
16529
+ let pressed = null;
16530
+ const cardUnder = (event) => {
16531
+ const all = [...cards()];
16532
+ const hits = typeof doc.elementsFromPoint === "function" ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
16533
+ for (const node of [...hits, event.target]) {
16534
+ const card = all.find((candidate) => candidate.contains(node));
16535
+ if (card) return turnedAway(card) ? null : { card, node };
16536
+ }
16537
+ return null;
16538
+ };
16539
+ const onPress = (event) => {
16540
+ pressed = event.button === 0 ? cardUnder(event) : null;
16541
+ };
16542
+ const onClick = (event) => {
16543
+ const press = event.detail > 0 ? pressed : null;
16544
+ pressed = null;
16545
+ const target = event.target;
16546
+ if (event.defaultPrevented || !primaryClick(event) || target.closest(OWN_CLICK)) return;
16547
+ if (press?.node.closest(OWN_CLICK)) return;
16548
+ const opened = press ? openAt2((card) => card === press.card, true) : openAt2((card) => card.contains(target));
16549
+ if (opened) event.preventDefault();
16550
+ };
16551
+ const onKeyDown = (event) => {
16552
+ const key = event.key;
16553
+ if (event.target !== host || key !== "Enter" && key !== " ") return;
16554
+ if (openAt2((card) => card.getAttribute(STEP_STATE_ATTR) === "active")) event.preventDefault();
16555
+ };
16556
+ host.addEventListener("pointerdown", onPress);
16557
+ host.addEventListener("click", onClick);
16558
+ host.addEventListener("keydown", onKeyDown);
16559
+ return () => {
16560
+ host.removeEventListener("pointerdown", onPress);
16561
+ host.removeEventListener("click", onClick);
16562
+ host.removeEventListener("keydown", onKeyDown);
16563
+ shell.release();
16564
+ };
14875
16565
  }
14876
16566
  function prepareLightbox(el, params, ctx) {
14877
16567
  const media = params.text("media", "image");
14878
- const found = targets(el, params, ctx, media);
14879
- if (found.length === 0) return () => {
16568
+ const triggers = triggersFor(targets(el, params, ctx, media), media);
16569
+ if (triggers.length === 0) return () => {
14880
16570
  };
14881
16571
  const controller = new AbortController();
14882
16572
  const shell = acquireModalShell(ctx.doc);
14883
- const wiring = { shell, options: modalOptions(params, ctx), signal: controller.signal };
14884
- let restore = [];
14885
- if (media === "video") {
14886
- wireVideos(found, wiring, params.text("aspect"), ctx);
14887
- } else {
14888
- const items = imageItems(found, params.text("caption", "figcaption"));
14889
- restore = wireImages(items, wiring, params.is("loop"));
14890
- }
16573
+ const options = { ...modalOptions(params, ctx), loop: params.is("loop"), aspect: params.text("aspect") };
16574
+ const restore = wireTriggers(triggers, {
16575
+ shell,
16576
+ options,
16577
+ signal: controller.signal,
16578
+ media,
16579
+ captionSource: params.text("caption", "figcaption"),
16580
+ ctx,
16581
+ warned: /* @__PURE__ */ new Set()
16582
+ });
14891
16583
  return continuousSetup(() => {
14892
16584
  controller.abort();
14893
16585
  for (const undo of restore) undo();
14894
16586
  shell.release();
14895
16587
  });
14896
16588
  }
16589
+ var TRIGGERS_CHANNEL = "lightbox:triggers";
14897
16590
  var LIGHTBOX_PRIMITIVE = widgetPrimitive(
14898
16591
  "lightbox",
14899
- { channels: ["widget"], parameters: paramsSchema, perfClass: "paint" },
16592
+ { channels: [TRIGGERS_CHANNEL], parameters: paramsSchema, perfClass: "paint" },
14900
16593
  withTimingContract("lightbox", { honours: ["duration", "ease"], because: "the dialog opens immediately, so it has no delay phase" }, deferPrepare(prepareLightbox))
14901
16594
  );
14902
16595
  var LIGHTBOX_PRESETS = [
14903
16596
  { name: "lightbox", primitive: "lightbox", params: { media: "image" } },
14904
- { name: "video-lightbox", primitive: "lightbox", params: { media: "video" } }
16597
+ // A video row has never shown captions; `caption:figcaption` opts one in.
16598
+ { name: "video-lightbox", primitive: "lightbox", params: { media: "video", caption: "none" } }
14905
16599
  ];
14906
16600
 
14907
16601
  // src/showcase/compare.ts
14908
16602
  var COMPARE_AXES = ["x", "y"];
14909
16603
  var COMPARE_AXIS_ATTR = "data-kui-compare-axis";
16604
+ var COMPARE_DIVIDERS_ATTR = "data-kui-compare-dividers";
14910
16605
  var compareParams = {
14911
16606
  position: {
14912
16607
  type: "percentage",
14913
16608
  default: "50%",
14914
16609
  cssProperty: "--kui-compare"
14915
16610
  },
16611
+ /*
16612
+ * Where each divider of a three-or-more frame starts, as one quoted, space-separated list —
16613
+ * `positions:'20% 45% 80%'` — because a comma would start a new step, not a new value. Default
16614
+ * (empty) is an even split. `position:` stays the two-media spelling: it is a single percentage
16615
+ * and every existing page relies on it validating as one.
16616
+ */
16617
+ positions: { type: "text", default: "", cssProperty: "--kui-compare-positions" },
14916
16618
  axis: {
14917
16619
  type: "keyword",
14918
16620
  default: "x",
14919
16621
  cssProperty: "--kui-compare-axis",
14920
16622
  keywords: [...COMPARE_AXES]
14921
- }
16623
+ },
16624
+ /*
16625
+ * The auto-drag, in `background-media`'s words (`src/effects/catalog/media.ts`): `always` starts
16626
+ * at load, `in-view` the first time the slider is on screen, `never` (default) not at all.
16627
+ *
16628
+ * Not `on:load` / `on:enter`, because the activation grammar already has a job here: it is what
16629
+ * *builds* the slider, and a widget accepts only `on:load` (`src/showcase/shared.ts`) so the
16630
+ * control exists before a keyboard user tabs to it. `on:enter` would defer the widget, not the
16631
+ * motion — so the motion's start is its own parameter.
16632
+ */
16633
+ autoplay: {
16634
+ type: "keyword",
16635
+ default: "never",
16636
+ cssProperty: "--kui-compare-autoplay",
16637
+ keywords: ["never", "in-view", "always"]
16638
+ },
16639
+ /** One edge-to-edge sweep. The positional spelling (`compare 4s`) means the same thing. */
16640
+ duration: { type: "time", default: "3s", cssProperty: "--kui-compare-duration" },
16641
+ /** Head toward 0% first instead of 100%. */
16642
+ reverse: { type: "keyword", default: "false", cssProperty: "--kui-compare-reverse", keywords: ["true", "false"] },
16643
+ /** Sweep back and forth until someone takes the handle, instead of one pass. */
16644
+ loop: { type: "keyword", default: "false", cssProperty: "--kui-compare-loop", keywords: ["true", "false"] }
14922
16645
  };
16646
+ var MIN_SWEEP_MS = 250;
16647
+ var MAX_FRAME_MS2 = 100;
14923
16648
  var VERTICAL_KEY_DELTA = { ArrowUp: -1, ArrowDown: 1 };
14924
16649
  function getMediaName(item, fallback) {
14925
16650
  const alt = item.localName === "picture" ? item.querySelector("img")?.getAttribute("alt") : item.getAttribute("alt");
@@ -14935,76 +16660,402 @@ var kuinetic = (() => {
14935
16660
  }
14936
16661
  return "Compare";
14937
16662
  }
14938
- function createCompareElements(doc, label, initialPercent, axis) {
16663
+ function createRange(doc, label, value, axis) {
14939
16664
  const range = doc.createElement("input");
14940
16665
  range.type = "range";
14941
16666
  range.className = "kui-compare-range";
14942
16667
  range.min = "0";
14943
16668
  range.max = "100";
14944
16669
  range.step = "1";
14945
- range.value = String(initialPercent);
14946
- range.setAttribute("aria-label", label);
14947
- range.setAttribute("aria-valuetext", `${initialPercent}% after`);
16670
+ range.value = String(value);
16671
+ range.setAttribute("aria-label", label.name);
16672
+ range.setAttribute("aria-valuetext", label.valueText);
14948
16673
  if (axis === "y") range.setAttribute("aria-orientation", "vertical");
16674
+ return range;
16675
+ }
16676
+ function createHandle(doc) {
14949
16677
  const handle = doc.createElement("span");
14950
16678
  handle.className = "kui-compare-handle";
14951
16679
  handle.setAttribute("aria-hidden", "true");
14952
- return { range, handle };
16680
+ return handle;
14953
16681
  }
14954
- function prepareCompare(el, params, ctx) {
14955
- const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
14956
- if (media.length < 2) {
14957
- ctx.warn("compare requires at least two media children (img, picture, or video)");
16682
+ function verticalKeys(range, axis, onInput) {
16683
+ return (event) => {
16684
+ const delta = VERTICAL_KEY_DELTA[event.key];
16685
+ if (axis !== "y" || delta === void 0) return;
16686
+ event.preventDefault();
16687
+ if (delta < 0) range.stepDown();
16688
+ else range.stepUp();
16689
+ onInput();
16690
+ };
16691
+ }
16692
+ function sweepPercent(phase) {
16693
+ return 50 - 50 * Math.cos(Math.PI * phase / 100);
16694
+ }
16695
+ function startPhase(percent, reverse) {
16696
+ const outbound = 100 / Math.PI * Math.acos(1 - percent / 50);
16697
+ return reverse ? 200 - outbound : outbound;
16698
+ }
16699
+ function sweepDuration(params, ctx) {
16700
+ const authored = params.timing.durationMs ?? params.ms("duration", 3e3);
16701
+ if (authored >= MIN_SWEEP_MS) return authored;
16702
+ ctx.warn(`compare duration: minimum is ${MIN_SWEEP_MS}ms; clamped to ${MIN_SWEEP_MS}ms`);
16703
+ return MIN_SWEEP_MS;
16704
+ }
16705
+ function bindAutoDrag(options) {
16706
+ const { host, grab, ctx, mode, loop: loop2 } = options;
16707
+ if (mode === "never" || ctx.reducedMotion) return () => {
16708
+ };
16709
+ const run = runAutoDrag(options, () => stop());
16710
+ let observer = null;
16711
+ if (mode === "in-view" || loop2) {
16712
+ observer = observeOnscreen(ctx.win, host, run.setOnscreen);
14958
16713
  }
14959
- const axis = params.text("axis", "x");
14960
- const attrs = createAttributeLedger(el);
14961
- attrs.set(COMPARE_AXIS_ATTR, axis);
16714
+ const stop = () => {
16715
+ run.stop();
16716
+ observer?.disconnect();
16717
+ observer = null;
16718
+ for (const target of grab) {
16719
+ target.removeEventListener("pointerdown", stop);
16720
+ target.removeEventListener("focus", stop);
16721
+ }
16722
+ };
16723
+ for (const target of grab) {
16724
+ target.addEventListener("pointerdown", stop);
16725
+ target.addEventListener("focus", stop);
16726
+ }
16727
+ run.setOnscreen(mode === "always" || observer === null);
16728
+ return stop;
16729
+ }
16730
+ function sweepPlan(options) {
16731
+ const { write, homes, bounds, durationMs, reverse, loop: loop2 } = options;
16732
+ const order = homes.map((_, index) => index);
16733
+ if (reverse) order.reverse();
16734
+ const cycleMs = 2 * durationMs;
16735
+ const totalMs = cycleMs * order.length;
16736
+ const phases = homes.map((home, index) => {
16737
+ const [lo, hi] = bounds[index];
16738
+ return hi > lo ? startPhase(100 * (home - lo) / (hi - lo), reverse) : 0;
16739
+ });
16740
+ const park = (slot2) => write(order[slot2], homes[order[slot2]]);
16741
+ let slot = 0;
16742
+ return {
16743
+ show(elapsed) {
16744
+ if (!loop2 && elapsed >= totalMs) {
16745
+ park(slot);
16746
+ return false;
16747
+ }
16748
+ const into = elapsed % totalMs;
16749
+ const next = Math.floor(into / cycleMs);
16750
+ if (next !== slot) park(slot);
16751
+ slot = next;
16752
+ const index = order[slot];
16753
+ const [lo, hi] = bounds[index];
16754
+ const phase = phases[index] + 100 * (into - slot * cycleMs) / durationMs;
16755
+ write(index, lo + (hi - lo) * sweepPercent(phase) / 100);
16756
+ return true;
16757
+ }
16758
+ };
16759
+ }
16760
+ function observeOnscreen(win, host, onChange) {
16761
+ const Observer = win.IntersectionObserver;
16762
+ if (!Observer) return null;
16763
+ const observer = new Observer((entries) => {
16764
+ for (const entry of entries) onChange(entry.isIntersecting);
16765
+ });
16766
+ observer.observe(host);
16767
+ return observer;
16768
+ }
16769
+ function runAutoDrag(options, finish) {
16770
+ const win = options.ctx.win;
16771
+ const plan = sweepPlan(options);
16772
+ let elapsed = 0;
16773
+ let lastFrameAt = null;
16774
+ let frame = 0;
16775
+ let done = false;
16776
+ let onscreen = false;
16777
+ const update = () => {
16778
+ const active = !done && onscreen;
16779
+ if (active && frame === 0) {
16780
+ lastFrameAt = null;
16781
+ frame = win.requestAnimationFrame(tick);
16782
+ } else if (!active && frame !== 0) {
16783
+ win.cancelAnimationFrame(frame);
16784
+ frame = 0;
16785
+ }
16786
+ };
16787
+ function tick(now) {
16788
+ frame = 0;
16789
+ if (lastFrameAt !== null) elapsed += Math.min(now - lastFrameAt, MAX_FRAME_MS2);
16790
+ lastFrameAt = now;
16791
+ if (plan.show(elapsed)) frame = win.requestAnimationFrame(tick);
16792
+ else finish();
16793
+ }
16794
+ return {
16795
+ setOnscreen(next) {
16796
+ onscreen = next;
16797
+ update();
16798
+ },
16799
+ stop() {
16800
+ done = true;
16801
+ update();
16802
+ }
16803
+ };
16804
+ }
16805
+ function buildPair({ el, media, params, ctx, axis, timing: timing3 }) {
14962
16806
  const initialPercent = Math.round(params.num("position", 0.5) * 100);
14963
16807
  const style = createStyleLedger(el);
14964
16808
  style.set("--kui-compare", params.text("position", "50%"));
14965
- const { range, handle } = createCompareElements(
14966
- el.ownerDocument,
14967
- resolveCompareLabel(el, media),
16809
+ const doc = el.ownerDocument;
16810
+ const range = createRange(
16811
+ doc,
16812
+ { name: resolveCompareLabel(el, media), valueText: `${initialPercent}% after` },
14968
16813
  initialPercent,
14969
16814
  axis
14970
16815
  );
16816
+ const handle = createHandle(doc);
14971
16817
  const onInput = () => {
14972
16818
  const val = range.value;
14973
16819
  style.set("--kui-compare", `${val}%`);
14974
16820
  range.setAttribute("aria-valuetext", `${val}% after`);
14975
16821
  };
14976
- const onKeyDown = (event) => {
14977
- const delta = VERTICAL_KEY_DELTA[event.key];
14978
- if (axis !== "y" || delta === void 0) return;
14979
- event.preventDefault();
14980
- if (delta < 0) range.stepDown();
14981
- else range.stepUp();
14982
- onInput();
16822
+ const writeAuto = (_index, percent) => {
16823
+ range.value = String(Math.round(percent));
16824
+ style.set("--kui-compare", `${Number(percent.toFixed(3))}%`);
16825
+ range.setAttribute("aria-valuetext", `${range.value}% after`);
14983
16826
  };
16827
+ const onKeyDown = verticalKeys(range, axis, onInput);
14984
16828
  range.addEventListener("input", onInput);
14985
16829
  range.addEventListener("keydown", onKeyDown);
14986
16830
  el.appendChild(range);
14987
16831
  el.appendChild(handle);
14988
- return continuousSetup(() => {
16832
+ const stopAutoDrag = bindAutoDrag({
16833
+ ...timing3,
16834
+ host: el,
16835
+ grab: [range],
16836
+ ctx,
16837
+ write: writeAuto,
16838
+ homes: [params.num("position", 0.5) * 100],
16839
+ bounds: [[0, 100]]
16840
+ });
16841
+ return () => {
16842
+ stopAutoDrag();
14989
16843
  range.removeEventListener("input", onInput);
14990
16844
  range.removeEventListener("keydown", onKeyDown);
14991
16845
  range.remove();
14992
16846
  handle.remove();
14993
- attrs.restore();
14994
16847
  style.restore();
16848
+ };
16849
+ }
16850
+ var PERCENT_TOKEN = /^(\d+(?:\.\d+)?)%$/;
16851
+ function resolvePositions(authored, count, warn) {
16852
+ const even = Array.from({ length: count }, (_, k) => Math.round(100 * (k + 1) / (count + 1)));
16853
+ if (authored.trim() === "") return even;
16854
+ const tokens = authored.trim().split(/\s+/);
16855
+ const values = tokens.map((token) => PERCENT_TOKEN.exec(token)?.[1]);
16856
+ const numbers = values.map(Number);
16857
+ const usable2 = tokens.length === count && values.every((value) => value !== void 0) && numbers.every((value, k) => value <= 100 && (k === 0 || value >= numbers[k - 1]));
16858
+ if (usable2) return numbers.map(Math.round);
16859
+ const example = even.map((value) => String(value) + "%").join(" ");
16860
+ warn(
16861
+ `compare positions: expected ${count} ascending percentages between 0% and 100% for ${count + 1} media (e.g. positions:'${example}'); using an even split`
16862
+ );
16863
+ return even;
16864
+ }
16865
+ function buildMulti({ el, media, params, ctx, axis, timing: timing3 }) {
16866
+ if (params.text("position", "50%") !== "50%") {
16867
+ ctx.warn(`compare position: sets the one divider of two media; with ${media.length}, use positions:`);
16868
+ }
16869
+ const positions = resolvePositions(params.text("positions", ""), media.length - 1, ctx.warn);
16870
+ const homes = [...positions];
16871
+ const attrs = createAttributeLedger(el);
16872
+ attrs.set(COMPARE_DIVIDERS_ATTR, String(homes.length));
16873
+ const dividers = createDividers(el, media, axis, positions);
16874
+ const surface = bindSurface(el, axis, dividers);
16875
+ const stopAutoDrag = bindAutoDrag({
16876
+ ...timing3,
16877
+ host: el,
16878
+ grab: [surface.node, ...dividers.ranges],
16879
+ ctx,
16880
+ write: dividers.render,
16881
+ homes,
16882
+ bounds: homes.map((_, k) => boundsOf(homes, k))
16883
+ });
16884
+ return () => {
16885
+ stopAutoDrag();
16886
+ surface.destroy();
16887
+ dividers.destroy();
16888
+ attrs.restore();
16889
+ };
16890
+ }
16891
+ function boundsOf(positions, k) {
16892
+ return [k > 0 ? positions[k - 1] : 0, k < positions.length - 1 ? positions[k + 1] : 100];
16893
+ }
16894
+ function dividerLabel(el, media, k) {
16895
+ const count = media.length - 1;
16896
+ const authored = el.getAttribute("aria-label")?.trim();
16897
+ if (authored) return `${authored}: divider ${k + 1} of ${count}`;
16898
+ const before = getMediaName(media[k], `Image ${k + 1}`);
16899
+ const after = getMediaName(media[k + 1], `Image ${k + 2}`);
16900
+ return `Divider ${k + 1} of ${count}: ${before} / ${after}`;
16901
+ }
16902
+ function createDividers(el, media, axis, positions) {
16903
+ const doc = el.ownerDocument;
16904
+ const count = positions.length;
16905
+ const clip = axis === "y" ? "inset(var(--kui-compare) 0 0 0)" : "inset(0 0 0 var(--kui-compare))";
16906
+ const mediaStyles = media.slice(1).map((item) => createStyleLedger(item));
16907
+ for (const ledger of mediaStyles) ledger.set("clip-path", clip);
16908
+ const ranges = [];
16909
+ const handles = [];
16910
+ const cleanups = [];
16911
+ const render = (k, shown) => {
16912
+ positions[k] = Math.round(shown);
16913
+ const css = `${Number(shown.toFixed(3))}%`;
16914
+ handles[k].style.setProperty("--kui-compare", css);
16915
+ mediaStyles[k].set("--kui-compare", css);
16916
+ ranges[k].value = String(positions[k]);
16917
+ ranges[k].setAttribute("aria-valuetext", `${positions[k]}%`);
16918
+ if (k > 0) ranges[k - 1].max = String(positions[k]);
16919
+ if (k < count - 1) ranges[k + 1].min = String(positions[k]);
16920
+ };
16921
+ for (let k = 0; k < count; k++) {
16922
+ const range = createRange(doc, { name: dividerLabel(el, media, k), valueText: `${positions[k]}%` }, positions[k], axis);
16923
+ const handle = createHandle(doc);
16924
+ ranges.push(range);
16925
+ handles.push(handle);
16926
+ el.append(range, handle);
16927
+ const onInput = () => render(k, Number(range.value));
16928
+ const onKeyDown = verticalKeys(range, axis, onInput);
16929
+ range.addEventListener("input", onInput);
16930
+ range.addEventListener("keydown", onKeyDown);
16931
+ cleanups.push(() => {
16932
+ range.removeEventListener("input", onInput);
16933
+ range.removeEventListener("keydown", onKeyDown);
16934
+ });
16935
+ }
16936
+ for (let k = 0; k < count; k++) {
16937
+ const [lo, hi] = boundsOf(positions, k);
16938
+ ranges[k].min = String(lo);
16939
+ ranges[k].max = String(hi);
16940
+ render(k, positions[k]);
16941
+ }
16942
+ return {
16943
+ ranges,
16944
+ render,
16945
+ moveTo(k, percent) {
16946
+ const [lo, hi] = boundsOf(positions, k);
16947
+ render(k, Math.min(hi, Math.max(lo, percent)));
16948
+ },
16949
+ nearest: (percent) => nearestDivider(positions, percent),
16950
+ destroy() {
16951
+ for (const cleanup of cleanups) cleanup();
16952
+ for (const node of [...ranges, ...handles]) node.remove();
16953
+ for (const ledger of mediaStyles) ledger.restore();
16954
+ }
16955
+ };
16956
+ }
16957
+ function nearestDivider(positions, percent) {
16958
+ let best = 0;
16959
+ for (let k = 1; k < positions.length; k++) {
16960
+ const distance3 = Math.abs(positions[k] - percent);
16961
+ const bestDistance = Math.abs(positions[best] - percent);
16962
+ if (distance3 < bestDistance || distance3 === bestDistance && percent >= positions[k]) best = k;
16963
+ }
16964
+ return best;
16965
+ }
16966
+ function bindSurface(el, axis, dividers) {
16967
+ const surface = el.ownerDocument.createElement("div");
16968
+ surface.className = "kui-compare-surface";
16969
+ surface.setAttribute("aria-hidden", "true");
16970
+ el.appendChild(surface);
16971
+ let dragging = -1;
16972
+ let pointerId = null;
16973
+ const percentAt = (event) => {
16974
+ const rect = el.getBoundingClientRect();
16975
+ const along = axis === "y" ? event.clientY - rect.top : event.clientX - rect.left;
16976
+ const size = axis === "y" ? rect.height : rect.width;
16977
+ return size > 0 ? 100 * along / size : 0;
16978
+ };
16979
+ const onPointerDown = (event) => {
16980
+ if (event.button > 0 || pointerId !== null) return;
16981
+ pointerId = event.pointerId;
16982
+ const percent = percentAt(event);
16983
+ dragging = dividers.nearest(percent);
16984
+ event.preventDefault();
16985
+ surface.setPointerCapture?.(event.pointerId);
16986
+ dividers.ranges[dragging].focus({ preventScroll: true });
16987
+ dividers.moveTo(dragging, percent);
16988
+ };
16989
+ const onPointerMove = (event) => {
16990
+ if (dragging >= 0 && event.pointerId === pointerId) dividers.moveTo(dragging, percentAt(event));
16991
+ };
16992
+ const onPointerUp = (event) => {
16993
+ if (event.pointerId !== pointerId) return;
16994
+ dragging = -1;
16995
+ pointerId = null;
16996
+ };
16997
+ const onLostCapture = (event) => {
16998
+ if (event.target !== surface) return;
16999
+ onPointerUp(event);
17000
+ };
17001
+ const listeners = [
17002
+ ["pointerdown", onPointerDown],
17003
+ ["pointermove", onPointerMove],
17004
+ ["pointerup", onPointerUp],
17005
+ ["pointercancel", onPointerUp],
17006
+ ["lostpointercapture", onLostCapture]
17007
+ ];
17008
+ for (const [type, listener] of listeners) surface.addEventListener(type, listener);
17009
+ return {
17010
+ node: surface,
17011
+ destroy() {
17012
+ for (const [type, listener] of listeners) surface.removeEventListener(type, listener);
17013
+ surface.remove();
17014
+ }
17015
+ };
17016
+ }
17017
+ function prepareCompare(el, params, ctx) {
17018
+ const media = Array.from(el.querySelectorAll(":scope > :is(img, picture, video)"));
17019
+ if (media.length < 2) {
17020
+ ctx.warn("compare requires at least two media children (img, picture, or video)");
17021
+ }
17022
+ const axis = params.text("axis", "x");
17023
+ const attrs = createAttributeLedger(el);
17024
+ attrs.set(COMPARE_AXIS_ATTR, axis);
17025
+ const build2 = media.length > 2 ? buildMulti : buildPair;
17026
+ const teardown = build2({
17027
+ el,
17028
+ media,
17029
+ params,
17030
+ ctx,
17031
+ axis,
17032
+ timing: {
17033
+ mode: params.text("autoplay", "never"),
17034
+ durationMs: sweepDuration(params, ctx),
17035
+ reverse: params.is("reverse"),
17036
+ loop: params.is("loop")
17037
+ }
17038
+ });
17039
+ return continuousSetup(() => {
17040
+ teardown();
17041
+ attrs.restore();
14995
17042
  });
14996
17043
  }
14997
17044
  var COMPARE_PRIMITIVE = widgetPrimitive(
14998
17045
  "compare",
14999
17046
  {
15000
- channels: ["clip", "widget"],
17047
+ // The layers it stacks and the range, handle and surface it appends; the axis and divider
17048
+ // markers it writes on the host. Not `clip`: its `clip-path` is on the after layer, never the
17049
+ // host, so a `wipe-up` revealing the whole slider is not a fight over one property.
17050
+ channels: [SUBTREE_CHANNEL, attributeChannel(COMPARE_AXIS_ATTR), attributeChannel(COMPARE_DIVIDERS_ATTR)],
15001
17051
  parameters: compareParams,
15002
17052
  perfClass: "paint"
15003
17053
  },
15004
17054
  withTimingContract(
15005
17055
  "compare",
15006
17056
  {
15007
- because: "it builds an interactive before/after slider; there is no motion to time"
17057
+ honours: ["duration"],
17058
+ because: "its only motion is the autoplay: sweep, which starts on autoplay: rather than after a delay and eases into each edge on its own curve"
15008
17059
  },
15009
17060
  deferPrepare(prepareCompare)
15010
17061
  )
@@ -15178,7 +17229,8 @@ var kuinetic = (() => {
15178
17229
  var HOTSPOTS_PRIMITIVE = widgetPrimitive(
15179
17230
  "hotspots",
15180
17231
  {
15181
- channels: ["widget"],
17232
+ // The marker buttons it appends, placed in percent of the host, and the marker style it writes.
17233
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-hotspot-marker")],
15182
17234
  parameters: hotspotsParams,
15183
17235
  perfClass: "layout"
15184
17236
  },
@@ -15382,7 +17434,7 @@ var kuinetic = (() => {
15382
17434
  hovered = true;
15383
17435
  update();
15384
17436
  };
15385
- const leave = () => {
17437
+ const leave2 = () => {
15386
17438
  hovered = false;
15387
17439
  update();
15388
17440
  };
@@ -15399,7 +17451,7 @@ var kuinetic = (() => {
15399
17451
  update();
15400
17452
  };
15401
17453
  el.addEventListener("pointerenter", enter);
15402
- el.addEventListener("pointerleave", leave);
17454
+ el.addEventListener("pointerleave", leave2);
15403
17455
  el.addEventListener("focusin", focus);
15404
17456
  el.addEventListener("focusout", blur);
15405
17457
  ctx.doc.addEventListener("visibilitychange", update);
@@ -15408,7 +17460,7 @@ var kuinetic = (() => {
15408
17460
  return () => {
15409
17461
  if (timer !== null) clearTimeout(timer);
15410
17462
  el.removeEventListener("pointerenter", enter);
15411
- el.removeEventListener("pointerleave", leave);
17463
+ el.removeEventListener("pointerleave", leave2);
15412
17464
  el.removeEventListener("focusin", focus);
15413
17465
  el.removeEventListener("focusout", blur);
15414
17466
  ctx.doc.removeEventListener("visibilitychange", update);
@@ -15440,7 +17492,7 @@ var kuinetic = (() => {
15440
17492
  return recognise(el, { onSwipe: (direction) => {
15441
17493
  if (direction === "left") index.next();
15442
17494
  if (direction === "right") index.prev();
15443
- } }, { axis: "x", capturePointer: false });
17495
+ } }, { axis: "x", capturePointer: "drag" });
15444
17496
  }
15445
17497
  function prepareSlideshow(el, params, ctx) {
15446
17498
  const selector = resolveTarget(
@@ -15474,7 +17526,6 @@ var kuinetic = (() => {
15474
17526
  el,
15475
17527
  params,
15476
17528
  ctx,
15477
- scope,
15478
17529
  resolveSteps,
15479
17530
  name: "slideshow",
15480
17531
  clickFallback: false,
@@ -15502,7 +17553,19 @@ var kuinetic = (() => {
15502
17553
  }
15503
17554
  var SLIDESHOW_PRIMITIVE = widgetPrimitive(
15504
17555
  "slideshow",
15505
- { channels: ["widget"], parameters: slideshowParams, perfClass: "layout" },
17556
+ // The controls it inserts, then what it writes on the host: `createStepIndex`'s `data-kui-step`,
17557
+ // as every step deck does, and `decorateHost`'s mode and carousel roles.
17558
+ {
17559
+ channels: [
17560
+ SUBTREE_CHANNEL,
17561
+ attributeChannel("data-kui-step"),
17562
+ attributeChannel(SLIDE_MODE_ATTR),
17563
+ attributeChannel("role"),
17564
+ attributeChannel("aria-roledescription")
17565
+ ],
17566
+ parameters: slideshowParams,
17567
+ perfClass: "layout"
17568
+ },
15506
17569
  withTimingContract(
15507
17570
  "slideshow",
15508
17571
  { honours: ["duration", "ease"], because: "the slideshow has no delayed start" },
@@ -15664,7 +17727,14 @@ var kuinetic = (() => {
15664
17727
  }
15665
17728
  var SCROLL_STORY_PRIMITIVE = widgetPrimitive(
15666
17729
  "scroll-story",
15667
- { channels: ["widget"], parameters: scrollStoryParams, perfClass: "layout" },
17730
+ // Its stylesheet lays out the host's first and last child, so nothing else may insert one. The
17731
+ // step index on the host (`applyStep`) is claimed so a `carousel` or `scroll-progress` beside it
17732
+ // is refused rather than left to overwrite it.
17733
+ {
17734
+ channels: [SUBTREE_CHANNEL, attributeChannel("data-kui-step"), attributeChannel(STORY_SIDE_ATTR)],
17735
+ parameters: scrollStoryParams,
17736
+ perfClass: "layout"
17737
+ },
15668
17738
  withTimingContract(
15669
17739
  "scroll-story",
15670
17740
  { because: "scroll position" },
@@ -15767,7 +17837,8 @@ var kuinetic = (() => {
15767
17837
  }
15768
17838
  var SLOW_MO_PRIMITIVE = widgetPrimitive(
15769
17839
  "slow-mo",
15770
- { channels: ["widget"], parameters: slowMoParams, perfClass: "compositor" },
17840
+ // The toggle it prepends into the host, and the time scale it writes there.
17841
+ { channels: [SUBTREE_CHANNEL, attributeChannel(TIME_SCALE_ATTR)], parameters: slowMoParams, perfClass: "compositor" },
15771
17842
  withTimingContract(
15772
17843
  "slow-mo",
15773
17844
  { because: "it changes playback rate rather than defining an animation timeline" },
@@ -15796,6 +17867,7 @@ var kuinetic = (() => {
15796
17867
  ...SLOW_MO_PRESETS
15797
17868
  ];
15798
17869
  function registerShowcase(target) {
17870
+ provideDeckViewer(attachDeckLightbox);
15799
17871
  return registerInto(target, SHOWCASE_PRIMITIVES, SHOWCASE_PRESETS, "Showcase");
15800
17872
  }
15801
17873
 
@@ -15824,6 +17896,16 @@ var kuinetic = (() => {
15824
17896
  return new Animator({ registry: createRegistry(), ...options });
15825
17897
  }
15826
17898
  var src_default = kuinetic;
17899
+ var catalog;
17900
+ function describe2(name, options = {}) {
17901
+ return describeEffect(options.registry ?? (catalog ??= createRegistry()), name, options.notes);
17902
+ }
17903
+ function describeSteps(value, options = {}) {
17904
+ return describeAttribute(options.registry ?? (catalog ??= createRegistry()), value, options.notes);
17905
+ }
17906
+ function describeElement(value, options = {}) {
17907
+ return describeElementAttribute(options.registry ?? (catalog ??= createRegistry()), value, options.notes);
17908
+ }
15827
17909
  return __toCommonJS(src_exports);
15828
17910
  })();
15829
17911
 
@@ -15909,17 +17991,24 @@ var __kuineticBoot = (() => {
15909
17991
  }
15910
17992
  }
15911
17993
  function guarded(runtime, original, core, options) {
17994
+ const wantsShared = !options || Object.entries(options).every(([key, value]) => key === "observe" && value === true);
17995
+ if (runtime.manual) {
17996
+ const made = original.call(core, options);
17997
+ adoptManual(runtime, made);
17998
+ return made;
17999
+ }
15912
18000
  if (!runtime.started) {
15913
18001
  const replacement = original.call(core, options);
15914
18002
  adopt(runtime, replacement);
15915
- warnManualCall(
15916
- runtime,
15917
- "yours is now the one this page uses, exactly as it would have been before the script tag started one, so the call is safe to delete"
15918
- );
18003
+ if (wantsShared) {
18004
+ warnManualCall(
18005
+ runtime,
18006
+ "yours is now the one this page uses, exactly as it would have been before the script tag started one, so the call is safe to delete"
18007
+ );
18008
+ }
15919
18009
  return replacement;
15920
18010
  }
15921
18011
  const shared = runtime.animator;
15922
- const wantsShared = !options || Object.entries(options).every(([key, value]) => key === "observe" && value === true);
15923
18012
  if (shared && wantsShared) {
15924
18013
  warnManualCall(runtime, "you have been handed the one it already made, so this call is safe to delete");
15925
18014
  return shared;
@@ -15946,6 +18035,40 @@ var __kuineticBoot = (() => {
15946
18035
  runtime.animator = animator;
15947
18036
  globalScope().__kuinetic = animator;
15948
18037
  }
18038
+ function adoptManual(runtime, animator) {
18039
+ if (!runtime.animator) adopt(runtime, animator);
18040
+ const entry = { animator, started: false };
18041
+ const list = runtime.handBuilt ??= [];
18042
+ list.push(entry);
18043
+ const start = animator.start.bind(animator);
18044
+ animator.start = () => {
18045
+ entry.started = true;
18046
+ return start();
18047
+ };
18048
+ if (typeof animator.destroy === "function") {
18049
+ const destroy = animator.destroy.bind(animator);
18050
+ animator.destroy = () => {
18051
+ const at = list.indexOf(entry);
18052
+ if (at >= 0) list.splice(at, 1);
18053
+ return destroy();
18054
+ };
18055
+ }
18056
+ for (const tier of runtime.pending) registerTier(tier, animator);
18057
+ }
18058
+ function registerTier(entry, animator) {
18059
+ try {
18060
+ entry.register(animator);
18061
+ } catch (error) {
18062
+ warn(`the "${entry.tier}" tier failed to register: ${String(error)}`);
18063
+ }
18064
+ }
18065
+ function enlistOnManual(runtime, entry) {
18066
+ runtime.pending.push(entry);
18067
+ for (const { animator, started } of [...runtime.handBuilt ?? []]) {
18068
+ registerTier(entry, animator);
18069
+ if (started) recompile(animator);
18070
+ }
18071
+ }
15949
18072
  function createAnimator(runtime, core) {
15950
18073
  adopt(runtime, core.kuinetic({ observe: true }));
15951
18074
  }
@@ -15975,13 +18098,7 @@ var __kuineticBoot = (() => {
15975
18098
  }
15976
18099
  function drain(runtime, animator) {
15977
18100
  const pending = runtime.pending.splice(0, runtime.pending.length);
15978
- for (const entry of pending) {
15979
- try {
15980
- entry.register(animator);
15981
- } catch (error) {
15982
- warn(`the "${entry.tier}" tier failed to register: ${String(error)}`);
15983
- }
15984
- }
18101
+ for (const entry of pending) registerTier(entry, animator);
15985
18102
  return pending.length;
15986
18103
  }
15987
18104
  function runBoot(runtime) {
@@ -16003,13 +18120,6 @@ var __kuineticBoot = (() => {
16003
18120
  }
16004
18121
  if (registered) recompile(animator);
16005
18122
  }
16006
- function optedOut(runtime, options) {
16007
- if (taggedManual()) {
16008
- if (options.core) runtime.manual = true;
16009
- return true;
16010
- }
16011
- return runtime.manual;
16012
- }
16013
18123
  function enlist(runtime, options) {
16014
18124
  if (options.core) {
16015
18125
  if (runtime.animator) return;
@@ -16023,7 +18133,17 @@ var __kuineticBoot = (() => {
16023
18133
  if (typeof document === "undefined" || typeof globalThis === "undefined") return;
16024
18134
  const runtime = getRuntime();
16025
18135
  checkVersion(runtime, options.tier);
16026
- if (optedOut(runtime, options)) return;
18136
+ if (taggedManual()) {
18137
+ if (options.core) {
18138
+ runtime.manual = true;
18139
+ installGuard(runtime, options.core, options.globalName);
18140
+ }
18141
+ return;
18142
+ }
18143
+ if (runtime.manual) {
18144
+ if (options.register) enlistOnManual(runtime, { tier: options.tier, register: options.register });
18145
+ return;
18146
+ }
16027
18147
  enlist(runtime, options);
16028
18148
  schedule(runtime);
16029
18149
  }