@eintrek/erp-shell 0.1.63 → 0.1.65

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.65";
1979
1979
 
1980
1980
  const HELP_APP_LABEL = {
1981
1981
  platform: "แพลตฟอร์ม",
@@ -3911,6 +3911,52 @@ function needsBridge(step, env, ctx) {
3911
3911
  !env.sidebarOpen &&
3912
3912
  !env.isVisible(step.target, targetIdOf(step, ctx)));
3913
3913
  }
3914
+ /**
3915
+ * The page a step happens on: its own `route`, or else where the steps before
3916
+ * it left the user (an earlier step's route trigger or route). Undefined when
3917
+ * nothing says (a mission's first steps, which work from any page).
3918
+ */
3919
+ function stepHomeRoute(steps, index) {
3920
+ for (let j = Math.min(index, steps.length - 1); j >= 0; j--) {
3921
+ const s = steps[j];
3922
+ if (j < index) {
3923
+ // What this earlier step moved the user to comes after where it happened.
3924
+ const t = triggersOf(s).find((x) => "route" in x);
3925
+ if (t)
3926
+ return t.route;
3927
+ }
3928
+ if (s.route)
3929
+ return s.route;
3930
+ }
3931
+ return undefined;
3932
+ }
3933
+ /**
3934
+ * The page a step is shown on, or the "ทำต่อ" card when the user is elsewhere:
3935
+ * its route; for a checkpoint without one, the page the steps before it lead
3936
+ * to (so going back to it from another page cannot loop).
3937
+ */
3938
+ function placeRoute(steps, index) {
3939
+ const step = steps[index];
3940
+ if (step.route)
3941
+ return step.route;
3942
+ return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
3943
+ }
3944
+ /** The user is not on the page the step happens on. */
3945
+ function offRoute(steps, index, env) {
3946
+ const home = stepHomeRoute(steps, index);
3947
+ return !!home && !matchRoute(home, env.pathname, env.search, env.code);
3948
+ }
3949
+ /** What puts the page back when a replay ends at this step (see GuideStep.abort). */
3950
+ function abortActionsFor(mission, index) {
3951
+ const step = mission.steps[index];
3952
+ const own = step?.abort ?? mission.abort;
3953
+ if (own)
3954
+ return Array.isArray(own) ? own : [own];
3955
+ // Default: Escape to the dialog the tour opened; nothing on a stable page.
3956
+ if (!step || index === 0 || step.checkpoint)
3957
+ return [];
3958
+ return [{ escape: true }];
3959
+ }
3914
3960
  function routeParamsFor(step, env) {
3915
3961
  if (!step.route)
3916
3962
  return {};
@@ -4052,7 +4098,7 @@ function announceStep(d, suite, env) {
4052
4098
  const step = mission.steps[s.index];
4053
4099
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4054
4100
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
4055
- const why = s.bridge ? COPY.bridgeWhy(suite.appName) : resolveText(step.why, ctx);
4101
+ const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
4056
4102
  d.effects.push({
4057
4103
  type: "announce",
4058
4104
  text: `ขั้นที่ ${s.index + 1} จาก ${mission.steps.length}. ${title}${why ? `. ${why}` : ""}`,
@@ -4075,7 +4121,9 @@ function enterStep(d, suite, env, mission, index, opts) {
4075
4121
  const params = routeParamsFor(step, env);
4076
4122
  const ctx = guideContext(env, d.state.progress.context, params);
4077
4123
  if (step.requires && !safe(() => env.can(step.requires), true)) {
4078
- withProgress(d, { last_error: `forbidden:${step.requires}`.slice(0, 128) });
4124
+ // A replay writes nothing, not even what went wrong.
4125
+ if (!opts.replay)
4126
+ withProgress(d, { last_error: `forbidden:${step.requires}`.slice(0, 128) });
4079
4127
  i++;
4080
4128
  continue;
4081
4129
  }
@@ -4101,7 +4149,8 @@ function enterStep(d, suite, env, mission, index, opts) {
4101
4149
  }
4102
4150
  const step = steps[i];
4103
4151
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4104
- const away = !!step.route && !matchRoute(step.route, env.pathname, env.search, env.code);
4152
+ const where = placeRoute(steps, i);
4153
+ const away = !!where && !matchRoute(where, env.pathname, env.search, env.code);
4105
4154
  const bridge = !away && needsBridge(step, env, ctx);
4106
4155
  setScreen(d, {
4107
4156
  name: "step",
@@ -4147,6 +4196,35 @@ function markReadyIfVisible(d, suite, env) {
4147
4196
  d.effects.push({ type: "announce", text: s.error.title });
4148
4197
  }
4149
4198
  }
4199
+ /** Where to send the user to resume at a step: its resumeUrl, or its page filled in. */
4200
+ function resumeHref(mission, index, d, env) {
4201
+ const step = mission.steps[index];
4202
+ const ctx = guideContext(env, d.state.progress.context);
4203
+ const where = placeRoute(mission.steps, index);
4204
+ return (safe(() => step.resumeUrl?.(ctx), undefined) ??
4205
+ (where ? fillRoute(where, { ...d.state.progress.context, code: env.code }) : null));
4206
+ }
4207
+ /**
4208
+ * The user left the page the step happens on (a link, the browser's back):
4209
+ * back to the last checkpoint. On another page that is the "ทำต่อ" card;
4210
+ * `navigate` (ลองอีกครั้ง) goes there at once.
4211
+ */
4212
+ function recoverOffRoute(d, suite, env, navigate) {
4213
+ const s = d.state.screen;
4214
+ if (s.name !== "step")
4215
+ return;
4216
+ const mission = findMission(suite, s.mission);
4217
+ if (!mission)
4218
+ return;
4219
+ const cp = checkpointAtOrBefore(mission.steps, s.index);
4220
+ enterStep(d, suite, env, mission, cp, { replay: s.replay });
4221
+ const now = d.state.screen;
4222
+ if (navigate && now.name === "step" && now.phase === "away") {
4223
+ const href = resumeHref(mission, now.index, d, env);
4224
+ if (href)
4225
+ d.effects.push({ type: "navigate", href });
4226
+ }
4227
+ }
4150
4228
  function captureInto(d, step, e, ctx) {
4151
4229
  if (!step.capture)
4152
4230
  return;
@@ -4214,6 +4292,7 @@ function startMission(d, suite, env, key, replay) {
4214
4292
  const entry = d.state.progress.missions[key];
4215
4293
  // A mission already done is only ever replayed; its done status stays.
4216
4294
  const isReplay = replay || entry?.status === "done";
4295
+ const before = d.state.progress;
4217
4296
  d.state = { ...d.state, panelOpen: false, justDone: null };
4218
4297
  if (d.state.progress.welcome === "not_seen")
4219
4298
  withProgress(d, { welcome: "started" });
@@ -4231,7 +4310,9 @@ function startMission(d, suite, env, key, replay) {
4231
4310
  index = checkpointAtOrBefore(mission.steps, at);
4232
4311
  }
4233
4312
  enterStep(d, suite, env, mission, index, { replay: isReplay });
4234
- if (isReplay)
4313
+ // A replay never writes progress; only what starting it changed (the
4314
+ // welcome, first seen through a replay) is saved.
4315
+ if (isReplay && d.state.progress !== before)
4235
4316
  save(d);
4236
4317
  }
4237
4318
  function exitMission(d) {
@@ -4281,6 +4362,17 @@ function apiFailureFor(step, sig) {
4281
4362
  (!t.api.method || t.api.method.toUpperCase() === sig.method.toUpperCase()) &&
4282
4363
  matchApiPath(t.api.path, sig.path));
4283
4364
  }
4365
+ /** A replay ends early: what puts the page back (nothing on another page). */
4366
+ function pushAbort(d, suite, s) {
4367
+ if (s.phase === "away")
4368
+ return;
4369
+ const mission = findMission(suite, s.mission);
4370
+ if (!mission)
4371
+ return;
4372
+ const actions = abortActionsFor(mission, s.index);
4373
+ if (actions.length)
4374
+ d.effects.push({ type: "abort", actions });
4375
+ }
4284
4376
  function reduceGuide(state, action, suite, env) {
4285
4377
  const d = { state, effects: [] };
4286
4378
  const s = state.screen;
@@ -4361,7 +4453,8 @@ function reduceGuide(state, action, suite, env) {
4361
4453
  const step = mission.steps[s.index];
4362
4454
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4363
4455
  if (s.phase === "away") {
4364
- if (!step.route || matchRoute(step.route, env.pathname, env.search, env.code)) {
4456
+ const where = placeRoute(mission.steps, s.index);
4457
+ if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
4365
4458
  // Arrived: run the step again from here (it may already be passed).
4366
4459
  enterStep(d, suite, env, mission, s.index, { replay: s.replay, error: s.error });
4367
4460
  }
@@ -4378,6 +4471,11 @@ function reduceGuide(state, action, suite, env) {
4378
4471
  break;
4379
4472
  }
4380
4473
  }
4474
+ if (!s.bridge && !stepTargetVisible(step, env, ctx, false) && offRoute(mission.steps, s.index, env)) {
4475
+ // The target is missing because the page is another one.
4476
+ recoverOffRoute(d, suite, env, false);
4477
+ break;
4478
+ }
4381
4479
  if (armed !== s.armed)
4382
4480
  setScreen(d, { ...s, armed });
4383
4481
  const cur = d.state.screen;
@@ -4432,7 +4530,8 @@ function reduceGuide(state, action, suite, env) {
4432
4530
  : { title: COPY.saveFailed, detail: sig.message };
4433
4531
  setScreen(d, { ...s, error });
4434
4532
  d.effects.push({ type: "announce", text: [error.title, error.detail].filter(Boolean).join(". ") });
4435
- withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
4533
+ if (!s.replay)
4534
+ withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
4436
4535
  }
4437
4536
  break;
4438
4537
  }
@@ -4477,6 +4576,10 @@ function reduceGuide(state, action, suite, env) {
4477
4576
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4478
4577
  if (stepTargetVisible(step, env, ctx, s.bridge))
4479
4578
  break;
4579
+ if (!s.bridge && offRoute(mission.steps, s.index, env)) {
4580
+ recoverOffRoute(d, suite, env, false);
4581
+ break;
4582
+ }
4480
4583
  const cp = checkpointAtOrBefore(mission.steps, s.index);
4481
4584
  if (cp < s.index && !s.bridge) {
4482
4585
  // A dialog or a menu closed under the step: back to what opens it.
@@ -4494,9 +4597,16 @@ function reduceGuide(state, action, suite, env) {
4494
4597
  if (!mission)
4495
4598
  break;
4496
4599
  const step = mission.steps[s.index];
4600
+ if (!s.bridge && offRoute(mission.steps, s.index, env)) {
4601
+ recoverOffRoute(d, suite, env, false);
4602
+ break;
4603
+ }
4497
4604
  const anchor = s.bridge ? BRIDGE_TARGET : (step.target ?? step.key);
4498
- withProgress(d, { last_error: `target_missing:${anchor}`.slice(0, 128) });
4499
- save(d);
4605
+ // Diagnostic only: kept with the progress, sent with its next save (a
4606
+ // replay keeps nothing). The same error again changes nothing, so it
4607
+ // never sends a PUT on its own.
4608
+ if (!s.replay)
4609
+ withProgress(d, { last_error: `target_missing:${anchor}`.slice(0, 128) });
4500
4610
  const misses = s.misses + 1;
4501
4611
  const policy = step.onMissing ?? "retry";
4502
4612
  const cp = checkpointAtOrBefore(mission.steps, s.index);
@@ -4523,12 +4633,19 @@ function reduceGuide(state, action, suite, env) {
4523
4633
  }
4524
4634
  break;
4525
4635
  }
4526
- case "retry":
4527
- if (s.name === "step" && s.phase === "missing") {
4528
- setScreen(d, { ...s, phase: "waiting" });
4529
- markReadyIfVisible(d, suite, env);
4636
+ case "retry": {
4637
+ if (s.name !== "step" || s.phase !== "missing")
4638
+ break;
4639
+ const mission = findMission(suite, s.mission);
4640
+ if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
4641
+ // Polling again cannot help on another page: go back to the step's.
4642
+ recoverOffRoute(d, suite, env, true);
4643
+ break;
4530
4644
  }
4645
+ setScreen(d, { ...s, phase: "waiting" });
4646
+ markReadyIfVisible(d, suite, env);
4531
4647
  break;
4648
+ }
4532
4649
  case "blocked":
4533
4650
  if (s.name === "step")
4534
4651
  d.state = { ...d.state, nudge: d.state.nudge + 1 };
@@ -4537,6 +4654,7 @@ function reduceGuide(state, action, suite, env) {
4537
4654
  if (s.name !== "step")
4538
4655
  break;
4539
4656
  if (s.replay) {
4657
+ pushAbort(d, suite, s);
4540
4658
  setScreen(d, { name: "idle" });
4541
4659
  d.effects.push({ type: "toast", text: COPY.replayEnded });
4542
4660
  }
@@ -4557,8 +4675,9 @@ function reduceGuide(state, action, suite, env) {
4557
4675
  case "endWithoutSave":
4558
4676
  if (s.name !== "step" || !s.replay)
4559
4677
  break;
4678
+ pushAbort(d, suite, s);
4560
4679
  setScreen(d, { name: "idle" });
4561
- d.effects.push({ type: "closeAppDialog" }, { type: "toast", text: COPY.replayEnded });
4680
+ d.effects.push({ type: "toast", text: COPY.replayEnded });
4562
4681
  break;
4563
4682
  case "goResume": {
4564
4683
  if (s.name !== "step" || s.phase !== "away")
@@ -4566,10 +4685,7 @@ function reduceGuide(state, action, suite, env) {
4566
4685
  const mission = findMission(suite, s.mission);
4567
4686
  if (!mission)
4568
4687
  break;
4569
- const step = mission.steps[s.index];
4570
- const ctx = guideContext(env, d.state.progress.context);
4571
- const href = safe(() => step.resumeUrl?.(ctx), undefined) ??
4572
- (step.route ? fillRoute(step.route, { ...d.state.progress.context, code: env.code }) : null);
4688
+ const href = resumeHref(mission, s.index, d, env);
4573
4689
  if (href)
4574
4690
  d.effects.push({ type: "navigate", href });
4575
4691
  break;
@@ -4805,6 +4921,27 @@ function putBody(p, app) {
4805
4921
  TERMINAL.includes(p.server_status),
4806
4922
  };
4807
4923
  }
4924
+ function stable(v) {
4925
+ if (Array.isArray(v))
4926
+ return `[${v.map(stable).join(",")}]`;
4927
+ if (isRecord(v)) {
4928
+ return `{${Object.keys(v)
4929
+ .sort()
4930
+ .map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
4931
+ .join(",")}}`;
4932
+ }
4933
+ return JSON.stringify(v) ?? "null";
4934
+ }
4935
+ /**
4936
+ * What a PUT would store, without the rev it is checked against: two equal
4937
+ * keys mean the save would change nothing, and it is not sent (a replay
4938
+ * starting, a step entered again, never bump the row's rev). Key order does
4939
+ * not matter.
4940
+ */
4941
+ function payloadKey(p, app) {
4942
+ const { rev: _rev, restart: _restart, ...rest } = putBody(p, app);
4943
+ return stable(rest);
4944
+ }
4808
4945
  const MISSION_RANK = { todo: 0, skipped: 1, active: 1, done: 3 };
4809
4946
  const WELCOME_RANK = { not_seen: 0, skipped: 1, started: 1 };
4810
4947
  /**
@@ -4939,8 +5076,9 @@ function isPresent(el) {
4939
5076
  if (r.width <= 0 || r.height <= 0)
4940
5077
  return false;
4941
5078
  // 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)
5079
+ // scrolled away, and the tour scrolls it back. So is a tab scrolled out of
5080
+ // a row of tabs that scrolls sideways, as long as the row is on screen.
5081
+ if ((r.right <= 0 || r.left >= window.innerWidth) && !inSidewaysScroller(el))
4944
5082
  return false;
4945
5083
  if (el.getAttribute("data-state") === "closed")
4946
5084
  return false;
@@ -5020,6 +5158,26 @@ function sameRect(a, b) {
5020
5158
  const POPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
5021
5159
  /** Toasts stay usable above the tour. */
5022
5160
  const TOASTER_SELECTOR = "[data-sonner-toaster]";
5161
+ /** Open dialogs (Radix dialog, alert dialog, sheet, popover content). */
5162
+ const OPEN_DIALOG_SELECTOR = '[role="dialog"][data-state="open"],[role="alertdialog"][data-state="open"]';
5163
+ /**
5164
+ * Whether an open dialog of the app's covers the target: the target is
5165
+ * neither in it nor around it (a confirm opened by the step's own button).
5166
+ * The ring and the hand would be drawn over the dialog, pointing at a button
5167
+ * behind it.
5168
+ */
5169
+ function coveredByDialog(el) {
5170
+ if (!hasDocument() || !el)
5171
+ return false;
5172
+ for (const d of Array.from(document.querySelectorAll(OPEN_DIALOG_SELECTOR))) {
5173
+ if (inGuideLayer(d) || !isPresent(d))
5174
+ continue;
5175
+ if (d.contains(el) || el.contains(d))
5176
+ continue;
5177
+ return true;
5178
+ }
5179
+ return false;
5180
+ }
5023
5181
  function openPoppers() {
5024
5182
  if (!hasDocument())
5025
5183
  return [];
@@ -5037,18 +5195,224 @@ function prefersReducedMotion() {
5037
5195
  function isPhoneWidth() {
5038
5196
  return typeof window !== "undefined" && window.innerWidth < 768;
5039
5197
  }
5040
- /** Scrolls the target to the middle when it is out of view (instant under reduced motion). */
5198
+ /* ---------------- what is really on screen ---------------- */
5199
+ const INFINITE = 1e7;
5200
+ /** The overlap of two rectangles, or null when they do not meet. */
5201
+ function intersectRect(a, b) {
5202
+ const x = Math.max(a.x, b.x);
5203
+ const y = Math.max(a.y, b.y);
5204
+ const right = Math.min(a.x + a.width, b.x + b.width);
5205
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
5206
+ if (right - x <= 0 || bottom - y <= 0)
5207
+ return null;
5208
+ return { x, y, width: right - x, height: bottom - y };
5209
+ }
5210
+ /** The part of `rect` inside every clip (scroll boxes, the viewport); null when none is. */
5211
+ function visibleRect(rect, clips) {
5212
+ let r = rect;
5213
+ for (const c of clips) {
5214
+ if (!r)
5215
+ return null;
5216
+ r = intersectRect(r, c);
5217
+ }
5218
+ return r;
5219
+ }
5220
+ const clipsX = (v) => v !== "visible";
5221
+ const scrollsX = (v) => v === "auto" || v === "scroll";
5222
+ /**
5223
+ * The ancestors that cut the element off: anything with overflow other than
5224
+ * visible on an axis. The body and the root scroll the page and are covered by
5225
+ * the viewport. Read once per target; their rectangles are read every frame.
5226
+ */
5227
+ function clipAncestors(el) {
5228
+ const out = [];
5229
+ if (typeof getComputedStyle !== "function")
5230
+ return out;
5231
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5232
+ if (n.hasAttribute(GUIDE_LAYER_ATTRIBUTE))
5233
+ break;
5234
+ const s = getComputedStyle(n);
5235
+ const x = clipsX(s.overflowX);
5236
+ const y = clipsX(s.overflowY);
5237
+ if (x || y)
5238
+ out.push({ el: n, x, y });
5239
+ if (s.position === "fixed")
5240
+ break;
5241
+ }
5242
+ return out;
5243
+ }
5244
+ /** An ancestor's clip as a rectangle: unbounded on an axis it does not clip. */
5245
+ function clipRectOf(c) {
5246
+ const r = c.el.getBoundingClientRect();
5247
+ return {
5248
+ x: c.x ? r.left : -INFINITE,
5249
+ y: c.y ? r.top : -INFINITE,
5250
+ width: c.x ? r.width : INFINITE * 2,
5251
+ height: c.y ? r.height : INFINITE * 2,
5252
+ };
5253
+ }
5254
+ function viewportRect() {
5255
+ const vv = typeof window !== "undefined" ? window.visualViewport : undefined;
5256
+ return {
5257
+ x: 0,
5258
+ y: vv ? vv.offsetTop : 0,
5259
+ width: window.innerWidth,
5260
+ height: vv ? vv.height : window.innerHeight,
5261
+ };
5262
+ }
5263
+ /**
5264
+ * The part of the element a person can see: its box cut by every scroll box
5265
+ * around it and by the viewport. The spotlight, its hole, the ring and the
5266
+ * hand use this, so a tab half out of a sideways-scrolling row of tabs is not
5267
+ * spotlit where it cannot be pressed. Null when none of it is on screen.
5268
+ */
5269
+ function visibleRectOf(el, ancestors = clipAncestors(el)) {
5270
+ return visibleRect(rectOf(el), [...ancestors.map(clipRectOf), viewportRect()]);
5271
+ }
5272
+ /** Inside a box that scrolls sideways (with somewhere to scroll) that is itself on screen. */
5273
+ function inSidewaysScroller(el) {
5274
+ if (typeof getComputedStyle !== "function")
5275
+ return false;
5276
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5277
+ const s = getComputedStyle(n);
5278
+ if (scrollsX(s.overflowX) && n.scrollWidth > n.clientWidth + 1) {
5279
+ const r = n.getBoundingClientRect();
5280
+ return r.width > 0 && r.right > 0 && r.left < window.innerWidth;
5281
+ }
5282
+ }
5283
+ return false;
5284
+ }
5285
+ /**
5286
+ * How to bring a target into view, or null when it already is: to the middle
5287
+ * when it is above or below the comfortable band (under the header, at the
5288
+ * bottom edge), the nearest edge when only a side is cut off (a tab out of a
5289
+ * sideways-scrolling row, on a phone).
5290
+ */
5291
+ function revealPlan(rect, visible, viewportHeight) {
5292
+ const offVertically = rect.y < 70 || rect.y + rect.height > viewportHeight - 20;
5293
+ const cutSideways = !visible || visible.width < rect.width - 1;
5294
+ if (!offVertically && !cutSideways)
5295
+ return null;
5296
+ return { block: offVertically ? "center" : "nearest", inline: "nearest" };
5297
+ }
5298
+ /** Room left beside a target scrolled into a sideways scroll box. */
5299
+ const SIDEWAYS_MARGIN = 12;
5300
+ /**
5301
+ * How far to scroll a sideways scroll box so the target sits inside its
5302
+ * visible part (`box`: the box cut by the viewport) with `margin` to spare on
5303
+ * both sides; 0 when it already does. Clamped to what the box can scroll.
5304
+ * A target wider than the room is aligned to its start.
5305
+ */
5306
+ function sidewaysDelta(target, box, scrollLeft, maxScrollLeft, margin = SIDEWAYS_MARGIN) {
5307
+ const left = box.x + margin;
5308
+ const right = box.x + box.width - margin;
5309
+ let delta = 0;
5310
+ if (target.x < left || target.width > right - left)
5311
+ delta = target.x - left;
5312
+ else if (target.x + target.width > right)
5313
+ delta = target.x + target.width - right;
5314
+ const next = Math.min(Math.max(scrollLeft + delta, 0), Math.max(maxScrollLeft, 0));
5315
+ return next - scrollLeft;
5316
+ }
5317
+ /** The nearest ancestor that scrolls sideways and has somewhere to scroll. */
5318
+ function sidewaysScroller(el) {
5319
+ if (typeof getComputedStyle !== "function")
5320
+ return null;
5321
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5322
+ if (scrollsX(getComputedStyle(n).overflowX) && n.scrollWidth > n.clientWidth + 1)
5323
+ return n;
5324
+ }
5325
+ return null;
5326
+ }
5327
+ /**
5328
+ * Scrolls the target into view (instant under reduced motion). In a box that
5329
+ * scrolls sideways (a row of tabs on a phone) the box is scrolled first,
5330
+ * leaving SIDEWAYS_MARGIN beside the target so its whole label shows; then
5331
+ * the page, up or down only.
5332
+ */
5041
5333
  function revealTarget(el) {
5042
- const r = el.getBoundingClientRect();
5334
+ const behavior = prefersReducedMotion() ? "auto" : "smooth";
5335
+ const scroller = sidewaysScroller(el);
5336
+ if (scroller) {
5337
+ const box = intersectRect(rectOf(scroller), viewportRect()) ?? rectOf(scroller);
5338
+ const dx = sidewaysDelta(rectOf(el), box, scroller.scrollLeft, scroller.scrollWidth - scroller.clientWidth);
5339
+ // Instant: the page's own scroll below must not cancel it.
5340
+ if (Math.abs(dx) >= 1)
5341
+ scroller.scrollLeft += dx;
5342
+ }
5043
5343
  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" });
5344
+ const plan = revealPlan(rectOf(el), visibleRectOf(el), vh);
5345
+ if (!plan)
5346
+ return;
5347
+ try {
5348
+ el.scrollIntoView({ ...plan, behavior });
5349
+ }
5350
+ catch {
5351
+ el.scrollIntoView();
5352
+ }
5353
+ }
5354
+ /* ---------------- the scrim's holes, as one union ---------------- */
5355
+ /**
5356
+ * The scrim's blocking area: the screen (with a 10 px margin) minus the union
5357
+ * of the holes, as rectangles that do not overlap. Cutting each hole out with
5358
+ * an even-odd fill made the place where two holes overlap (a field and the
5359
+ * select list open over it) solid again, and the option there could not be
5360
+ * pressed.
5361
+ *
5362
+ * Every hole edge splits the screen into a grid; a cell is blocked when no
5363
+ * hole covers it. Cells in a row are joined, and identical rows stacked.
5364
+ */
5365
+ function blockedRects(holes, width, height) {
5366
+ const x0 = -10;
5367
+ const y0 = -10;
5368
+ const x1 = width + 10;
5369
+ const y1 = height + 10;
5370
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
5371
+ const hs = holes
5372
+ .filter(h => h.width > 0 && h.height > 0)
5373
+ .map(h => ({
5374
+ l: clamp(h.x, x0, x1),
5375
+ t: clamp(h.y, y0, y1),
5376
+ r: clamp(h.x + h.width, x0, x1),
5377
+ b: clamp(h.y + h.height, y0, y1),
5378
+ }))
5379
+ .filter(h => h.r > h.l && h.b > h.t);
5380
+ if (!hs.length)
5381
+ return [{ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }];
5382
+ const uniq = (v) => Array.from(new Set(v)).sort((a, b) => a - b);
5383
+ const xs = uniq([x0, x1, ...hs.flatMap(h => [h.l, h.r])]);
5384
+ const ys = uniq([y0, y1, ...hs.flatMap(h => [h.t, h.b])]);
5385
+ const out = [];
5386
+ let prev = null;
5387
+ for (let j = 0; j < ys.length - 1; j++) {
5388
+ const top = ys[j];
5389
+ const bottom = ys[j + 1];
5390
+ const cy = (top + bottom) / 2;
5391
+ const row = [];
5392
+ for (let i = 0; i < xs.length - 1; i++) {
5393
+ const left = xs[i];
5394
+ const right = xs[i + 1];
5395
+ const cx = (left + right) / 2;
5396
+ const covered = hs.some(h => cx > h.l && cx < h.r && cy > h.t && cy < h.b);
5397
+ if (covered)
5398
+ continue;
5399
+ const last = row[row.length - 1];
5400
+ if (last && last.x + last.width === left)
5401
+ last.width = right - last.x;
5402
+ else
5403
+ row.push({ x: left, y: top, width: right - left, height: bottom - top });
5047
5404
  }
5048
- catch {
5049
- el.scrollIntoView();
5405
+ const key = row.map(r => `${r.x},${r.width}`).join("|");
5406
+ if (prev && prev.key === key) {
5407
+ for (const r of prev.rects)
5408
+ r.height += bottom - top;
5409
+ }
5410
+ else {
5411
+ out.push(...row);
5412
+ prev = { key, rects: row };
5050
5413
  }
5051
5414
  }
5415
+ return out;
5052
5416
  }
5053
5417
 
5054
5418
  /**
@@ -5070,7 +5434,8 @@ const GUIDE_CSS = `
5070
5434
  .eg-i{pointer-events:auto}
5071
5435
  .eg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
5072
5436
  .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}
5437
+ .eg-scrim-svg .eg-scrim-fill{fill:var(--eg-scrim);pointer-events:none}
5438
+ .eg-scrim-svg .eg-scrim-hit{fill:transparent;pointer-events:all}
5074
5439
  .eg-scrim{position:fixed;inset:0;background:var(--eg-scrim);pointer-events:auto}
5075
5440
  .eg-scrim.eg-light{background:rgba(12,20,34,.45)}
5076
5441
  .eg-scrim.eg-faint{background:rgba(12,20,34,.35)}
@@ -5179,6 +5544,7 @@ const GUIDE_CSS = `
5179
5544
  .eg-q-item.eg-done .eg-t b{color:var(--eg-muted)}
5180
5545
  .eg-q-item .eg-t small{display:block;font-size:12px;color:var(--eg-muted)}
5181
5546
  .eg-q-item .eg-t small.eg-sk{color:#b06a00}
5547
+ .eg-q-item .eg-t small.eg-lock{color:var(--eg-fg);font-weight:500}
5182
5548
  .eg-q-item .eg-btn{height:32px;padding:0 12px;font-size:13px}
5183
5549
  .eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
5184
5550
  .eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
@@ -5243,15 +5609,53 @@ function roundRect(r, radius) {
5243
5609
  `H${x + rad}A${rad} ${rad} 0 0 1 ${x} ${y + h - rad}` +
5244
5610
  `V${y + rad}A${rad} ${rad} 0 0 1 ${x + rad} ${y}Z`);
5245
5611
  }
5612
+ const rectPath = (r) => `M${r.x} ${r.y}H${r.x + r.width}V${r.y + r.height}H${r.x}Z`;
5613
+ /**
5614
+ * The dim layer that eats every press outside the holes. The holes are a
5615
+ * union: where two overlap (a field and the select list open over it) the
5616
+ * page stays open.
5617
+ *
5618
+ * - The look: one rectangle masked by the rounded holes (a mask adds holes
5619
+ * up, so an overlap is not filled back in).
5620
+ * - The presses: a transparent path made of the screen minus the holes, as
5621
+ * rectangles that do not overlap (blockedRects). A press inside a hole
5622
+ * reaches the page underneath; any other lands on the path (marked
5623
+ * data-eg-block) and is cancelled.
5624
+ * - `open`: the holes a press may go through, when not all of them (a "view"
5625
+ * step's target is shown but not pressable). Default: every hole.
5626
+ */
5627
+ function Scrim({ holes, open, width, height }) {
5628
+ const maskId = "eg-scrim-mask-" + React__namespace.useId().replace(/[^a-zA-Z0-9_-]/g, "");
5629
+ const block = blockedRects(open ?? holes, width, height).map(rectPath).join("");
5630
+ 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": "" })] }));
5631
+ }
5632
+ /** Whether two rectangles overlap. */
5633
+ const meets = (a, b) => !!intersectRect(a, b);
5634
+ /** The smallest rectangle around all of them. */
5635
+ function boundingRect(rs) {
5636
+ const x = Math.min(...rs.map(r => r.x));
5637
+ const y = Math.min(...rs.map(r => r.y));
5638
+ const right = Math.max(...rs.map(r => r.x + r.width));
5639
+ const bottom = Math.max(...rs.map(r => r.y + r.height));
5640
+ return { x, y, width: right - x, height: bottom - y };
5641
+ }
5642
+ /** Where the bubble may go, in order of preference. */
5643
+ const BUBBLE_PLACEMENTS = ["right", "bottom", "top", "left"];
5246
5644
  /**
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.
5645
+ * The first placement (in BUBBLE_PLACEMENTS order) whose bubble is on screen,
5646
+ * off the target (with its open list), and off everything to keep clear;
5647
+ * failing that, the first that is only on screen and off the target; -1 when
5648
+ * none is.
5250
5649
  */
5251
- 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": "" }) }));
5650
+ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
5651
+ const box = (r) => ({ x: r.x, y: r.y, width: bw, height: bh });
5652
+ 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;
5653
+ const ok = results.map(r => {
5654
+ const b = box(r);
5655
+ return fits(b) && !meets(b, anchor);
5656
+ });
5657
+ const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
5658
+ return best >= 0 ? best : ok.indexOf(true);
5255
5659
  }
5256
5660
  const BUBBLE_TITLE_ID = "eg-bubble-title";
5257
5661
  const BUBBLE_WHY_ID = "eg-bubble-why";
@@ -5291,12 +5695,21 @@ function StepLayer(p) {
5291
5695
  const gap = p.showHand ? 34 : 14;
5292
5696
  const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
5293
5697
  const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
5698
+ // What the bubble sits beside: the target, with an open list over it (the
5699
+ // list is what the person is choosing from now). What it must not cover:
5700
+ // open lists, and the other anchors the step allows (a map step's
5701
+ // coordinate fields).
5702
+ const popoverList = p.popovers ?? [];
5703
+ const anchor = spot ? boundingRect([spot, ...popoverList.filter(o => meets(o, spot))]) : null;
5704
+ const clear = [...popoverList, ...p.holes];
5705
+ const anchorKey = anchor ? `${anchor.x}|${anchor.y}|${anchor.width}|${anchor.height}` : "";
5706
+ const clearKey = clear.map(r => `${r.x},${r.y},${r.width},${r.height}`).join(";");
5294
5707
  React.useLayoutEffect(() => {
5295
5708
  const el = bubbleRef.current;
5296
5709
  if (!el || !showBubble)
5297
5710
  return;
5298
5711
  const m = 16;
5299
- if (!spot) {
5712
+ if (!spot || !anchor) {
5300
5713
  // Nothing to point at: the middle of the screen (the bottom on a phone).
5301
5714
  const bw = el.offsetWidth;
5302
5715
  const bh = el.offsetHeight;
@@ -5307,13 +5720,13 @@ function StepLayer(p) {
5307
5720
  }
5308
5721
  if (phone) {
5309
5722
  // Docked to the top or the bottom of the screen, whichever covers less
5310
- // of the target (target in the lower half: top).
5723
+ // of the target and its open list (target in the lower half: top).
5311
5724
  const vh = viewport.height;
5312
5725
  const oy = viewport.offsetTop;
5313
5726
  const bh = el.offsetHeight;
5314
5727
  const topT = oy + m + 4;
5315
5728
  const botT = oy + vh - bh - m;
5316
- const { y, height: h } = spot;
5729
+ const { y, height: h } = anchor;
5317
5730
  const overlapTop = Math.max(0, topT + bh + 8 - y);
5318
5731
  const overlapBot = Math.max(0, y + h + gap - botT);
5319
5732
  const top = y + h / 2 > vh / 2
@@ -5327,40 +5740,42 @@ function StepLayer(p) {
5327
5740
  return;
5328
5741
  }
5329
5742
  let cancelled = false;
5743
+ const a = anchor;
5330
5744
  const ref = {
5331
5745
  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,
5746
+ x: a.x,
5747
+ y: a.y,
5748
+ left: a.x,
5749
+ top: a.y,
5750
+ width: a.width,
5751
+ height: a.height,
5752
+ right: a.x + a.width,
5753
+ bottom: a.y + a.height,
5340
5754
  }),
5341
5755
  };
5342
- dom.computePosition(ref, el, {
5756
+ const at = (placement) => dom.computePosition(ref, el, {
5343
5757
  strategy: "fixed",
5344
- placement: "right",
5758
+ placement,
5345
5759
  middleware: [
5346
- dom.offset(({ placement }) => (placement.startsWith("bottom") ? gap : 14)),
5347
- dom.flip({ fallbackPlacements: ["bottom", "top", "left"], padding: m }),
5760
+ dom.offset(placement.startsWith("bottom") ? gap : 14),
5348
5761
  dom.shift({ padding: m }),
5349
5762
  ...(arrowRef.current ? [dom.arrow({ element: arrowRef.current, padding: 14 })] : []),
5350
5763
  ],
5351
- })
5352
- .then(({ x, y, placement, middlewareData }) => {
5764
+ });
5765
+ Promise.all(BUBBLE_PLACEMENTS.map(at))
5766
+ .then(results => {
5353
5767
  if (cancelled)
5354
5768
  return;
5355
5769
  const bw = el.offsetWidth;
5356
5770
  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) {
5771
+ const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
5772
+ const i = pickPlacement(results, bw, bh, a, clear, bounds);
5773
+ if (i < 0) {
5360
5774
  // Nowhere beside it: the bottom-right corner.
5361
5775
  setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
5362
5776
  return;
5363
5777
  }
5778
+ const { x, y, placement, middlewareData } = results[i];
5364
5779
  const side = placement.split("-")[0];
5365
5780
  setPos({ left: x, top: y, side, ax: middlewareData.arrow?.x, ay: middlewareData.arrow?.y });
5366
5781
  })
@@ -5371,11 +5786,15 @@ function StepLayer(p) {
5371
5786
  return () => {
5372
5787
  cancelled = true;
5373
5788
  };
5374
- // spotKey/contentKey stand for spot and the bubble's size.
5789
+ // The keys stand for spot, the lists, the holes and the bubble's size.
5375
5790
  // 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;
5791
+ }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
5792
+ const popovers = p.popovers ?? [];
5793
+ const covered = !!p.covered;
5794
+ const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
5795
+ const openHoles = p.targetPressable === false ? [...popovers, ...p.holes] : holes;
5796
+ const ringShown = !!spot && p.phase === "ready" && !covered && !popovers.some(o => meets(o, spot));
5797
+ const hand = spot && p.showHand && p.phase === "ready" && !covered && !popovers.length ? handPosition(spot, viewport.height) : null;
5379
5798
  let arrowStyle;
5380
5799
  if (pos && pos.side !== "dock") {
5381
5800
  if (pos.side === "right")
@@ -5387,7 +5806,7 @@ function StepLayer(p) {
5387
5806
  else
5388
5807
  arrowStyle = { bottom: -7, left: pos.ax };
5389
5808
  }
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: {
5809
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Scrim, { holes: p.phase === "ready" ? holes : [], open: p.phase === "ready" ? openHoles : [], 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
5810
  left: pos?.left ?? 0,
5392
5811
  top: pos?.top ?? 0,
5393
5812
  width: phone ? viewport.width - 32 : undefined,
@@ -5398,7 +5817,7 @@ function BubbleBody(p) {
5398
5817
  const b = p.bubble;
5399
5818
  const title = b.error ? b.error.title : b.title;
5400
5819
  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] })] })] }));
5820
+ 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
5821
  }
5403
5822
  /** The fingertip at ≈62% across and down the target; upside down near the bottom. */
5404
5823
  function handPosition(spot, viewportHeight) {
@@ -5511,6 +5930,9 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
5511
5930
  meta = jsxRuntime.jsx("span", { children: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E27\u0E49" });
5512
5931
  else if (v === "waiting" && gate)
5513
5932
  meta = gate.pending;
5933
+ // Locked: what to do first gets its own line under the title, so the title
5934
+ // keeps the row's width and the button stays short.
5935
+ const lockHint = v === "locked" && item.lockedBy ? `ทำ ${item.lockedBy} ก่อน` : null;
5514
5936
  let button;
5515
5937
  if (v === "done")
5516
5938
  button = jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onStart(item.key, true), children: "\u0E17\u0E33\u0E0B\u0E49\u0E33" });
@@ -5519,11 +5941,11 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
5519
5941
  else if (v === "waiting" && gate)
5520
5942
  button = (jsxRuntime.jsx("a", { className: "eg-btn eg-ghost", href: gate.href, style: { textDecoration: "none" }, children: gate.label }));
5521
5943
  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"] }));
5944
+ 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
5945
  else
5524
5946
  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
5947
  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)] }));
5948
+ 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
5949
  }
5528
5950
  function GuideToast({ text }) {
5529
5951
  return (jsxRuntime.jsx("div", { className: "eg-toast", role: "status", children: text }));
@@ -5666,6 +6088,45 @@ function usePrimaryTab(name) {
5666
6088
  return primary;
5667
6089
  }
5668
6090
  /* ------------------------------------------------------------------ */
6091
+ /* Putting the page back after a replay */
6092
+ /* ------------------------------------------------------------------ */
6093
+ const ABORT_DELAY_MS = 60;
6094
+ const ABORT_GAP_MS = 150;
6095
+ /**
6096
+ * Runs a replay's abort actions in order, once the tour has let go of the
6097
+ * page: press an anchor (passed over when it is not there), emit an app
6098
+ * event, or Escape to the open dialog (Radix listens on the document).
6099
+ */
6100
+ function runAbort(actions) {
6101
+ actions.forEach((a, i) => {
6102
+ setTimeout(() => {
6103
+ try {
6104
+ if ("click" in a) {
6105
+ findAnchor(a.click)?.click();
6106
+ }
6107
+ else if ("event" in a) {
6108
+ emitGuideEvent(a.event);
6109
+ }
6110
+ else if ("escape" in a) {
6111
+ const t = document.activeElement ?? document.body;
6112
+ t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
6113
+ }
6114
+ }
6115
+ catch {
6116
+ /* never breaks the app */
6117
+ }
6118
+ }, ABORT_DELAY_MS + i * ABORT_GAP_MS);
6119
+ });
6120
+ }
6121
+ /** A step's own target takes presses: not on a "view" step, unless the step allows it. */
6122
+ function targetPressable(step, bridge) {
6123
+ if (!step || bridge)
6124
+ return true;
6125
+ if (stepKind(step) !== "view")
6126
+ return true;
6127
+ return !!step.target && (step.allow ?? []).includes(step.target);
6128
+ }
6129
+ /* ------------------------------------------------------------------ */
5669
6130
  /* Host */
5670
6131
  /* ------------------------------------------------------------------ */
5671
6132
  const TARGET_PAD = 6;
@@ -5711,6 +6172,9 @@ function GuideHostInner({ config, organizationId, code }) {
5711
6172
  /* ---------------- machine ---------------- */
5712
6173
  const [state, setState] = React.useState(null);
5713
6174
  const stateRef = React.useRef(null);
6175
+ // What the server holds (payloadKey), so a save that would change nothing
6176
+ // is not sent and does not bump its rev.
6177
+ const savedKey = React.useRef(null);
5714
6178
  React.useEffect(() => {
5715
6179
  if (!answer || answer.audience === "none" || answer.newerOnServer) {
5716
6180
  stateRef.current = null;
@@ -5719,6 +6183,7 @@ function GuideHostInner({ config, organizationId, code }) {
5719
6183
  }
5720
6184
  if (stateRef.current)
5721
6185
  return;
6186
+ savedKey.current = answer.progress && answer.progress.rev > 0 ? payloadKey(answer.progress, suite.app) : null;
5722
6187
  // Resume from whichever copy is further along: the server's, or this
5723
6188
  // tab's session copy from before a refresh (its rev is the server's).
5724
6189
  const mirror = readMirror(organizationId, suite);
@@ -5775,7 +6240,11 @@ function GuideHostInner({ config, organizationId, code }) {
5775
6240
  const cur = stateRef.current;
5776
6241
  if (!cur)
5777
6242
  return;
6243
+ if (payloadKey(cur.progress, suite.app) === savedKey.current)
6244
+ continue;
5778
6245
  const row = await saveGuides(organizationId, suite, cur.progress);
6246
+ if (row)
6247
+ savedKey.current = payloadKey(row, suite.app);
5779
6248
  if (row && stateRef.current) {
5780
6249
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
5781
6250
  dispatchRef.current({ type: "serverRow", progress: row });
@@ -5816,13 +6285,9 @@ function GuideHostInner({ config, organizationId, code }) {
5816
6285
  else
5817
6286
  router.push(e.href);
5818
6287
  }
5819
- else if (e.type === "closeAppDialog") {
5820
- // After this render takes the guard away, Escape to the app's
5821
- // dialog (Radix listens on the document).
5822
- setTimeout(() => {
5823
- const t = document.activeElement ?? document.body;
5824
- t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
5825
- }, 60);
6288
+ else if (e.type === "abort") {
6289
+ // After this render takes the guard away, put the page back.
6290
+ runAbort(e.actions);
5826
6291
  }
5827
6292
  }
5828
6293
  catch {
@@ -5952,7 +6417,9 @@ function GuideHostInner({ config, organizationId, code }) {
5952
6417
  scrolling, dialogs animating in, the drawer sliding, the keyboard. */
5953
6418
  const [layout, setLayout] = React.useState({
5954
6419
  spot: null,
6420
+ covered: false,
5955
6421
  holes: [],
6422
+ popovers: [],
5956
6423
  vw: 0,
5957
6424
  vh: 0,
5958
6425
  oy: 0,
@@ -5964,9 +6431,22 @@ function GuideHostInner({ config, organizationId, code }) {
5964
6431
  }
5965
6432
  let raf = 0;
5966
6433
  let lostSince = null;
5967
- let last = { spot: null, holes: [], vw: 0, vh: 0, oy: 0 };
6434
+ let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
5968
6435
  targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
5969
6436
  const allow = stepInfo?.step.allow ?? [];
6437
+ // The spotlight is the part of the target a person can see: cut by the
6438
+ // scroll boxes around it (a row of tabs that scrolls sideways) and the
6439
+ // viewport. Wholly out of view (scrolled away up or down), its own box,
6440
+ // and the tour scrolls it back.
6441
+ let clipFor = null;
6442
+ let clips = [];
6443
+ const spotOf = (el) => {
6444
+ if (el !== clipFor) {
6445
+ clipFor = el;
6446
+ clips = clipAncestors(el);
6447
+ }
6448
+ return padRect(visibleRectOf(el, clips) ?? rectOf(el), TARGET_PAD);
6449
+ };
5970
6450
  const tick = () => {
5971
6451
  raf = requestAnimationFrame(tick);
5972
6452
  const vv = window.visualViewport;
@@ -5981,14 +6461,14 @@ function GuideHostInner({ config, organizationId, code }) {
5981
6461
  let spot = null;
5982
6462
  if (target && el && isPresent(el)) {
5983
6463
  lostSince = null;
5984
- spot = padRect(rectOf(el), TARGET_PAD);
6464
+ spot = spotOf(el);
5985
6465
  }
5986
6466
  else if (target) {
5987
6467
  // Maybe re-rendered under another node.
5988
6468
  const again = findAnchor(target.anchor, target.id);
5989
6469
  if (again) {
5990
6470
  targetElRef.current = again;
5991
- spot = padRect(rectOf(again), TARGET_PAD);
6471
+ spot = spotOf(again);
5992
6472
  lostSince = null;
5993
6473
  }
5994
6474
  else if (phase === "ready") {
@@ -6001,21 +6481,25 @@ function GuideHostInner({ config, organizationId, code }) {
6001
6481
  }
6002
6482
  if (phase === "waiting" && spot)
6003
6483
  dispatchRef.current({ type: "sync" });
6004
- const holes = [];
6484
+ const popovers = [];
6005
6485
  for (const p of openPoppers())
6006
- holes.push(padRect(rectOf(p), 2));
6486
+ popovers.push(padRect(rectOf(p), 2));
6487
+ const holes = [];
6007
6488
  for (const a of allow) {
6008
6489
  const ael = findAnchor(a);
6009
6490
  if (ael)
6010
6491
  holes.push(padRect(rectOf(ael), TARGET_PAD));
6011
6492
  }
6012
- const next = { spot, holes, vw, vh, oy };
6493
+ const covered = !!spot && coveredByDialog(targetElRef.current);
6494
+ const next = { spot, covered, holes, popovers, vw, vh, oy };
6495
+ const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
6013
6496
  if (!sameRect(next.spot, last.spot) ||
6497
+ next.covered !== last.covered ||
6014
6498
  next.vw !== last.vw ||
6015
6499
  next.vh !== last.vh ||
6016
6500
  next.oy !== last.oy ||
6017
- next.holes.length !== last.holes.length ||
6018
- next.holes.some((h, i) => !sameRect(h, last.holes[i]))) {
6501
+ !sameList(next.holes, last.holes) ||
6502
+ !sameList(next.popovers, last.popovers)) {
6019
6503
  last = next;
6020
6504
  setLayout(next);
6021
6505
  }
@@ -6146,7 +6630,10 @@ function GuideHostInner({ config, organizationId, code }) {
6146
6630
  if (!(node instanceof Node))
6147
6631
  return false;
6148
6632
  const el = targetElRef.current;
6149
- if (el && el.contains(node))
6633
+ // A "view" step's target is there to be read: ถัดไป is the way on.
6634
+ const sc = stateRef.current?.screen;
6635
+ const pressable = targetPressable(stepInfoRef.current?.step, sc?.name === "step" && sc.bridge);
6636
+ if (el && pressable && el.contains(node))
6150
6637
  return true;
6151
6638
  const elNode = node instanceof Element ? node : node.parentElement;
6152
6639
  if (!elNode)
@@ -6245,7 +6732,7 @@ function GuideHostInner({ config, organizationId, code }) {
6245
6732
  const el = targetElRef.current;
6246
6733
  const step = stepInfoRef.current?.step;
6247
6734
  const isInput = !!step && !sc.bridge && stepKind(step) === "input";
6248
- const inTargetList = el ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
6735
+ const inTargetList = el && targetPressable(step, sc.bridge) ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
6249
6736
  const bubble = layer?.querySelector("[data-eg-bubble]") ?? null;
6250
6737
  list = [...inTargetList, ...(bubble ? Array.from(bubble.querySelectorAll(FOCUSABLE)) : [])];
6251
6738
  for (const p of openPoppers())
@@ -6320,19 +6807,38 @@ function GuideHostInner({ config, organizationId, code }) {
6320
6807
  React.useEffect(() => {
6321
6808
  const el = document.createElement("div");
6322
6809
  el.setAttribute(GUIDE_LAYER_ATTRIBUTE, "");
6810
+ // A Radix modal (dialog, select, menu) hides everything outside it from
6811
+ // screen readers with aria-hidden's hideOthers. It leaves [aria-live]
6812
+ // elements and their insides alone, but walks into their ancestors and
6813
+ // hides the siblings: with only the status line live, the bubble and its
6814
+ // buttons were hidden. "off" announces nothing; it only makes the whole
6815
+ // layer one of the kept elements.
6816
+ el.setAttribute("aria-live", "off");
6323
6817
  document.body.appendChild(el);
6324
6818
  layerRef.current = el;
6325
6819
  setLayer(el);
6326
- // A Radix modal hides everything else from screen readers; not us.
6327
- const obs = new MutationObserver(() => {
6328
- if (el.getAttribute("aria-hidden") === "true")
6329
- el.removeAttribute("aria-hidden");
6330
- if (el.hasAttribute("data-aria-hidden"))
6331
- el.removeAttribute("data-aria-hidden");
6332
- if (el.hasAttribute("inert"))
6333
- el.removeAttribute("inert");
6820
+ // And should anything still hide the layer or a part of it (another
6821
+ // library, a later hideOthers), take it off again. hideOthers marks what
6822
+ // it hides with data-aria-hidden (the layer never sets that, or inert);
6823
+ // aria-hidden goes only where it was just added, so the layer's own
6824
+ // decorative aria-hidden stays.
6825
+ const obs = new MutationObserver(records => {
6826
+ for (const r of records) {
6827
+ const n = r.target;
6828
+ if (!(n instanceof Element))
6829
+ continue;
6830
+ if (r.attributeName === "inert" && n.hasAttribute("inert"))
6831
+ n.removeAttribute("inert");
6832
+ else if (r.attributeName === "data-aria-hidden" && n.hasAttribute("data-aria-hidden"))
6833
+ n.removeAttribute("data-aria-hidden");
6834
+ else if (r.attributeName === "aria-hidden" &&
6835
+ n.getAttribute("aria-hidden") === "true" &&
6836
+ (n === el || r.oldValue === null)) {
6837
+ n.removeAttribute("aria-hidden");
6838
+ }
6839
+ }
6334
6840
  });
6335
- obs.observe(el, { attributes: true, attributeFilter: ["aria-hidden", "data-aria-hidden", "inert"] });
6841
+ obs.observe(el, { attributes: true, attributeOldValue: true, subtree: true, attributeFilter: ["aria-hidden", "data-aria-hidden", "inert"] });
6336
6842
  return () => {
6337
6843
  obs.disconnect();
6338
6844
  el.remove();
@@ -6399,7 +6905,7 @@ function GuideHostInner({ config, organizationId, code }) {
6399
6905
  const { mission, step, missionNo } = stepInfo;
6400
6906
  const kind = stepKind(step);
6401
6907
  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: {
6908
+ 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
6909
  missionNo,
6404
6910
  missionTitle: mission.title,
6405
6911
  replay: screen.replay,
@@ -6410,7 +6916,7 @@ function GuideHostInner({ config, organizationId, code }) {
6410
6916
  error: screen.error,
6411
6917
  showNext: !bridge && (kind === "input" || kind === "view"),
6412
6918
  showEndWithoutSave: !bridge && screen.replay && kind === "commit",
6413
- }, hasTarget: bridge || !!step.target, phase: screen.phase, confirmExit: screen.confirmExit, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
6919
+ }, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), phase: screen.phase, confirmExit: screen.confirmExit, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
6414
6920
  }
6415
6921
  else if (screen.name === "celebrate" && celebrateShown) {
6416
6922
  const mission = findMission(suite, screen.mission);