arcy.js 0.1.4 → 0.1.6

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.
package/dist/index.cjs CHANGED
@@ -361,7 +361,7 @@ var init_message = __esm({
361
361
  DESIGN_MODE_PICK = "arcy:design-mode:pick";
362
362
  DESIGN_MODE_EXIT = "arcy:design-mode:exit";
363
363
  DESIGN_MODE_SCREENSHOT = "arcy:design-mode:screenshot";
364
- SCREENSHOT_RENDER_BUDGET_MS = 8e3;
364
+ SCREENSHOT_RENDER_BUDGET_MS = 5e3;
365
365
  }
366
366
  });
367
367
 
@@ -3992,9 +3992,6 @@ function mount(context) {
3992
3992
  flowOffers: result.flowsOffered
3993
3993
  });
3994
3994
  context.touchConversation?.();
3995
- if (result.isFallback) {
3996
- context.track?.("fallback_triggered", {}, { question: text });
3997
- }
3998
3995
  for (const offer of result.flowsOffered) {
3999
3996
  context.track?.(
4000
3997
  "flow_suggested",
@@ -5639,6 +5636,25 @@ function evaluateCurrentPage(node, href) {
5639
5636
  if (node.matches.length === 0) return true;
5640
5637
  return node.matches.some((pattern) => globMatches(pattern, href));
5641
5638
  }
5639
+ function parseInstant(value) {
5640
+ if (!DATE_SHAPED.test(value)) return null;
5641
+ const ms = new Date(value).getTime();
5642
+ return Number.isFinite(ms) ? ms : null;
5643
+ }
5644
+ function compareOrdered(live, expected) {
5645
+ if (live.trim() === "" || expected.trim() === "") return null;
5646
+ const liveNumber = Number(live);
5647
+ const expectedNumber = Number(expected);
5648
+ if (Number.isFinite(liveNumber) && Number.isFinite(expectedNumber)) {
5649
+ return liveNumber - expectedNumber;
5650
+ }
5651
+ const liveInstant = parseInstant(live);
5652
+ const expectedInstant = parseInstant(expected);
5653
+ if (liveInstant !== null && expectedInstant !== null) {
5654
+ return liveInstant - expectedInstant;
5655
+ }
5656
+ return null;
5657
+ }
5642
5658
  function compareText(operator, live, expected) {
5643
5659
  if (live === void 0) return false;
5644
5660
  switch (operator) {
@@ -5660,16 +5676,14 @@ function compareText(operator, live, expected) {
5660
5676
  return live === "";
5661
5677
  case "is_not_empty":
5662
5678
  return live !== "";
5663
- // Numeric compares parse both sides; either side not a number means the
5664
- // condition cannot hold (never throw, never coerce NaN into an answer).
5679
+ // Ordered compares read both sides as numbers, then as instants; either
5680
+ // side unreadable means the condition cannot hold (never throw, never
5681
+ // coerce NaN into an answer).
5665
5682
  case "greater_than":
5666
5683
  case "less_than": {
5667
- const liveNumber = Number(live);
5668
- const expectedNumber = Number(expected);
5669
- if (live.trim() === "" || !Number.isFinite(liveNumber) || !Number.isFinite(expectedNumber)) {
5670
- return false;
5671
- }
5672
- return operator === "greater_than" ? liveNumber > expectedNumber : liveNumber < expectedNumber;
5684
+ const ordered = compareOrdered(live, expected);
5685
+ if (ordered === null) return false;
5686
+ return operator === "greater_than" ? ordered > 0 : ordered < 0;
5673
5687
  }
5674
5688
  // The same `*` glob `current_page` uses, applied to a field value.
5675
5689
  case "matches_pattern":
@@ -5738,15 +5752,22 @@ function evaluateElement(node, ctx) {
5738
5752
  return false;
5739
5753
  }
5740
5754
  }
5755
+ function isEmptyValue(live) {
5756
+ if (live === void 0 || live === null || live === "") return true;
5757
+ if (Array.isArray(live)) return live.length === 0;
5758
+ if (typeof live === "object") return Object.keys(live).length === 0;
5759
+ return false;
5760
+ }
5741
5761
  function evaluateAttribute(node, ctx) {
5742
5762
  const live = ctx.getTrait?.(node.codeName);
5743
5763
  switch (node.operator) {
5744
5764
  case "has_any_value":
5745
- return live !== void 0 && live !== null && live !== "";
5765
+ return !isEmptyValue(live);
5746
5766
  case "is_empty":
5747
- return live === void 0 || live === null || live === "";
5767
+ return isEmptyValue(live);
5748
5768
  }
5749
5769
  if (live === void 0) return false;
5770
+ if (typeof live === "object") return false;
5750
5771
  const liveText = String(live);
5751
5772
  const expected = node.value === void 0 ? "" : String(node.value);
5752
5773
  return compareText(node.operator, liveText, expected);
@@ -5799,7 +5820,7 @@ function needsIdleCheck(node) {
5799
5820
  if (node.type === "group") return node.children.some(needsIdleCheck);
5800
5821
  return false;
5801
5822
  }
5802
- var MAX_CONDITION_DEPTH, CONDITION_TYPES;
5823
+ var MAX_CONDITION_DEPTH, CONDITION_TYPES, DATE_SHAPED;
5803
5824
  var init_condition = __esm({
5804
5825
  "src/flow/condition.ts"() {
5805
5826
  init_matcher();
@@ -5816,6 +5837,7 @@ var init_condition = __esm({
5816
5837
  "always_true",
5817
5838
  "group"
5818
5839
  ];
5840
+ DATE_SHAPED = /^\d{4}-\d{2}-\d{2}/;
5819
5841
  }
5820
5842
  });
5821
5843
 
@@ -6027,6 +6049,21 @@ var init_runtime = __esm({
6027
6049
  /** ADR 0166: true while a fill question is awaiting the end user's
6028
6050
  * answer. Suspends trigger evaluation for the duration. */
6029
6051
  this.inputPending = false;
6052
+ /**
6053
+ * True from the moment a step is entered until its own action has
6054
+ * settled. Suspends trigger evaluation for exactly that window, the same
6055
+ * posture `inputPending` takes around a fill question.
6056
+ *
6057
+ * **This is what makes a step's type mean anything.** The builder's
6058
+ * default condition for a new trigger is `always_true` with no wait
6059
+ * (`flow-draft.ts`'s `newTrigger`), so the ordinary way to say "then go to
6060
+ * step 2" is a trigger that is true the instant the step mounts. Without
6061
+ * this flag `startWatch`'s initial pass fired that trigger before the step
6062
+ * had clicked, filled or navigated anything, and the action never ran at
6063
+ * all: a two-step flow counted to step 2 and quietly ended, having done
6064
+ * nothing. A step acts first; its triggers are how it is left.
6065
+ */
6066
+ this.actionPending = false;
6030
6067
  /** Extra capture-phase listeners `startWatch` installs alongside the
6031
6068
  * click listener (D1297): typing, focus moves and checkbox flips change
6032
6069
  * nothing a MutationObserver can see, yet `text_input`, `is_focused`
@@ -6163,6 +6200,7 @@ var init_runtime = __esm({
6163
6200
  }
6164
6201
  this.activityListeners = [];
6165
6202
  this.inputPending = false;
6203
+ this.actionPending = false;
6166
6204
  this.trueSince.clear();
6167
6205
  this.clicked.clear();
6168
6206
  this.initialValues.clear();
@@ -6252,7 +6290,7 @@ var init_runtime = __esm({
6252
6290
  }
6253
6291
  checkTriggers(step, ctx) {
6254
6292
  if (this.stopped) return;
6255
- if (this.inputPending) return;
6293
+ const mayFire = !this.inputPending && !this.actionPending;
6256
6294
  const now = this.now();
6257
6295
  for (const trigger of [...step.triggers].sort((a, b) => a.position - b.position)) {
6258
6296
  const parsed = this.parsedTrigger(trigger);
@@ -6266,7 +6304,7 @@ var init_runtime = __esm({
6266
6304
  const since = this.trueSince.get(key) ?? now;
6267
6305
  if (!this.trueSince.has(key)) this.trueSince.set(key, since);
6268
6306
  const waitMs = (trigger.waitSeconds ?? 0) * 1e3;
6269
- if (now - since >= waitMs) {
6307
+ if (now - since >= waitMs && mayFire) {
6270
6308
  this.fireTrigger(trigger);
6271
6309
  return;
6272
6310
  }
@@ -6352,8 +6390,7 @@ var init_runtime = __esm({
6352
6390
  }
6353
6391
  this.checkTriggers(step, this.conditionContext());
6354
6392
  }
6355
- async runExecutor(step) {
6356
- const startIndex = this.stepIndex;
6393
+ async runExecutor(step, startIndex) {
6357
6394
  try {
6358
6395
  const outcome = await this.executor(step, {
6359
6396
  doc: this.doc,
@@ -6365,6 +6402,7 @@ var init_runtime = __esm({
6365
6402
  }
6366
6403
  });
6367
6404
  if (this.stopped || this.stepIndex !== startIndex) return;
6405
+ this.actionPending = false;
6368
6406
  if (outcome.status === "advance") {
6369
6407
  this.emit("flow_step_completed");
6370
6408
  if (step.isCompletion) {
@@ -6409,11 +6447,14 @@ var init_runtime = __esm({
6409
6447
  );
6410
6448
  return;
6411
6449
  }
6450
+ this.scheduleCheck(step);
6412
6451
  } catch {
6452
+ this.actionPending = false;
6413
6453
  }
6414
6454
  }
6415
6455
  enterStep() {
6416
6456
  const step = this.currentStep();
6457
+ const stepIndex = this.stepIndex;
6417
6458
  if (!step || this.stopped) {
6418
6459
  this.finish(step ? "completed" : "error", step ? void 0 : { reason: "missing_step" });
6419
6460
  return;
@@ -6428,8 +6469,9 @@ var init_runtime = __esm({
6428
6469
  });
6429
6470
  const begin = () => {
6430
6471
  if (this.stopped) return;
6472
+ this.actionPending = true;
6431
6473
  this.startWatch(step);
6432
- void this.runExecutor(step);
6474
+ void this.runExecutor(step, stepIndex);
6433
6475
  };
6434
6476
  this.waitForPageReady(begin);
6435
6477
  }
@@ -8630,6 +8672,208 @@ function computeCropRect(target, viewportWidth, viewportHeight) {
8630
8672
  if (bottom - top > height) top += (bottom - top - height) / 2;
8631
8673
  return { left, top, width: Math.max(0, width), height: Math.max(0, height) };
8632
8674
  }
8675
+ function effectiveOpacity(el, view) {
8676
+ let opacity = 1;
8677
+ let current = el;
8678
+ let depth = 0;
8679
+ while (current !== null && depth < 64) {
8680
+ try {
8681
+ const value = Number.parseFloat(view.getComputedStyle(current).opacity);
8682
+ if (!Number.isNaN(value)) opacity *= value;
8683
+ } catch {
8684
+ break;
8685
+ }
8686
+ if (opacity <= 1e-3) return 0;
8687
+ current = parentOrHost(current);
8688
+ depth++;
8689
+ }
8690
+ return opacity;
8691
+ }
8692
+ function waitUntilSettled(el, view) {
8693
+ return new Promise((resolve) => {
8694
+ const raf = view.requestAnimationFrame;
8695
+ if (typeof raf !== "function") {
8696
+ resolve();
8697
+ return;
8698
+ }
8699
+ const start = Date.now();
8700
+ let previous = null;
8701
+ let stable = 0;
8702
+ const check = () => {
8703
+ try {
8704
+ if (Date.now() - start >= SETTLE_MAX_MS) {
8705
+ resolve();
8706
+ return;
8707
+ }
8708
+ const rect = el.getBoundingClientRect();
8709
+ const now = `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)},${Math.round(rect.height)}`;
8710
+ const opaque = effectiveOpacity(el, view) >= 0.99;
8711
+ stable = opaque && now === previous ? stable + 1 : 0;
8712
+ previous = now;
8713
+ if (stable >= SETTLE_STABLE_FRAMES) {
8714
+ resolve();
8715
+ return;
8716
+ }
8717
+ raf.call(view, check);
8718
+ } catch {
8719
+ resolve();
8720
+ }
8721
+ };
8722
+ raf.call(view, check);
8723
+ });
8724
+ }
8725
+ function findRenderRoot(el, crop, root, view) {
8726
+ let covering = null;
8727
+ let cheapest = null;
8728
+ let current = el;
8729
+ let depth = 0;
8730
+ while (current !== null && current !== root && depth < 64) {
8731
+ if (isTransformed(current, view)) {
8732
+ covering = null;
8733
+ cheapest = null;
8734
+ } else {
8735
+ const rect = current.getBoundingClientRect();
8736
+ if (covering === null && covers(rect, crop)) covering = current;
8737
+ if (cheapest === null && keptRatio(rect, crop) >= MIN_CROP_KEPT) {
8738
+ cheapest = current;
8739
+ }
8740
+ }
8741
+ current = parentOrHost(current);
8742
+ depth++;
8743
+ }
8744
+ if (covering !== null && isAffordable(covering, view)) return covering;
8745
+ return cheapest ?? covering ?? root;
8746
+ }
8747
+ function isAffordable(el, view) {
8748
+ if (countElements(el) > NODE_BUDGET) return false;
8749
+ const rect = el.getBoundingClientRect();
8750
+ const viewport = Math.max(1, view.innerWidth * view.innerHeight);
8751
+ return rect.width * rect.height / viewport <= AREA_BUDGET_SCREENS;
8752
+ }
8753
+ function keptRatio(bounds, crop) {
8754
+ const whole = crop.width * crop.height;
8755
+ if (whole <= 0) return 0;
8756
+ const kept = clipCrop(crop, {
8757
+ left: bounds.left,
8758
+ top: bounds.top,
8759
+ width: bounds.width,
8760
+ height: bounds.height
8761
+ });
8762
+ return kept.width * kept.height / whole;
8763
+ }
8764
+ function countElements(el) {
8765
+ try {
8766
+ return el.getElementsByTagName("*").length;
8767
+ } catch {
8768
+ return Number.MAX_SAFE_INTEGER;
8769
+ }
8770
+ }
8771
+ function clipCrop(crop, bounds) {
8772
+ const left = Math.max(crop.left, bounds.left);
8773
+ const top = Math.max(crop.top, bounds.top);
8774
+ const right = Math.min(crop.left + crop.width, bounds.left + bounds.width);
8775
+ const bottom = Math.min(crop.top + crop.height, bounds.top + bounds.height);
8776
+ return {
8777
+ left,
8778
+ top,
8779
+ width: Math.max(0, right - left),
8780
+ height: Math.max(0, bottom - top)
8781
+ };
8782
+ }
8783
+ function isTransformed(el, view) {
8784
+ try {
8785
+ const style2 = view.getComputedStyle(el);
8786
+ return style2.transform !== "" && style2.transform !== "none" || style2.perspective !== "" && style2.perspective !== "none" || style2.translate !== "" && style2.translate !== "none" || style2.rotate !== "" && style2.rotate !== "none" || style2.scale !== "" && style2.scale !== "none";
8787
+ } catch {
8788
+ return false;
8789
+ }
8790
+ }
8791
+ function covers(rect, crop) {
8792
+ return rect.left <= crop.left + 0.5 && rect.top <= crop.top + 0.5 && rect.right >= crop.left + crop.width - 0.5 && rect.bottom >= crop.top + crop.height - 0.5;
8793
+ }
8794
+ function backgroundUnder(root, crop, doc, view) {
8795
+ const points = [
8796
+ [crop.left + crop.width / 2, crop.top + crop.height / 2],
8797
+ [crop.left + 1, crop.top + 1],
8798
+ [crop.left + crop.width - 1, crop.top + crop.height - 1]
8799
+ ];
8800
+ for (const [x, y] of points) {
8801
+ let stack = [];
8802
+ try {
8803
+ stack = doc.elementsFromPoint(x, y) ?? [];
8804
+ } catch {
8805
+ break;
8806
+ }
8807
+ for (const candidate of stack) {
8808
+ if (candidate === root || root.contains(candidate)) continue;
8809
+ if (candidate.hasAttribute(HOST_ATTRIBUTE)) continue;
8810
+ const color = opaqueBackground(candidate, view);
8811
+ if (color) return color;
8812
+ }
8813
+ }
8814
+ let current = root;
8815
+ let depth = 0;
8816
+ while (current !== null && depth < 64) {
8817
+ const color = opaqueBackground(current, view);
8818
+ if (color) return color;
8819
+ current = parentOrHost(current);
8820
+ depth++;
8821
+ }
8822
+ return "#ffffff";
8823
+ }
8824
+ function opaqueBackground(el, view) {
8825
+ try {
8826
+ const color = view.getComputedStyle(el).backgroundColor;
8827
+ if (!color || color === "transparent") return null;
8828
+ if (color.replace(/\s/g, "").startsWith("rgba(0,0,0,0)")) return null;
8829
+ return color;
8830
+ } catch {
8831
+ return null;
8832
+ }
8833
+ }
8834
+ function renderRootStyle(root, rootRect, doc, view, shiftX, shiftY) {
8835
+ const placement = {
8836
+ transform: `translate(${shiftX}px, ${shiftY}px)`,
8837
+ transformOrigin: "top left",
8838
+ margin: "0"
8839
+ };
8840
+ if (root === doc.documentElement) {
8841
+ return {
8842
+ ...placement,
8843
+ width: `${view.innerWidth}px`,
8844
+ minHeight: `${view.innerHeight}px`,
8845
+ overflow: "visible"
8846
+ };
8847
+ }
8848
+ return {
8849
+ ...placement,
8850
+ position: "static",
8851
+ inset: "auto",
8852
+ float: "none",
8853
+ // The size has to be stated, and this is the line the whole subtree render
8854
+ // stands on. The clone is laid out inside a `foreignObject` the size of
8855
+ // the CROP, and a block element with no width of its own fills its
8856
+ // parent: a 1440px section became 163px wide and the page reflowed into a
8857
+ // column, which is why the hero came back as an empty panel rather than a
8858
+ // misplaced one. `documentElement` was pinned the same way from the start,
8859
+ // which is why nobody met this until the root became something else.
8860
+ // Border-box, because a measured rect is a border box.
8861
+ boxSizing: "border-box",
8862
+ width: `${rootRect.width}px`,
8863
+ minHeight: `${rootRect.height}px`
8864
+ };
8865
+ }
8866
+ function withDeadline(work, ms, view) {
8867
+ return Promise.race([
8868
+ work,
8869
+ new Promise((resolve) => {
8870
+ try {
8871
+ view.setTimeout(() => resolve(null), ms);
8872
+ } catch {
8873
+ }
8874
+ })
8875
+ ]);
8876
+ }
8633
8877
  function isTextualField(el) {
8634
8878
  if (el instanceof HTMLTextAreaElement) return true;
8635
8879
  if (!(el instanceof HTMLInputElement)) return false;
@@ -8639,6 +8883,56 @@ function isTextualField(el) {
8639
8883
  function isCapturable(node) {
8640
8884
  return !(node instanceof Element && node.hasAttribute(HOST_ATTRIBUTE));
8641
8885
  }
8886
+ function repairAutoMargins(live, clone, view) {
8887
+ let repaired = 0;
8888
+ const walk = (liveNode, cloneNode2, depth) => {
8889
+ if (depth > 64 || repaired > 2e3) return;
8890
+ const liveKids = liveNode.children;
8891
+ const cloneKids = cloneNode2.children;
8892
+ let l = 0;
8893
+ let c = 0;
8894
+ while (l < liveKids.length && c < cloneKids.length) {
8895
+ const liveKid = liveKids[l];
8896
+ const cloneKid = cloneKids[c];
8897
+ if (liveKid.tagName !== cloneKid.tagName) {
8898
+ l++;
8899
+ continue;
8900
+ }
8901
+ if (applyUsedMargins(liveNode, liveKid, cloneKid, view)) repaired++;
8902
+ walk(liveKid, cloneKid, depth + 1);
8903
+ l++;
8904
+ c++;
8905
+ }
8906
+ };
8907
+ try {
8908
+ walk(live, clone, 0);
8909
+ } catch {
8910
+ }
8911
+ }
8912
+ function applyUsedMargins(liveParent, live, clone, view) {
8913
+ try {
8914
+ const style2 = view.getComputedStyle(live);
8915
+ if (style2.marginLeft !== "0px" || style2.marginRight !== "0px") return false;
8916
+ if (style2.position !== "static" && style2.position !== "relative") return false;
8917
+ if (style2.float !== "none") return false;
8918
+ const parentStyle = view.getComputedStyle(liveParent);
8919
+ if (!BLOCK_DISPLAYS.has(parentStyle.display)) return false;
8920
+ const rect = live.getBoundingClientRect();
8921
+ const parentRect = liveParent.getBoundingClientRect();
8922
+ const contentLeft = parentRect.left + parseFloat(parentStyle.borderLeftWidth || "0") + parseFloat(parentStyle.paddingLeft || "0");
8923
+ const contentRight = parentRect.right - parseFloat(parentStyle.borderRightWidth || "0") - parseFloat(parentStyle.paddingRight || "0");
8924
+ const left = rect.left - contentLeft;
8925
+ const right = contentRight - rect.right;
8926
+ if (left < 0.5 || right < 0.5 || Math.abs(left - right) > 1) return false;
8927
+ const target = clone;
8928
+ if (!target.style) return false;
8929
+ target.style.setProperty("margin-left", `${left}px`);
8930
+ target.style.setProperty("margin-right", `${right}px`);
8931
+ return true;
8932
+ } catch {
8933
+ return false;
8934
+ }
8935
+ }
8642
8936
  function maskClone(root) {
8643
8937
  const doc = root.ownerDocument ?? root;
8644
8938
  const view = doc.defaultView;
@@ -8671,7 +8965,8 @@ function maskClone(root) {
8671
8965
  el.value = masked;
8672
8966
  }
8673
8967
  const placeholder = el.getAttribute("placeholder");
8674
- if (placeholder) el.setAttribute("placeholder", maskPiiText(placeholder));
8968
+ if (placeholder)
8969
+ el.setAttribute("placeholder", maskPiiText(placeholder));
8675
8970
  }
8676
8971
  }
8677
8972
  node = walker.nextNode();
@@ -8711,6 +9006,8 @@ async function captureElementScreenshot(el) {
8711
9006
  const doc = el.ownerDocument;
8712
9007
  const view = doc.defaultView;
8713
9008
  if (!view || !doc.documentElement || !el.isConnected) return null;
9009
+ await waitUntilSettled(el, view);
9010
+ if (!el.isConnected) return null;
8714
9011
  const targetRectRaw = el.getBoundingClientRect();
8715
9012
  const targetRect = {
8716
9013
  left: targetRectRaw.left,
@@ -8719,31 +9016,54 @@ async function captureElementScreenshot(el) {
8719
9016
  height: targetRectRaw.height
8720
9017
  };
8721
9018
  if (targetRect.width <= 0 || targetRect.height <= 0) return null;
8722
- const cropRect = computeCropRect(targetRect, view.innerWidth, view.innerHeight);
9019
+ const idealCrop = computeCropRect(
9020
+ targetRect,
9021
+ view.innerWidth,
9022
+ view.innerHeight
9023
+ );
9024
+ if (idealCrop.width <= 0 || idealCrop.height <= 0) return null;
9025
+ const renderRoot = findRenderRoot(el, idealCrop, doc.documentElement, view);
9026
+ const rootRect = renderRoot.getBoundingClientRect();
9027
+ const rootBox = {
9028
+ left: rootRect.left,
9029
+ top: rootRect.top,
9030
+ width: rootRect.width,
9031
+ height: rootRect.height
9032
+ };
9033
+ const cropRect = clipCrop(idealCrop, rootBox);
8723
9034
  if (cropRect.width <= 0 || cropRect.height <= 0) return null;
8724
9035
  const scale = outputScale(view, cropRect);
8725
- const shiftX = -(cropRect.left + (view.scrollX || 0));
8726
- const shiftY = -(cropRect.top + (view.scrollY || 0));
8727
- const canvas = await domToCanvas(doc.documentElement, {
8728
- width: cropRect.width,
8729
- height: cropRect.height,
8730
- scale,
8731
- backgroundColor: "#ffffff",
8732
- timeout: RENDER_TIMEOUT_MS,
8733
- filter: isCapturable,
8734
- onCloneNode: (cloned) => {
8735
- maskClone(cloned);
8736
- },
8737
- font: { preferredFormat: "woff2" },
8738
- style: {
8739
- transform: `translate(${shiftX}px, ${shiftY}px)`,
8740
- transformOrigin: "top left",
8741
- width: `${view.innerWidth}px`,
8742
- minHeight: `${view.innerHeight}px`,
8743
- margin: "0",
8744
- overflow: "visible"
8745
- }
8746
- });
9036
+ const shiftX = -(cropRect.left - rootRect.left);
9037
+ const shiftY = -(cropRect.top - rootRect.top);
9038
+ const canvas = await withDeadline(
9039
+ domToCanvas(renderRoot, {
9040
+ width: cropRect.width,
9041
+ height: cropRect.height,
9042
+ scale,
9043
+ backgroundColor: backgroundUnder(renderRoot, cropRect, doc, view),
9044
+ // Per resource, not per capture. `RENDER_TIMEOUT_MS` is the total,
9045
+ // and it is the race around this call that enforces it.
9046
+ timeout: RESOURCE_TIMEOUT_MS,
9047
+ filter: isCapturable,
9048
+ onCloneNode: (cloned) => {
9049
+ if (cloned instanceof Element) {
9050
+ repairAutoMargins(renderRoot, cloned, view);
9051
+ }
9052
+ maskClone(cloned);
9053
+ },
9054
+ // No `preferredFormat`. Asking for woff2 sounds like asking for the
9055
+ // smaller file and is really a filter: a face whose chosen source is
9056
+ // not woff2 is dropped rather than downgraded, and the text renders in
9057
+ // the browser's serif fallback. On our own hero that turned the button
9058
+ // label into Times (D1258). Let the library take whatever each face
9059
+ // actually offers.
9060
+ font: {},
9061
+ style: renderRootStyle(renderRoot, rootBox, doc, view, shiftX, shiftY)
9062
+ }),
9063
+ RENDER_TIMEOUT_MS,
9064
+ view
9065
+ );
9066
+ if (!canvas) return null;
8747
9067
  const ctx = canvas.getContext("2d");
8748
9068
  if (!ctx) return null;
8749
9069
  drawOutline(ctx, targetRect, cropRect, scale);
@@ -8758,10 +9078,11 @@ async function captureElementScreenshot(el) {
8758
9078
  return null;
8759
9079
  }
8760
9080
  }
8761
- var CROP_MARGIN_RATIO, CROP_MARGIN_MAX_PX, CROP_MARGIN_MIN_PX, CROP_MAX_WIDTH, CROP_MAX_HEIGHT, OUTPUT_MAX_DPR, OUTPUT_MAX_PIXELS, DATA_URL_SOFT_MAX, RENDER_TIMEOUT_MS, PII_PATTERNS2, MASK2, OUTLINE_COLOR;
9081
+ var CROP_MARGIN_RATIO, CROP_MARGIN_MAX_PX, CROP_MARGIN_MIN_PX, CROP_MAX_WIDTH, CROP_MAX_HEIGHT, OUTPUT_MAX_DPR, OUTPUT_MAX_PIXELS, DATA_URL_SOFT_MAX, RENDER_TIMEOUT_MS, RESOURCE_TIMEOUT_MS, PII_PATTERNS2, MASK2, OUTLINE_COLOR, SETTLE_MAX_MS, SETTLE_STABLE_FRAMES, NODE_BUDGET, AREA_BUDGET_SCREENS, MIN_CROP_KEPT, BLOCK_DISPLAYS;
8762
9082
  var init_screenshot = __esm({
8763
9083
  "src/picker/screenshot.ts"() {
8764
9084
  init_dist();
9085
+ init_shadow();
8765
9086
  init_host();
8766
9087
  init_message();
8767
9088
  CROP_MARGIN_RATIO = 0.25;
@@ -8773,6 +9094,7 @@ var init_screenshot = __esm({
8773
9094
  OUTPUT_MAX_PIXELS = 26e5;
8774
9095
  DATA_URL_SOFT_MAX = 36e5;
8775
9096
  RENDER_TIMEOUT_MS = SCREENSHOT_RENDER_BUDGET_MS;
9097
+ RESOURCE_TIMEOUT_MS = 1500;
8776
9098
  PII_PATTERNS2 = [
8777
9099
  /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
8778
9100
  /\b\d{3}-\d{2}-\d{4}\b/g,
@@ -8780,6 +9102,12 @@ var init_screenshot = __esm({
8780
9102
  ];
8781
9103
  MASK2 = "[masked]";
8782
9104
  OUTLINE_COLOR = "#f04e23";
9105
+ SETTLE_MAX_MS = 900;
9106
+ SETTLE_STABLE_FRAMES = 2;
9107
+ NODE_BUDGET = 800;
9108
+ AREA_BUDGET_SCREENS = 3;
9109
+ MIN_CROP_KEPT = 0.4;
9110
+ BLOCK_DISPLAYS = /* @__PURE__ */ new Set(["block", "flow-root", "list-item", "table-cell"]);
8783
9111
  }
8784
9112
  });
8785
9113
 
@@ -8791,10 +9119,6 @@ function applySurvivalStyle(el, pointerEvents, offsets = []) {
8791
9119
  el.style.setProperty(property, value, "important");
8792
9120
  }
8793
9121
  }
8794
- function statusLabel(target) {
8795
- const { text, tag } = target.core;
8796
- return text ? `Sent "${text}" to ARCY` : `Sent <${tag}> to ARCY`;
8797
- }
8798
9122
  function createPickerOverlay(context) {
8799
9123
  let shell = createShell({ id: PICKER_HOST_ID });
8800
9124
  if (!shell) return null;
@@ -8817,28 +9141,36 @@ function createPickerOverlay(context) {
8817
9141
  for (const [property, value] of BANNER_STYLE) {
8818
9142
  banner.style.setProperty(property, value);
8819
9143
  }
8820
- const title = doc.createElement("span");
8821
- title.className = `${BANNER_CLASS}__title`;
8822
- title.textContent = "ARCY element picker";
9144
+ const mark = doc.createElement("span");
9145
+ mark.className = `${BANNER_CLASS}__mark`;
9146
+ const brand = doc.createElement("span");
9147
+ brand.className = `${BANNER_CLASS}__brand`;
9148
+ brand.textContent = "ARCY";
9149
+ const label = doc.createElement("span");
9150
+ label.className = `${BANNER_CLASS}__label`;
9151
+ label.textContent = "Element picker";
9152
+ mark.append(brand, label);
9153
+ const modes = doc.createElement("span");
9154
+ modes.className = `${BANNER_CLASS}__modes`;
9155
+ applySurvivalStyle(modes, "auto");
9156
+ modes.style.setProperty("position", "relative", "important");
8823
9157
  const pickButton = doc.createElement("button");
8824
9158
  pickButton.type = "button";
8825
9159
  pickButton.textContent = "Pick";
8826
9160
  const browseButton = doc.createElement("button");
8827
9161
  browseButton.type = "button";
8828
9162
  browseButton.textContent = "Browse";
8829
- const status = doc.createElement("span");
8830
- status.className = `${BANNER_CLASS}__status`;
8831
- status.textContent = "Click the element you want this step to target.";
9163
+ modes.append(pickButton, browseButton);
8832
9164
  const exitButton = doc.createElement("button");
8833
9165
  exitButton.type = "button";
9166
+ exitButton.className = `${BANNER_CLASS}__exit`;
8834
9167
  exitButton.textContent = "Exit";
8835
- banner.append(title, pickButton, browseButton, status, exitButton);
9168
+ banner.append(mark, modes, exitButton);
8836
9169
  shell.root.append(highlight, banner);
8837
9170
  function setMode(next) {
8838
9171
  mode = next;
8839
9172
  pickButton.classList.toggle("is-active", next === "pick");
8840
9173
  browseButton.classList.toggle("is-active", next === "browse");
8841
- status.textContent = next === "pick" ? "Click the element you want this step to target." : "Browsing. Clicks work normally; switch back to pick.";
8842
9174
  if (next === "browse") hideHighlight();
8843
9175
  }
8844
9176
  function hideHighlight() {
@@ -8921,7 +9253,6 @@ function createPickerOverlay(context) {
8921
9253
  const ranked = rankSelectors(el);
8922
9254
  fingerprint.selector = ranked.autoSelector;
8923
9255
  fingerprint.selectorCandidates = ranked.candidates;
8924
- status.textContent = statusLabel(fingerprint);
8925
9256
  hovered = el;
8926
9257
  moveHighlight(el);
8927
9258
  let matchCount = 0;
@@ -8932,17 +9263,29 @@ function createPickerOverlay(context) {
8932
9263
  context.onPick(fingerprint, matchCount);
8933
9264
  if (context.screenshotsEnabled === false) return;
8934
9265
  const token = ++screenshotToken;
8935
- void captureElementScreenshot(el).then((screenshot) => {
8936
- if (!screenshot || destroyed || token !== screenshotToken) return;
8937
- try {
8938
- context.onScreenshot?.(screenshot);
8939
- } catch {
8940
- }
9266
+ afterNextPaint(() => {
9267
+ if (destroyed || token !== screenshotToken) return;
9268
+ void captureElementScreenshot(el).then((screenshot) => {
9269
+ if (!screenshot || destroyed || token !== screenshotToken) return;
9270
+ try {
9271
+ context.onScreenshot?.(screenshot);
9272
+ } catch {
9273
+ }
9274
+ });
8941
9275
  });
8942
9276
  } catch (error) {
8943
9277
  warn(`The element picker could not read that element. ${String(error)}`);
8944
9278
  }
8945
9279
  };
9280
+ function afterNextPaint(fn) {
9281
+ const view = doc.defaultView;
9282
+ const raf = view?.requestAnimationFrame;
9283
+ if (typeof raf !== "function") {
9284
+ setTimeout(fn, 0);
9285
+ return;
9286
+ }
9287
+ raf.call(view, () => raf.call(view, fn));
9288
+ }
8946
9289
  function isEditable(target) {
8947
9290
  if (!(target instanceof Element)) return false;
8948
9291
  const tag = target.tagName;
@@ -9008,7 +9351,7 @@ function createPickerOverlay(context) {
9008
9351
  setMode("pick");
9009
9352
  return { destroy };
9010
9353
  }
9011
- var PICKER_HOST_ID, BANNER_CLASS, HIGHLIGHT_CLASS, PICKER_CSS, HIGHLIGHT_STYLE, BANNER_OFFSET, BANNER_STYLE;
9354
+ var PICKER_HOST_ID, BANNER_CLASS, HIGHLIGHT_CLASS, INK, SURFACE, HAIRLINE, TEXT, MUTED, ACCENT, ON_ACCENT, PICKER_CSS, HIGHLIGHT_STYLE, BANNER_OFFSET, BANNER_STYLE;
9012
9355
  var init_overlay = __esm({
9013
9356
  "src/picker/overlay.ts"() {
9014
9357
  init_capture();
@@ -9022,54 +9365,102 @@ var init_overlay = __esm({
9022
9365
  PICKER_HOST_ID = "arcy-picker";
9023
9366
  BANNER_CLASS = "arcy-picker-banner";
9024
9367
  HIGHLIGHT_CLASS = "arcy-picker-highlight";
9368
+ INK = "#17131A";
9369
+ SURFACE = "#201B23";
9370
+ HAIRLINE = "#332B33";
9371
+ TEXT = "#F3EEE8";
9372
+ MUTED = "#9E959A";
9373
+ ACCENT = "#FF5A3C";
9374
+ ON_ACCENT = "#17131A";
9025
9375
  PICKER_CSS = `
9026
9376
  .${BANNER_CLASS} {
9027
9377
  display: flex;
9028
9378
  align-items: center;
9029
- gap: 10px;
9030
- padding: 10px 14px;
9379
+ gap: 12px;
9380
+ padding: 7px 8px 7px 16px;
9031
9381
  border-radius: 999px;
9032
- background: #101828;
9033
- color: #ffffff;
9034
- font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
9035
- box-shadow: 0 8px 24px rgba(16, 24, 40, 0.28);
9382
+ background: ${INK};
9383
+ border: 1px solid ${HAIRLINE};
9384
+ color: ${TEXT};
9385
+ font: 400 14px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
9386
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.38);
9036
9387
  white-space: nowrap;
9037
9388
  }
9038
- .${BANNER_CLASS}__title {
9389
+ .${BANNER_CLASS}__mark {
9390
+ display: flex;
9391
+ align-items: baseline;
9392
+ gap: 7px;
9393
+ }
9394
+ .${BANNER_CLASS}__brand {
9039
9395
  font-weight: 600;
9040
- margin-right: 2px;
9396
+ letter-spacing: 0.02em;
9041
9397
  }
9042
- .${BANNER_CLASS}__status {
9043
- max-width: 260px;
9044
- overflow: hidden;
9045
- text-overflow: ellipsis;
9046
- color: #d0d5dd;
9398
+ /* The label role from DESIGN.md: 600, 12px, uppercase, tracked wide. */
9399
+ .${BANNER_CLASS}__label {
9400
+ font-size: 12px;
9401
+ font-weight: 600;
9402
+ letter-spacing: 0.14em;
9403
+ text-transform: uppercase;
9404
+ color: ${MUTED};
9405
+ }
9406
+ /* Pick and Browse are one control with two states, not two buttons that
9407
+ * happen to disagree. The old bar drew both as outlines and coloured whichever
9408
+ * was on, which reads as two things you can press rather than as a switch. */
9409
+ .${BANNER_CLASS}__modes {
9410
+ display: flex;
9411
+ align-items: center;
9412
+ gap: 2px;
9413
+ padding: 3px;
9414
+ border-radius: 999px;
9415
+ background: ${SURFACE};
9047
9416
  }
9048
9417
  .${BANNER_CLASS} button {
9049
9418
  appearance: none;
9050
- border: 1px solid rgba(255, 255, 255, 0.25);
9419
+ border: 1px solid transparent;
9051
9420
  border-radius: 999px;
9052
9421
  background: transparent;
9053
- color: #ffffff;
9422
+ color: ${MUTED};
9054
9423
  font: inherit;
9055
- padding: 4px 12px;
9424
+ font-weight: 500;
9425
+ padding: 5px 14px;
9056
9426
  cursor: pointer;
9427
+ transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
9057
9428
  }
9058
9429
  .${BANNER_CLASS} button:hover {
9059
- background: rgba(255, 255, 255, 0.1);
9430
+ color: ${TEXT};
9431
+ }
9432
+ .${BANNER_CLASS} button:focus-visible {
9433
+ outline: 2px solid ${ACCENT};
9434
+ outline-offset: 2px;
9060
9435
  }
9061
- .${BANNER_CLASS} button.is-active {
9062
- background: #f04e23;
9063
- border-color: #f04e23;
9436
+ /* The one filled control on the bar (DESIGN.md, one accent fill per surface). */
9437
+ .${BANNER_CLASS}__modes button.is-active {
9438
+ background: ${ACCENT};
9439
+ color: ${ON_ACCENT};
9440
+ font-weight: 600;
9441
+ }
9442
+ .${BANNER_CLASS}__modes button.is-active:hover {
9443
+ color: ${ON_ACCENT};
9444
+ }
9445
+ .${BANNER_CLASS}__exit {
9446
+ border-color: ${HAIRLINE} !important;
9447
+ }
9448
+ .${BANNER_CLASS}__exit:hover {
9449
+ background: ${SURFACE};
9064
9450
  }
9065
9451
  .${HIGHLIGHT_CLASS} {
9066
- border-radius: 3px;
9452
+ border-radius: 4px;
9067
9453
  }
9068
9454
  `;
9069
9455
  HIGHLIGHT_STYLE = [
9070
9456
  ["box-sizing", "border-box"],
9071
- ["border", "2px solid #f04e23"],
9072
- ["background", "rgba(240, 78, 35, 0.08)"]
9457
+ [
9458
+ "border",
9459
+ // ADR 0171's accent border rule: an edge, not a fill. The tint behind it
9460
+ // is what keeps a thin outline readable on a busy page.
9461
+ `2px solid ${ACCENT}`
9462
+ ],
9463
+ ["background", "rgba(255, 90, 60, 0.1)"]
9073
9464
  ];
9074
9465
  BANNER_OFFSET = [
9075
9466
  ["left", "50%"],
@@ -13485,15 +13876,19 @@ function createArcyInternals(internalOptions = {}) {
13485
13876
  token,
13486
13877
  sessionToken,
13487
13878
  sessionId: telemetry?.sessionId(),
13488
- // Slice 18.1's trigger conditions read traits set via identify();
13489
- // `Attributes` also allows null and string[], neither of which
13490
- // `ConditionContext.getTrait` accepts, so both degrade to
13491
- // "unresolvable" rather than being coerced into something wrong.
13879
+ // Slice 18.1's trigger conditions read attributes set via identify().
13880
+ // Every present, non-null value is reported as it is and judged here
13881
+ // not at all (ADR 0205). Filtering lists out used to report a written
13882
+ // key as absent, which made `has_any_value` false for a list the
13883
+ // customer had sent; what a value *means* is `condition.ts`'s call and
13884
+ // is made in one place there.
13885
+ //
13886
+ // Nothing becomes comparable by arriving: `condition.ts` refuses every
13887
+ // comparison on an object or an array (ADR 0198's opaque rule), so no
13888
+ // value here can become `is "[object Object]"`.
13492
13889
  getTrait: (codeName) => {
13493
13890
  const value = identity.attributes[codeName];
13494
- if (value === void 0 || value === null || Array.isArray(value)) {
13495
- return void 0;
13496
- }
13891
+ if (value === void 0 || value === null) return void 0;
13497
13892
  return value;
13498
13893
  },
13499
13894
  // D1299: absent entirely (rather than answering false) when storage