@eintrek/erp-shell 0.1.63 → 0.1.64

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.js CHANGED
@@ -1975,7 +1975,7 @@ function platformApiBase(app) {
1975
1975
  * This package's version, attached to help messages so the back office can
1976
1976
  * tell which shell a report came from. Keep in step with package.json.
1977
1977
  */
1978
- const SHELL_VERSION = "0.1.63";
1978
+ const SHELL_VERSION = "0.1.64";
1979
1979
 
1980
1980
  const HELP_APP_LABEL = {
1981
1981
  platform: "แพลตฟอร์ม",
@@ -4052,7 +4052,7 @@ function announceStep(d, suite, env) {
4052
4052
  const step = mission.steps[s.index];
4053
4053
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4054
4054
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
4055
- const why = s.bridge ? COPY.bridgeWhy(suite.appName) : resolveText(step.why, ctx);
4055
+ const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
4056
4056
  d.effects.push({
4057
4057
  type: "announce",
4058
4058
  text: `ขั้นที่ ${s.index + 1} จาก ${mission.steps.length}. ${title}${why ? `. ${why}` : ""}`,
@@ -4214,6 +4214,7 @@ function startMission(d, suite, env, key, replay) {
4214
4214
  const entry = d.state.progress.missions[key];
4215
4215
  // A mission already done is only ever replayed; its done status stays.
4216
4216
  const isReplay = replay || entry?.status === "done";
4217
+ const before = d.state.progress;
4217
4218
  d.state = { ...d.state, panelOpen: false, justDone: null };
4218
4219
  if (d.state.progress.welcome === "not_seen")
4219
4220
  withProgress(d, { welcome: "started" });
@@ -4231,7 +4232,9 @@ function startMission(d, suite, env, key, replay) {
4231
4232
  index = checkpointAtOrBefore(mission.steps, at);
4232
4233
  }
4233
4234
  enterStep(d, suite, env, mission, index, { replay: isReplay });
4234
- if (isReplay)
4235
+ // A replay never writes progress; only what starting it changed (the
4236
+ // welcome, first seen through a replay) is saved.
4237
+ if (isReplay && d.state.progress !== before)
4235
4238
  save(d);
4236
4239
  }
4237
4240
  function exitMission(d) {
@@ -4805,6 +4808,27 @@ function putBody(p, app) {
4805
4808
  TERMINAL.includes(p.server_status),
4806
4809
  };
4807
4810
  }
4811
+ function stable(v) {
4812
+ if (Array.isArray(v))
4813
+ return `[${v.map(stable).join(",")}]`;
4814
+ if (isRecord(v)) {
4815
+ return `{${Object.keys(v)
4816
+ .sort()
4817
+ .map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
4818
+ .join(",")}}`;
4819
+ }
4820
+ return JSON.stringify(v) ?? "null";
4821
+ }
4822
+ /**
4823
+ * What a PUT would store, without the rev it is checked against: two equal
4824
+ * keys mean the save would change nothing, and it is not sent (a replay
4825
+ * starting, a step entered again, never bump the row's rev). Key order does
4826
+ * not matter.
4827
+ */
4828
+ function payloadKey(p, app) {
4829
+ const { rev: _rev, restart: _restart, ...rest } = putBody(p, app);
4830
+ return stable(rest);
4831
+ }
4808
4832
  const MISSION_RANK = { todo: 0, skipped: 1, active: 1, done: 3 };
4809
4833
  const WELCOME_RANK = { not_seen: 0, skipped: 1, started: 1 };
4810
4834
  /**
@@ -4939,8 +4963,9 @@ function isPresent(el) {
4939
4963
  if (r.width <= 0 || r.height <= 0)
4940
4964
  return false;
4941
4965
  // Slid off the side (a closed drawer); off the top or bottom is only
4942
- // scrolled away, and the tour scrolls it back.
4943
- if (r.right <= 0 || r.left >= window.innerWidth)
4966
+ // scrolled away, and the tour scrolls it back. So is a tab scrolled out of
4967
+ // a row of tabs that scrolls sideways, as long as the row is on screen.
4968
+ if ((r.right <= 0 || r.left >= window.innerWidth) && !inSidewaysScroller(el))
4944
4969
  return false;
4945
4970
  if (el.getAttribute("data-state") === "closed")
4946
4971
  return false;
@@ -5020,6 +5045,26 @@ function sameRect(a, b) {
5020
5045
  const POPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
5021
5046
  /** Toasts stay usable above the tour. */
5022
5047
  const TOASTER_SELECTOR = "[data-sonner-toaster]";
5048
+ /** Open dialogs (Radix dialog, alert dialog, sheet, popover content). */
5049
+ const OPEN_DIALOG_SELECTOR = '[role="dialog"][data-state="open"],[role="alertdialog"][data-state="open"]';
5050
+ /**
5051
+ * Whether an open dialog of the app's covers the target: the target is
5052
+ * neither in it nor around it (a confirm opened by the step's own button).
5053
+ * The ring and the hand would be drawn over the dialog, pointing at a button
5054
+ * behind it.
5055
+ */
5056
+ function coveredByDialog(el) {
5057
+ if (!hasDocument() || !el)
5058
+ return false;
5059
+ for (const d of Array.from(document.querySelectorAll(OPEN_DIALOG_SELECTOR))) {
5060
+ if (inGuideLayer(d) || !isPresent(d))
5061
+ continue;
5062
+ if (d.contains(el) || el.contains(d))
5063
+ continue;
5064
+ return true;
5065
+ }
5066
+ return false;
5067
+ }
5023
5068
  function openPoppers() {
5024
5069
  if (!hasDocument())
5025
5070
  return [];
@@ -5037,18 +5082,181 @@ function prefersReducedMotion() {
5037
5082
  function isPhoneWidth() {
5038
5083
  return typeof window !== "undefined" && window.innerWidth < 768;
5039
5084
  }
5040
- /** Scrolls the target to the middle when it is out of view (instant under reduced motion). */
5085
+ /* ---------------- what is really on screen ---------------- */
5086
+ const INFINITE = 1e7;
5087
+ /** The overlap of two rectangles, or null when they do not meet. */
5088
+ function intersectRect(a, b) {
5089
+ const x = Math.max(a.x, b.x);
5090
+ const y = Math.max(a.y, b.y);
5091
+ const right = Math.min(a.x + a.width, b.x + b.width);
5092
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
5093
+ if (right - x <= 0 || bottom - y <= 0)
5094
+ return null;
5095
+ return { x, y, width: right - x, height: bottom - y };
5096
+ }
5097
+ /** The part of `rect` inside every clip (scroll boxes, the viewport); null when none is. */
5098
+ function visibleRect(rect, clips) {
5099
+ let r = rect;
5100
+ for (const c of clips) {
5101
+ if (!r)
5102
+ return null;
5103
+ r = intersectRect(r, c);
5104
+ }
5105
+ return r;
5106
+ }
5107
+ const clipsX = (v) => v !== "visible";
5108
+ const scrollsX = (v) => v === "auto" || v === "scroll";
5109
+ /**
5110
+ * The ancestors that cut the element off: anything with overflow other than
5111
+ * visible on an axis. The body and the root scroll the page and are covered by
5112
+ * the viewport. Read once per target; their rectangles are read every frame.
5113
+ */
5114
+ function clipAncestors(el) {
5115
+ const out = [];
5116
+ if (typeof getComputedStyle !== "function")
5117
+ return out;
5118
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5119
+ if (n.hasAttribute(GUIDE_LAYER_ATTRIBUTE))
5120
+ break;
5121
+ const s = getComputedStyle(n);
5122
+ const x = clipsX(s.overflowX);
5123
+ const y = clipsX(s.overflowY);
5124
+ if (x || y)
5125
+ out.push({ el: n, x, y });
5126
+ if (s.position === "fixed")
5127
+ break;
5128
+ }
5129
+ return out;
5130
+ }
5131
+ /** An ancestor's clip as a rectangle: unbounded on an axis it does not clip. */
5132
+ function clipRectOf(c) {
5133
+ const r = c.el.getBoundingClientRect();
5134
+ return {
5135
+ x: c.x ? r.left : -INFINITE,
5136
+ y: c.y ? r.top : -INFINITE,
5137
+ width: c.x ? r.width : INFINITE * 2,
5138
+ height: c.y ? r.height : INFINITE * 2,
5139
+ };
5140
+ }
5141
+ function viewportRect() {
5142
+ const vv = typeof window !== "undefined" ? window.visualViewport : undefined;
5143
+ return {
5144
+ x: 0,
5145
+ y: vv ? vv.offsetTop : 0,
5146
+ width: window.innerWidth,
5147
+ height: vv ? vv.height : window.innerHeight,
5148
+ };
5149
+ }
5150
+ /**
5151
+ * The part of the element a person can see: its box cut by every scroll box
5152
+ * around it and by the viewport. The spotlight, its hole, the ring and the
5153
+ * hand use this, so a tab half out of a sideways-scrolling row of tabs is not
5154
+ * spotlit where it cannot be pressed. Null when none of it is on screen.
5155
+ */
5156
+ function visibleRectOf(el, ancestors = clipAncestors(el)) {
5157
+ return visibleRect(rectOf(el), [...ancestors.map(clipRectOf), viewportRect()]);
5158
+ }
5159
+ /** Inside a box that scrolls sideways (with somewhere to scroll) that is itself on screen. */
5160
+ function inSidewaysScroller(el) {
5161
+ if (typeof getComputedStyle !== "function")
5162
+ return false;
5163
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5164
+ const s = getComputedStyle(n);
5165
+ if (scrollsX(s.overflowX) && n.scrollWidth > n.clientWidth + 1) {
5166
+ const r = n.getBoundingClientRect();
5167
+ return r.width > 0 && r.right > 0 && r.left < window.innerWidth;
5168
+ }
5169
+ }
5170
+ return false;
5171
+ }
5172
+ /**
5173
+ * How to bring a target into view, or null when it already is: to the middle
5174
+ * when it is above or below the comfortable band (under the header, at the
5175
+ * bottom edge), the nearest edge when only a side is cut off (a tab out of a
5176
+ * sideways-scrolling row, on a phone).
5177
+ */
5178
+ function revealPlan(rect, visible, viewportHeight) {
5179
+ const offVertically = rect.y < 70 || rect.y + rect.height > viewportHeight - 20;
5180
+ const cutSideways = !visible || visible.width < rect.width - 1;
5181
+ if (!offVertically && !cutSideways)
5182
+ return null;
5183
+ return { block: offVertically ? "center" : "nearest", inline: "nearest" };
5184
+ }
5185
+ /** Scrolls the target into view (instant under reduced motion). */
5041
5186
  function revealTarget(el) {
5042
- const r = el.getBoundingClientRect();
5043
5187
  const vh = window.visualViewport?.height ?? window.innerHeight;
5044
- if (r.top < 70 || r.bottom > vh - 20) {
5045
- try {
5046
- el.scrollIntoView({ block: "center", behavior: prefersReducedMotion() ? "auto" : "smooth" });
5188
+ const plan = revealPlan(rectOf(el), visibleRectOf(el), vh);
5189
+ if (!plan)
5190
+ return;
5191
+ try {
5192
+ el.scrollIntoView({ ...plan, behavior: prefersReducedMotion() ? "auto" : "smooth" });
5193
+ }
5194
+ catch {
5195
+ el.scrollIntoView();
5196
+ }
5197
+ }
5198
+ /* ---------------- the scrim's holes, as one union ---------------- */
5199
+ /**
5200
+ * The scrim's blocking area: the screen (with a 10 px margin) minus the union
5201
+ * of the holes, as rectangles that do not overlap. Cutting each hole out with
5202
+ * an even-odd fill made the place where two holes overlap (a field and the
5203
+ * select list open over it) solid again, and the option there could not be
5204
+ * pressed.
5205
+ *
5206
+ * Every hole edge splits the screen into a grid; a cell is blocked when no
5207
+ * hole covers it. Cells in a row are joined, and identical rows stacked.
5208
+ */
5209
+ function blockedRects(holes, width, height) {
5210
+ const x0 = -10;
5211
+ const y0 = -10;
5212
+ const x1 = width + 10;
5213
+ const y1 = height + 10;
5214
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
5215
+ const hs = holes
5216
+ .filter(h => h.width > 0 && h.height > 0)
5217
+ .map(h => ({
5218
+ l: clamp(h.x, x0, x1),
5219
+ t: clamp(h.y, y0, y1),
5220
+ r: clamp(h.x + h.width, x0, x1),
5221
+ b: clamp(h.y + h.height, y0, y1),
5222
+ }))
5223
+ .filter(h => h.r > h.l && h.b > h.t);
5224
+ if (!hs.length)
5225
+ return [{ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }];
5226
+ const uniq = (v) => Array.from(new Set(v)).sort((a, b) => a - b);
5227
+ const xs = uniq([x0, x1, ...hs.flatMap(h => [h.l, h.r])]);
5228
+ const ys = uniq([y0, y1, ...hs.flatMap(h => [h.t, h.b])]);
5229
+ const out = [];
5230
+ let prev = null;
5231
+ for (let j = 0; j < ys.length - 1; j++) {
5232
+ const top = ys[j];
5233
+ const bottom = ys[j + 1];
5234
+ const cy = (top + bottom) / 2;
5235
+ const row = [];
5236
+ for (let i = 0; i < xs.length - 1; i++) {
5237
+ const left = xs[i];
5238
+ const right = xs[i + 1];
5239
+ const cx = (left + right) / 2;
5240
+ const covered = hs.some(h => cx > h.l && cx < h.r && cy > h.t && cy < h.b);
5241
+ if (covered)
5242
+ continue;
5243
+ const last = row[row.length - 1];
5244
+ if (last && last.x + last.width === left)
5245
+ last.width = right - last.x;
5246
+ else
5247
+ row.push({ x: left, y: top, width: right - left, height: bottom - top });
5047
5248
  }
5048
- catch {
5049
- el.scrollIntoView();
5249
+ const key = row.map(r => `${r.x},${r.width}`).join("|");
5250
+ if (prev && prev.key === key) {
5251
+ for (const r of prev.rects)
5252
+ r.height += bottom - top;
5253
+ }
5254
+ else {
5255
+ out.push(...row);
5256
+ prev = { key, rects: row };
5050
5257
  }
5051
5258
  }
5259
+ return out;
5052
5260
  }
5053
5261
 
5054
5262
  /**
@@ -5070,7 +5278,8 @@ const GUIDE_CSS = `
5070
5278
  .eg-i{pointer-events:auto}
5071
5279
  .eg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
5072
5280
  .eg-scrim-svg{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
5073
- .eg-scrim-svg path{fill:var(--eg-scrim);pointer-events:visiblePainted;fill-rule:evenodd}
5281
+ .eg-scrim-svg .eg-scrim-fill{fill:var(--eg-scrim);pointer-events:none}
5282
+ .eg-scrim-svg .eg-scrim-hit{fill:transparent;pointer-events:all}
5074
5283
  .eg-scrim{position:fixed;inset:0;background:var(--eg-scrim);pointer-events:auto}
5075
5284
  .eg-scrim.eg-light{background:rgba(12,20,34,.45)}
5076
5285
  .eg-scrim.eg-faint{background:rgba(12,20,34,.35)}
@@ -5179,6 +5388,7 @@ const GUIDE_CSS = `
5179
5388
  .eg-q-item.eg-done .eg-t b{color:var(--eg-muted)}
5180
5389
  .eg-q-item .eg-t small{display:block;font-size:12px;color:var(--eg-muted)}
5181
5390
  .eg-q-item .eg-t small.eg-sk{color:#b06a00}
5391
+ .eg-q-item .eg-t small.eg-lock{color:var(--eg-fg);font-weight:500}
5182
5392
  .eg-q-item .eg-btn{height:32px;padding:0 12px;font-size:13px}
5183
5393
  .eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
5184
5394
  .eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
@@ -5243,15 +5453,51 @@ function roundRect(r, radius) {
5243
5453
  `H${x + rad}A${rad} ${rad} 0 0 1 ${x} ${y + h - rad}` +
5244
5454
  `V${y + rad}A${rad} ${rad} 0 0 1 ${x + rad} ${y}Z`);
5245
5455
  }
5456
+ const rectPath = (r) => `M${r.x} ${r.y}H${r.x + r.width}V${r.y + r.height}H${r.x}Z`;
5246
5457
  /**
5247
- * The dim layer that eats every press outside the holes. One SVG path with
5248
- * even-odd holes: a press inside a hole reaches the page underneath, any
5249
- * other lands on the path (marked data-eg-block) and is cancelled.
5458
+ * The dim layer that eats every press outside the holes. The holes are a
5459
+ * union: where two overlap (a field and the select list open over it) the
5460
+ * page stays open.
5461
+ *
5462
+ * - The look: one rectangle masked by the rounded holes (a mask adds holes
5463
+ * up, so an overlap is not filled back in).
5464
+ * - The presses: a transparent path made of the screen minus the holes, as
5465
+ * rectangles that do not overlap (blockedRects). A press inside a hole
5466
+ * reaches the page underneath; any other lands on the path (marked
5467
+ * data-eg-block) and is cancelled.
5250
5468
  */
5251
5469
  function Scrim({ holes, width, height }) {
5252
- const outer = `M-10 -10H${width + 10}V${height + 10}H-10Z`;
5253
- const d = outer + holes.map(h => roundRect(h, 12)).join("");
5254
- return (jsxRuntime.jsx("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: jsxRuntime.jsx("path", { d: d, "data-eg-block": "" }) }));
5470
+ const maskId = "eg-scrim-mask-" + React__namespace.useId().replace(/[^a-zA-Z0-9_-]/g, "");
5471
+ const block = blockedRects(holes, width, height).map(rectPath).join("");
5472
+ return (jsxRuntime.jsxs("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: [holes.length ? (jsxRuntime.jsx("defs", { children: jsxRuntime.jsxs("mask", { id: maskId, maskUnits: "userSpaceOnUse", x: -10, y: -10, width: width + 20, height: height + 20, children: [jsxRuntime.jsx("rect", { x: -10, y: -10, width: width + 20, height: height + 20, fill: "#fff" }), holes.map((h, i) => (jsxRuntime.jsx("path", { d: roundRect(h, 12), fill: "#000" }, i)))] }) })) : null, jsxRuntime.jsx("rect", { className: "eg-scrim-fill", x: -10, y: -10, width: width + 20, height: height + 20, mask: holes.length ? `url(#${maskId})` : undefined }), jsxRuntime.jsx("path", { className: "eg-scrim-hit", d: block, "data-eg-block": "" })] }));
5473
+ }
5474
+ /** Whether two rectangles overlap. */
5475
+ const meets = (a, b) => !!intersectRect(a, b);
5476
+ /** The smallest rectangle around all of them. */
5477
+ function boundingRect(rs) {
5478
+ const x = Math.min(...rs.map(r => r.x));
5479
+ const y = Math.min(...rs.map(r => r.y));
5480
+ const right = Math.max(...rs.map(r => r.x + r.width));
5481
+ const bottom = Math.max(...rs.map(r => r.y + r.height));
5482
+ return { x, y, width: right - x, height: bottom - y };
5483
+ }
5484
+ /** Where the bubble may go, in order of preference. */
5485
+ const BUBBLE_PLACEMENTS = ["right", "bottom", "top", "left"];
5486
+ /**
5487
+ * The first placement (in BUBBLE_PLACEMENTS order) whose bubble is on screen,
5488
+ * off the target (with its open list), and off everything to keep clear;
5489
+ * failing that, the first that is only on screen and off the target; -1 when
5490
+ * none is.
5491
+ */
5492
+ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
5493
+ const box = (r) => ({ x: r.x, y: r.y, width: bw, height: bh });
5494
+ const fits = (b) => b.x >= bounds.x - 1 && b.y >= bounds.y - 1 && b.x + b.width <= bounds.x + bounds.width + 1 && b.y + b.height <= bounds.y + bounds.height + 1;
5495
+ const ok = results.map(r => {
5496
+ const b = box(r);
5497
+ return fits(b) && !meets(b, anchor);
5498
+ });
5499
+ const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
5500
+ return best >= 0 ? best : ok.indexOf(true);
5255
5501
  }
5256
5502
  const BUBBLE_TITLE_ID = "eg-bubble-title";
5257
5503
  const BUBBLE_WHY_ID = "eg-bubble-why";
@@ -5291,12 +5537,21 @@ function StepLayer(p) {
5291
5537
  const gap = p.showHand ? 34 : 14;
5292
5538
  const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
5293
5539
  const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
5540
+ // What the bubble sits beside: the target, with an open list over it (the
5541
+ // list is what the person is choosing from now). What it must not cover:
5542
+ // open lists, and the other anchors the step allows (a map step's
5543
+ // coordinate fields).
5544
+ const popoverList = p.popovers ?? [];
5545
+ const anchor = spot ? boundingRect([spot, ...popoverList.filter(o => meets(o, spot))]) : null;
5546
+ const clear = [...popoverList, ...p.holes];
5547
+ const anchorKey = anchor ? `${anchor.x}|${anchor.y}|${anchor.width}|${anchor.height}` : "";
5548
+ const clearKey = clear.map(r => `${r.x},${r.y},${r.width},${r.height}`).join(";");
5294
5549
  React.useLayoutEffect(() => {
5295
5550
  const el = bubbleRef.current;
5296
5551
  if (!el || !showBubble)
5297
5552
  return;
5298
5553
  const m = 16;
5299
- if (!spot) {
5554
+ if (!spot || !anchor) {
5300
5555
  // Nothing to point at: the middle of the screen (the bottom on a phone).
5301
5556
  const bw = el.offsetWidth;
5302
5557
  const bh = el.offsetHeight;
@@ -5307,13 +5562,13 @@ function StepLayer(p) {
5307
5562
  }
5308
5563
  if (phone) {
5309
5564
  // Docked to the top or the bottom of the screen, whichever covers less
5310
- // of the target (target in the lower half: top).
5565
+ // of the target and its open list (target in the lower half: top).
5311
5566
  const vh = viewport.height;
5312
5567
  const oy = viewport.offsetTop;
5313
5568
  const bh = el.offsetHeight;
5314
5569
  const topT = oy + m + 4;
5315
5570
  const botT = oy + vh - bh - m;
5316
- const { y, height: h } = spot;
5571
+ const { y, height: h } = anchor;
5317
5572
  const overlapTop = Math.max(0, topT + bh + 8 - y);
5318
5573
  const overlapBot = Math.max(0, y + h + gap - botT);
5319
5574
  const top = y + h / 2 > vh / 2
@@ -5327,40 +5582,42 @@ function StepLayer(p) {
5327
5582
  return;
5328
5583
  }
5329
5584
  let cancelled = false;
5585
+ const a = anchor;
5330
5586
  const ref = {
5331
5587
  getBoundingClientRect: () => ({
5332
- x: spot.x,
5333
- y: spot.y,
5334
- left: spot.x,
5335
- top: spot.y,
5336
- width: spot.width,
5337
- height: spot.height,
5338
- right: spot.x + spot.width,
5339
- bottom: spot.y + spot.height,
5588
+ x: a.x,
5589
+ y: a.y,
5590
+ left: a.x,
5591
+ top: a.y,
5592
+ width: a.width,
5593
+ height: a.height,
5594
+ right: a.x + a.width,
5595
+ bottom: a.y + a.height,
5340
5596
  }),
5341
5597
  };
5342
- dom.computePosition(ref, el, {
5598
+ const at = (placement) => dom.computePosition(ref, el, {
5343
5599
  strategy: "fixed",
5344
- placement: "right",
5600
+ placement,
5345
5601
  middleware: [
5346
- dom.offset(({ placement }) => (placement.startsWith("bottom") ? gap : 14)),
5347
- dom.flip({ fallbackPlacements: ["bottom", "top", "left"], padding: m }),
5602
+ dom.offset(placement.startsWith("bottom") ? gap : 14),
5348
5603
  dom.shift({ padding: m }),
5349
5604
  ...(arrowRef.current ? [dom.arrow({ element: arrowRef.current, padding: 14 })] : []),
5350
5605
  ],
5351
- })
5352
- .then(({ x, y, placement, middlewareData }) => {
5606
+ });
5607
+ Promise.all(BUBBLE_PLACEMENTS.map(at))
5608
+ .then(results => {
5353
5609
  if (cancelled)
5354
5610
  return;
5355
5611
  const bw = el.offsetWidth;
5356
5612
  const bh = el.offsetHeight;
5357
- const fits = x >= m - 1 && y >= m - 1 && x + bw <= window.innerWidth - m + 1 && y + bh <= viewport.height + viewport.offsetTop - m + 1;
5358
- const overlaps = x < spot.x + spot.width && x + bw > spot.x && y < spot.y + spot.height && y + bh > spot.y;
5359
- if (!fits || overlaps) {
5613
+ const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
5614
+ const i = pickPlacement(results, bw, bh, a, clear, bounds);
5615
+ if (i < 0) {
5360
5616
  // Nowhere beside it: the bottom-right corner.
5361
5617
  setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
5362
5618
  return;
5363
5619
  }
5620
+ const { x, y, placement, middlewareData } = results[i];
5364
5621
  const side = placement.split("-")[0];
5365
5622
  setPos({ left: x, top: y, side, ax: middlewareData.arrow?.x, ay: middlewareData.arrow?.y });
5366
5623
  })
@@ -5371,11 +5628,14 @@ function StepLayer(p) {
5371
5628
  return () => {
5372
5629
  cancelled = true;
5373
5630
  };
5374
- // spotKey/contentKey stand for spot and the bubble's size.
5631
+ // The keys stand for spot, the lists, the holes and the bubble's size.
5375
5632
  // eslint-disable-next-line react-hooks/exhaustive-deps
5376
- }, [spotKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
5377
- const holes = [...(spot ? [spot] : []), ...p.holes];
5378
- const hand = spot && p.showHand && p.phase === "ready" ? handPosition(spot, viewport.height) : null;
5633
+ }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
5634
+ const popovers = p.popovers ?? [];
5635
+ const covered = !!p.covered;
5636
+ const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
5637
+ const ringShown = !!spot && p.phase === "ready" && !covered && !popovers.some(o => meets(o, spot));
5638
+ const hand = spot && p.showHand && p.phase === "ready" && !covered && !popovers.length ? handPosition(spot, viewport.height) : null;
5379
5639
  let arrowStyle;
5380
5640
  if (pos && pos.side !== "dock") {
5381
5641
  if (pos.side === "right")
@@ -5387,7 +5647,7 @@ function StepLayer(p) {
5387
5647
  else
5388
5648
  arrowStyle = { bottom: -7, left: pos.ax };
5389
5649
  }
5390
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Scrim, { holes: p.phase === "ready" ? holes : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && p.phase === "ready" ? (jsxRuntime.jsx("div", { ref: ringRef, className: "eg-ring" + (p.moving ? " eg-move" : ""), style: { left: spot.x, top: spot.y, width: spot.width, height: spot.height }, "aria-hidden": "true" })) : null, hand ? (jsxRuntime.jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsxRuntime.jsx("svg", { viewBox: "0 0 24 24", children: jsxRuntime.jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (jsxRuntime.jsxs("div", { ref: bubbleRef, className: "eg-bubble" + (p.bubble.error ? " eg-err" : ""), role: "dialog", "aria-modal": "false", "aria-labelledby": BUBBLE_TITLE_ID, "aria-describedby": BUBBLE_WHY_ID, tabIndex: -1, "data-eg-bubble": "", style: {
5650
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Scrim, { holes: p.phase === "ready" ? holes : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && ringShown ? (jsxRuntime.jsx("div", { ref: ringRef, className: "eg-ring" + (p.moving ? " eg-move" : ""), style: { left: spot.x, top: spot.y, width: spot.width, height: spot.height }, "aria-hidden": "true" })) : null, hand ? (jsxRuntime.jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsxRuntime.jsx("svg", { viewBox: "0 0 24 24", children: jsxRuntime.jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (jsxRuntime.jsxs("div", { ref: bubbleRef, className: "eg-bubble" + (p.bubble.error ? " eg-err" : ""), role: "dialog", "aria-modal": "false", "aria-labelledby": BUBBLE_TITLE_ID, "aria-describedby": BUBBLE_WHY_ID, tabIndex: -1, "data-eg-bubble": "", style: {
5391
5651
  left: pos?.left ?? 0,
5392
5652
  top: pos?.top ?? 0,
5393
5653
  width: phone ? viewport.width - 32 : undefined,
@@ -5398,7 +5658,7 @@ function BubbleBody(p) {
5398
5658
  const b = p.bubble;
5399
5659
  const title = b.error ? b.error.title : b.title;
5400
5660
  const why = b.error ? (b.error.detail ?? "") : b.why;
5401
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsxRuntime.jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsxRuntime.jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsxRuntime.jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsxRuntime.jsx(GuideTitle, { text: title }) }), jsxRuntime.jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: why }), jsxRuntime.jsxs("div", { className: "eg-b-f", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxRuntime.jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onEndWithoutSave, children: "\u0E08\u0E1A\u0E42\u0E14\u0E22\u0E44\u0E21\u0E48\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01" })) : null, b.showNext ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
5661
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsxRuntime.jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsxRuntime.jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsxRuntime.jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsxRuntime.jsx(GuideTitle, { text: title }) }), jsxRuntime.jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsxRuntime.jsx(GuideTitle, { text: why }) }), jsxRuntime.jsxs("div", { className: "eg-b-f", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxRuntime.jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onEndWithoutSave, children: "\u0E08\u0E1A\u0E42\u0E14\u0E22\u0E44\u0E21\u0E48\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01" })) : null, b.showNext ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
5402
5662
  }
5403
5663
  /** The fingertip at ≈62% across and down the target; upside down near the bottom. */
5404
5664
  function handPosition(spot, viewportHeight) {
@@ -5511,6 +5771,9 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
5511
5771
  meta = jsxRuntime.jsx("span", { children: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E27\u0E49" });
5512
5772
  else if (v === "waiting" && gate)
5513
5773
  meta = gate.pending;
5774
+ // Locked: what to do first gets its own line under the title, so the title
5775
+ // keeps the row's width and the button stays short.
5776
+ const lockHint = v === "locked" && item.lockedBy ? `ทำ ${item.lockedBy} ก่อน` : null;
5514
5777
  let button;
5515
5778
  if (v === "done")
5516
5779
  button = jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onStart(item.key, true), children: "\u0E17\u0E33\u0E0B\u0E49\u0E33" });
@@ -5519,11 +5782,11 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
5519
5782
  else if (v === "waiting" && gate)
5520
5783
  button = (jsxRuntime.jsx("a", { className: "eg-btn eg-ghost", href: gate.href, style: { textDecoration: "none" }, children: gate.label }));
5521
5784
  else if (v === "locked")
5522
- button = (jsxRuntime.jsxs("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, children: ["\u0E17\u0E33 ", item.lockedBy, " \u0E01\u0E48\u0E2D\u0E19"] }));
5785
+ button = (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, "aria-label": lockHint ? `เริ่ม (${lockHint})` : undefined, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" }));
5523
5786
  else
5524
5787
  button = (jsxRuntime.jsx("button", { type: "button", className: "eg-btn " + (v === "next" ? "eg-primary" : "eg-ghost"), onClick: () => onStart(item.key, false), children: v === "skipped" || v === "active" ? "ทำต่อ" : "เริ่ม" }));
5525
5788
  const dismissable = item.optional && (v === "todo" || v === "next");
5526
- return (jsxRuntime.jsxs("li", { className: cls, children: [jsxRuntime.jsx("span", { className: "eg-n", children: ticked ? jsxRuntime.jsx(IconCheck, {}) : n }), jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: item.title }), jsxRuntime.jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxRuntime.jsxs("span", { className: "eg-q-acts", children: [button, jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: () => onDismiss(item.key), children: "\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E49\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E19\u0E35\u0E49" })] })) : (button)] }));
5789
+ return (jsxRuntime.jsxs("li", { className: cls, children: [jsxRuntime.jsx("span", { className: "eg-n", children: ticked ? jsxRuntime.jsx(IconCheck, {}) : n }), jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: item.title }), lockHint ? jsxRuntime.jsx("small", { className: "eg-lock", children: lockHint }) : null, jsxRuntime.jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxRuntime.jsxs("span", { className: "eg-q-acts", children: [button, jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: () => onDismiss(item.key), children: "\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E49\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E19\u0E35\u0E49" })] })) : (button)] }));
5527
5790
  }
5528
5791
  function GuideToast({ text }) {
5529
5792
  return (jsxRuntime.jsx("div", { className: "eg-toast", role: "status", children: text }));
@@ -5711,6 +5974,9 @@ function GuideHostInner({ config, organizationId, code }) {
5711
5974
  /* ---------------- machine ---------------- */
5712
5975
  const [state, setState] = React.useState(null);
5713
5976
  const stateRef = React.useRef(null);
5977
+ // What the server holds (payloadKey), so a save that would change nothing
5978
+ // is not sent and does not bump its rev.
5979
+ const savedKey = React.useRef(null);
5714
5980
  React.useEffect(() => {
5715
5981
  if (!answer || answer.audience === "none" || answer.newerOnServer) {
5716
5982
  stateRef.current = null;
@@ -5719,6 +5985,7 @@ function GuideHostInner({ config, organizationId, code }) {
5719
5985
  }
5720
5986
  if (stateRef.current)
5721
5987
  return;
5988
+ savedKey.current = answer.progress && answer.progress.rev > 0 ? payloadKey(answer.progress, suite.app) : null;
5722
5989
  // Resume from whichever copy is further along: the server's, or this
5723
5990
  // tab's session copy from before a refresh (its rev is the server's).
5724
5991
  const mirror = readMirror(organizationId, suite);
@@ -5775,7 +6042,11 @@ function GuideHostInner({ config, organizationId, code }) {
5775
6042
  const cur = stateRef.current;
5776
6043
  if (!cur)
5777
6044
  return;
6045
+ if (payloadKey(cur.progress, suite.app) === savedKey.current)
6046
+ continue;
5778
6047
  const row = await saveGuides(organizationId, suite, cur.progress);
6048
+ if (row)
6049
+ savedKey.current = payloadKey(row, suite.app);
5779
6050
  if (row && stateRef.current) {
5780
6051
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
5781
6052
  dispatchRef.current({ type: "serverRow", progress: row });
@@ -5952,7 +6223,9 @@ function GuideHostInner({ config, organizationId, code }) {
5952
6223
  scrolling, dialogs animating in, the drawer sliding, the keyboard. */
5953
6224
  const [layout, setLayout] = React.useState({
5954
6225
  spot: null,
6226
+ covered: false,
5955
6227
  holes: [],
6228
+ popovers: [],
5956
6229
  vw: 0,
5957
6230
  vh: 0,
5958
6231
  oy: 0,
@@ -5964,9 +6237,22 @@ function GuideHostInner({ config, organizationId, code }) {
5964
6237
  }
5965
6238
  let raf = 0;
5966
6239
  let lostSince = null;
5967
- let last = { spot: null, holes: [], vw: 0, vh: 0, oy: 0 };
6240
+ let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
5968
6241
  targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
5969
6242
  const allow = stepInfo?.step.allow ?? [];
6243
+ // The spotlight is the part of the target a person can see: cut by the
6244
+ // scroll boxes around it (a row of tabs that scrolls sideways) and the
6245
+ // viewport. Wholly out of view (scrolled away up or down), its own box,
6246
+ // and the tour scrolls it back.
6247
+ let clipFor = null;
6248
+ let clips = [];
6249
+ const spotOf = (el) => {
6250
+ if (el !== clipFor) {
6251
+ clipFor = el;
6252
+ clips = clipAncestors(el);
6253
+ }
6254
+ return padRect(visibleRectOf(el, clips) ?? rectOf(el), TARGET_PAD);
6255
+ };
5970
6256
  const tick = () => {
5971
6257
  raf = requestAnimationFrame(tick);
5972
6258
  const vv = window.visualViewport;
@@ -5981,14 +6267,14 @@ function GuideHostInner({ config, organizationId, code }) {
5981
6267
  let spot = null;
5982
6268
  if (target && el && isPresent(el)) {
5983
6269
  lostSince = null;
5984
- spot = padRect(rectOf(el), TARGET_PAD);
6270
+ spot = spotOf(el);
5985
6271
  }
5986
6272
  else if (target) {
5987
6273
  // Maybe re-rendered under another node.
5988
6274
  const again = findAnchor(target.anchor, target.id);
5989
6275
  if (again) {
5990
6276
  targetElRef.current = again;
5991
- spot = padRect(rectOf(again), TARGET_PAD);
6277
+ spot = spotOf(again);
5992
6278
  lostSince = null;
5993
6279
  }
5994
6280
  else if (phase === "ready") {
@@ -6001,21 +6287,25 @@ function GuideHostInner({ config, organizationId, code }) {
6001
6287
  }
6002
6288
  if (phase === "waiting" && spot)
6003
6289
  dispatchRef.current({ type: "sync" });
6004
- const holes = [];
6290
+ const popovers = [];
6005
6291
  for (const p of openPoppers())
6006
- holes.push(padRect(rectOf(p), 2));
6292
+ popovers.push(padRect(rectOf(p), 2));
6293
+ const holes = [];
6007
6294
  for (const a of allow) {
6008
6295
  const ael = findAnchor(a);
6009
6296
  if (ael)
6010
6297
  holes.push(padRect(rectOf(ael), TARGET_PAD));
6011
6298
  }
6012
- const next = { spot, holes, vw, vh, oy };
6299
+ const covered = !!spot && coveredByDialog(targetElRef.current);
6300
+ const next = { spot, covered, holes, popovers, vw, vh, oy };
6301
+ const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
6013
6302
  if (!sameRect(next.spot, last.spot) ||
6303
+ next.covered !== last.covered ||
6014
6304
  next.vw !== last.vw ||
6015
6305
  next.vh !== last.vh ||
6016
6306
  next.oy !== last.oy ||
6017
- next.holes.length !== last.holes.length ||
6018
- next.holes.some((h, i) => !sameRect(h, last.holes[i]))) {
6307
+ !sameList(next.holes, last.holes) ||
6308
+ !sameList(next.popovers, last.popovers)) {
6019
6309
  last = next;
6020
6310
  setLayout(next);
6021
6311
  }
@@ -6399,7 +6689,7 @@ function GuideHostInner({ config, organizationId, code }) {
6399
6689
  const { mission, step, missionNo } = stepInfo;
6400
6690
  const kind = stepKind(step);
6401
6691
  const bridge = screen.bridge;
6402
- content = (jsxRuntime.jsx(StepLayer, { spot: layout.spot, holes: layout.holes, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
6692
+ content = (jsxRuntime.jsx(StepLayer, { spot: layout.spot, holes: layout.holes, popovers: layout.popovers, covered: layout.covered, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
6403
6693
  missionNo,
6404
6694
  missionTitle: mission.title,
6405
6695
  replay: screen.replay,