@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.esm.js CHANGED
@@ -14,7 +14,7 @@ import { createPortal } from 'react-dom';
14
14
  import { useTheme, ThemeProvider as ThemeProvider$1 } from 'next-themes';
15
15
  import axios, { isAxiosError, AxiosHeaders } from 'axios';
16
16
  export { isAxiosError } from 'axios';
17
- import { computePosition, offset, flip, shift, arrow } from '@floating-ui/dom';
17
+ import { computePosition, offset, shift, arrow } from '@floating-ui/dom';
18
18
  import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
19
19
 
20
20
  /**
@@ -1956,7 +1956,7 @@ function platformApiBase(app) {
1956
1956
  * This package's version, attached to help messages so the back office can
1957
1957
  * tell which shell a report came from. Keep in step with package.json.
1958
1958
  */
1959
- const SHELL_VERSION = "0.1.63";
1959
+ const SHELL_VERSION = "0.1.65";
1960
1960
 
1961
1961
  const HELP_APP_LABEL = {
1962
1962
  platform: "แพลตฟอร์ม",
@@ -3892,6 +3892,52 @@ function needsBridge(step, env, ctx) {
3892
3892
  !env.sidebarOpen &&
3893
3893
  !env.isVisible(step.target, targetIdOf(step, ctx)));
3894
3894
  }
3895
+ /**
3896
+ * The page a step happens on: its own `route`, or else where the steps before
3897
+ * it left the user (an earlier step's route trigger or route). Undefined when
3898
+ * nothing says (a mission's first steps, which work from any page).
3899
+ */
3900
+ function stepHomeRoute(steps, index) {
3901
+ for (let j = Math.min(index, steps.length - 1); j >= 0; j--) {
3902
+ const s = steps[j];
3903
+ if (j < index) {
3904
+ // What this earlier step moved the user to comes after where it happened.
3905
+ const t = triggersOf(s).find((x) => "route" in x);
3906
+ if (t)
3907
+ return t.route;
3908
+ }
3909
+ if (s.route)
3910
+ return s.route;
3911
+ }
3912
+ return undefined;
3913
+ }
3914
+ /**
3915
+ * The page a step is shown on, or the "ทำต่อ" card when the user is elsewhere:
3916
+ * its route; for a checkpoint without one, the page the steps before it lead
3917
+ * to (so going back to it from another page cannot loop).
3918
+ */
3919
+ function placeRoute(steps, index) {
3920
+ const step = steps[index];
3921
+ if (step.route)
3922
+ return step.route;
3923
+ return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
3924
+ }
3925
+ /** The user is not on the page the step happens on. */
3926
+ function offRoute(steps, index, env) {
3927
+ const home = stepHomeRoute(steps, index);
3928
+ return !!home && !matchRoute(home, env.pathname, env.search, env.code);
3929
+ }
3930
+ /** What puts the page back when a replay ends at this step (see GuideStep.abort). */
3931
+ function abortActionsFor(mission, index) {
3932
+ const step = mission.steps[index];
3933
+ const own = step?.abort ?? mission.abort;
3934
+ if (own)
3935
+ return Array.isArray(own) ? own : [own];
3936
+ // Default: Escape to the dialog the tour opened; nothing on a stable page.
3937
+ if (!step || index === 0 || step.checkpoint)
3938
+ return [];
3939
+ return [{ escape: true }];
3940
+ }
3895
3941
  function routeParamsFor(step, env) {
3896
3942
  if (!step.route)
3897
3943
  return {};
@@ -4033,7 +4079,7 @@ function announceStep(d, suite, env) {
4033
4079
  const step = mission.steps[s.index];
4034
4080
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4035
4081
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
4036
- const why = s.bridge ? COPY.bridgeWhy(suite.appName) : resolveText(step.why, ctx);
4082
+ const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
4037
4083
  d.effects.push({
4038
4084
  type: "announce",
4039
4085
  text: `ขั้นที่ ${s.index + 1} จาก ${mission.steps.length}. ${title}${why ? `. ${why}` : ""}`,
@@ -4056,7 +4102,9 @@ function enterStep(d, suite, env, mission, index, opts) {
4056
4102
  const params = routeParamsFor(step, env);
4057
4103
  const ctx = guideContext(env, d.state.progress.context, params);
4058
4104
  if (step.requires && !safe(() => env.can(step.requires), true)) {
4059
- withProgress(d, { last_error: `forbidden:${step.requires}`.slice(0, 128) });
4105
+ // A replay writes nothing, not even what went wrong.
4106
+ if (!opts.replay)
4107
+ withProgress(d, { last_error: `forbidden:${step.requires}`.slice(0, 128) });
4060
4108
  i++;
4061
4109
  continue;
4062
4110
  }
@@ -4082,7 +4130,8 @@ function enterStep(d, suite, env, mission, index, opts) {
4082
4130
  }
4083
4131
  const step = steps[i];
4084
4132
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4085
- const away = !!step.route && !matchRoute(step.route, env.pathname, env.search, env.code);
4133
+ const where = placeRoute(steps, i);
4134
+ const away = !!where && !matchRoute(where, env.pathname, env.search, env.code);
4086
4135
  const bridge = !away && needsBridge(step, env, ctx);
4087
4136
  setScreen(d, {
4088
4137
  name: "step",
@@ -4128,6 +4177,35 @@ function markReadyIfVisible(d, suite, env) {
4128
4177
  d.effects.push({ type: "announce", text: s.error.title });
4129
4178
  }
4130
4179
  }
4180
+ /** Where to send the user to resume at a step: its resumeUrl, or its page filled in. */
4181
+ function resumeHref(mission, index, d, env) {
4182
+ const step = mission.steps[index];
4183
+ const ctx = guideContext(env, d.state.progress.context);
4184
+ const where = placeRoute(mission.steps, index);
4185
+ return (safe(() => step.resumeUrl?.(ctx), undefined) ??
4186
+ (where ? fillRoute(where, { ...d.state.progress.context, code: env.code }) : null));
4187
+ }
4188
+ /**
4189
+ * The user left the page the step happens on (a link, the browser's back):
4190
+ * back to the last checkpoint. On another page that is the "ทำต่อ" card;
4191
+ * `navigate` (ลองอีกครั้ง) goes there at once.
4192
+ */
4193
+ function recoverOffRoute(d, suite, env, navigate) {
4194
+ const s = d.state.screen;
4195
+ if (s.name !== "step")
4196
+ return;
4197
+ const mission = findMission(suite, s.mission);
4198
+ if (!mission)
4199
+ return;
4200
+ const cp = checkpointAtOrBefore(mission.steps, s.index);
4201
+ enterStep(d, suite, env, mission, cp, { replay: s.replay });
4202
+ const now = d.state.screen;
4203
+ if (navigate && now.name === "step" && now.phase === "away") {
4204
+ const href = resumeHref(mission, now.index, d, env);
4205
+ if (href)
4206
+ d.effects.push({ type: "navigate", href });
4207
+ }
4208
+ }
4131
4209
  function captureInto(d, step, e, ctx) {
4132
4210
  if (!step.capture)
4133
4211
  return;
@@ -4195,6 +4273,7 @@ function startMission(d, suite, env, key, replay) {
4195
4273
  const entry = d.state.progress.missions[key];
4196
4274
  // A mission already done is only ever replayed; its done status stays.
4197
4275
  const isReplay = replay || entry?.status === "done";
4276
+ const before = d.state.progress;
4198
4277
  d.state = { ...d.state, panelOpen: false, justDone: null };
4199
4278
  if (d.state.progress.welcome === "not_seen")
4200
4279
  withProgress(d, { welcome: "started" });
@@ -4212,7 +4291,9 @@ function startMission(d, suite, env, key, replay) {
4212
4291
  index = checkpointAtOrBefore(mission.steps, at);
4213
4292
  }
4214
4293
  enterStep(d, suite, env, mission, index, { replay: isReplay });
4215
- if (isReplay)
4294
+ // A replay never writes progress; only what starting it changed (the
4295
+ // welcome, first seen through a replay) is saved.
4296
+ if (isReplay && d.state.progress !== before)
4216
4297
  save(d);
4217
4298
  }
4218
4299
  function exitMission(d) {
@@ -4262,6 +4343,17 @@ function apiFailureFor(step, sig) {
4262
4343
  (!t.api.method || t.api.method.toUpperCase() === sig.method.toUpperCase()) &&
4263
4344
  matchApiPath(t.api.path, sig.path));
4264
4345
  }
4346
+ /** A replay ends early: what puts the page back (nothing on another page). */
4347
+ function pushAbort(d, suite, s) {
4348
+ if (s.phase === "away")
4349
+ return;
4350
+ const mission = findMission(suite, s.mission);
4351
+ if (!mission)
4352
+ return;
4353
+ const actions = abortActionsFor(mission, s.index);
4354
+ if (actions.length)
4355
+ d.effects.push({ type: "abort", actions });
4356
+ }
4265
4357
  function reduceGuide(state, action, suite, env) {
4266
4358
  const d = { state, effects: [] };
4267
4359
  const s = state.screen;
@@ -4342,7 +4434,8 @@ function reduceGuide(state, action, suite, env) {
4342
4434
  const step = mission.steps[s.index];
4343
4435
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4344
4436
  if (s.phase === "away") {
4345
- if (!step.route || matchRoute(step.route, env.pathname, env.search, env.code)) {
4437
+ const where = placeRoute(mission.steps, s.index);
4438
+ if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
4346
4439
  // Arrived: run the step again from here (it may already be passed).
4347
4440
  enterStep(d, suite, env, mission, s.index, { replay: s.replay, error: s.error });
4348
4441
  }
@@ -4359,6 +4452,11 @@ function reduceGuide(state, action, suite, env) {
4359
4452
  break;
4360
4453
  }
4361
4454
  }
4455
+ if (!s.bridge && !stepTargetVisible(step, env, ctx, false) && offRoute(mission.steps, s.index, env)) {
4456
+ // The target is missing because the page is another one.
4457
+ recoverOffRoute(d, suite, env, false);
4458
+ break;
4459
+ }
4362
4460
  if (armed !== s.armed)
4363
4461
  setScreen(d, { ...s, armed });
4364
4462
  const cur = d.state.screen;
@@ -4413,7 +4511,8 @@ function reduceGuide(state, action, suite, env) {
4413
4511
  : { title: COPY.saveFailed, detail: sig.message };
4414
4512
  setScreen(d, { ...s, error });
4415
4513
  d.effects.push({ type: "announce", text: [error.title, error.detail].filter(Boolean).join(". ") });
4416
- withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
4514
+ if (!s.replay)
4515
+ withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
4417
4516
  }
4418
4517
  break;
4419
4518
  }
@@ -4458,6 +4557,10 @@ function reduceGuide(state, action, suite, env) {
4458
4557
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4459
4558
  if (stepTargetVisible(step, env, ctx, s.bridge))
4460
4559
  break;
4560
+ if (!s.bridge && offRoute(mission.steps, s.index, env)) {
4561
+ recoverOffRoute(d, suite, env, false);
4562
+ break;
4563
+ }
4461
4564
  const cp = checkpointAtOrBefore(mission.steps, s.index);
4462
4565
  if (cp < s.index && !s.bridge) {
4463
4566
  // A dialog or a menu closed under the step: back to what opens it.
@@ -4475,9 +4578,16 @@ function reduceGuide(state, action, suite, env) {
4475
4578
  if (!mission)
4476
4579
  break;
4477
4580
  const step = mission.steps[s.index];
4581
+ if (!s.bridge && offRoute(mission.steps, s.index, env)) {
4582
+ recoverOffRoute(d, suite, env, false);
4583
+ break;
4584
+ }
4478
4585
  const anchor = s.bridge ? BRIDGE_TARGET : (step.target ?? step.key);
4479
- withProgress(d, { last_error: `target_missing:${anchor}`.slice(0, 128) });
4480
- save(d);
4586
+ // Diagnostic only: kept with the progress, sent with its next save (a
4587
+ // replay keeps nothing). The same error again changes nothing, so it
4588
+ // never sends a PUT on its own.
4589
+ if (!s.replay)
4590
+ withProgress(d, { last_error: `target_missing:${anchor}`.slice(0, 128) });
4481
4591
  const misses = s.misses + 1;
4482
4592
  const policy = step.onMissing ?? "retry";
4483
4593
  const cp = checkpointAtOrBefore(mission.steps, s.index);
@@ -4504,12 +4614,19 @@ function reduceGuide(state, action, suite, env) {
4504
4614
  }
4505
4615
  break;
4506
4616
  }
4507
- case "retry":
4508
- if (s.name === "step" && s.phase === "missing") {
4509
- setScreen(d, { ...s, phase: "waiting" });
4510
- markReadyIfVisible(d, suite, env);
4617
+ case "retry": {
4618
+ if (s.name !== "step" || s.phase !== "missing")
4619
+ break;
4620
+ const mission = findMission(suite, s.mission);
4621
+ if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
4622
+ // Polling again cannot help on another page: go back to the step's.
4623
+ recoverOffRoute(d, suite, env, true);
4624
+ break;
4511
4625
  }
4626
+ setScreen(d, { ...s, phase: "waiting" });
4627
+ markReadyIfVisible(d, suite, env);
4512
4628
  break;
4629
+ }
4513
4630
  case "blocked":
4514
4631
  if (s.name === "step")
4515
4632
  d.state = { ...d.state, nudge: d.state.nudge + 1 };
@@ -4518,6 +4635,7 @@ function reduceGuide(state, action, suite, env) {
4518
4635
  if (s.name !== "step")
4519
4636
  break;
4520
4637
  if (s.replay) {
4638
+ pushAbort(d, suite, s);
4521
4639
  setScreen(d, { name: "idle" });
4522
4640
  d.effects.push({ type: "toast", text: COPY.replayEnded });
4523
4641
  }
@@ -4538,8 +4656,9 @@ function reduceGuide(state, action, suite, env) {
4538
4656
  case "endWithoutSave":
4539
4657
  if (s.name !== "step" || !s.replay)
4540
4658
  break;
4659
+ pushAbort(d, suite, s);
4541
4660
  setScreen(d, { name: "idle" });
4542
- d.effects.push({ type: "closeAppDialog" }, { type: "toast", text: COPY.replayEnded });
4661
+ d.effects.push({ type: "toast", text: COPY.replayEnded });
4543
4662
  break;
4544
4663
  case "goResume": {
4545
4664
  if (s.name !== "step" || s.phase !== "away")
@@ -4547,10 +4666,7 @@ function reduceGuide(state, action, suite, env) {
4547
4666
  const mission = findMission(suite, s.mission);
4548
4667
  if (!mission)
4549
4668
  break;
4550
- const step = mission.steps[s.index];
4551
- const ctx = guideContext(env, d.state.progress.context);
4552
- const href = safe(() => step.resumeUrl?.(ctx), undefined) ??
4553
- (step.route ? fillRoute(step.route, { ...d.state.progress.context, code: env.code }) : null);
4669
+ const href = resumeHref(mission, s.index, d, env);
4554
4670
  if (href)
4555
4671
  d.effects.push({ type: "navigate", href });
4556
4672
  break;
@@ -4786,6 +4902,27 @@ function putBody(p, app) {
4786
4902
  TERMINAL.includes(p.server_status),
4787
4903
  };
4788
4904
  }
4905
+ function stable(v) {
4906
+ if (Array.isArray(v))
4907
+ return `[${v.map(stable).join(",")}]`;
4908
+ if (isRecord(v)) {
4909
+ return `{${Object.keys(v)
4910
+ .sort()
4911
+ .map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
4912
+ .join(",")}}`;
4913
+ }
4914
+ return JSON.stringify(v) ?? "null";
4915
+ }
4916
+ /**
4917
+ * What a PUT would store, without the rev it is checked against: two equal
4918
+ * keys mean the save would change nothing, and it is not sent (a replay
4919
+ * starting, a step entered again, never bump the row's rev). Key order does
4920
+ * not matter.
4921
+ */
4922
+ function payloadKey(p, app) {
4923
+ const { rev: _rev, restart: _restart, ...rest } = putBody(p, app);
4924
+ return stable(rest);
4925
+ }
4789
4926
  const MISSION_RANK = { todo: 0, skipped: 1, active: 1, done: 3 };
4790
4927
  const WELCOME_RANK = { not_seen: 0, skipped: 1, started: 1 };
4791
4928
  /**
@@ -4920,8 +5057,9 @@ function isPresent(el) {
4920
5057
  if (r.width <= 0 || r.height <= 0)
4921
5058
  return false;
4922
5059
  // Slid off the side (a closed drawer); off the top or bottom is only
4923
- // scrolled away, and the tour scrolls it back.
4924
- if (r.right <= 0 || r.left >= window.innerWidth)
5060
+ // scrolled away, and the tour scrolls it back. So is a tab scrolled out of
5061
+ // a row of tabs that scrolls sideways, as long as the row is on screen.
5062
+ if ((r.right <= 0 || r.left >= window.innerWidth) && !inSidewaysScroller(el))
4925
5063
  return false;
4926
5064
  if (el.getAttribute("data-state") === "closed")
4927
5065
  return false;
@@ -5001,6 +5139,26 @@ function sameRect(a, b) {
5001
5139
  const POPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
5002
5140
  /** Toasts stay usable above the tour. */
5003
5141
  const TOASTER_SELECTOR = "[data-sonner-toaster]";
5142
+ /** Open dialogs (Radix dialog, alert dialog, sheet, popover content). */
5143
+ const OPEN_DIALOG_SELECTOR = '[role="dialog"][data-state="open"],[role="alertdialog"][data-state="open"]';
5144
+ /**
5145
+ * Whether an open dialog of the app's covers the target: the target is
5146
+ * neither in it nor around it (a confirm opened by the step's own button).
5147
+ * The ring and the hand would be drawn over the dialog, pointing at a button
5148
+ * behind it.
5149
+ */
5150
+ function coveredByDialog(el) {
5151
+ if (!hasDocument() || !el)
5152
+ return false;
5153
+ for (const d of Array.from(document.querySelectorAll(OPEN_DIALOG_SELECTOR))) {
5154
+ if (inGuideLayer(d) || !isPresent(d))
5155
+ continue;
5156
+ if (d.contains(el) || el.contains(d))
5157
+ continue;
5158
+ return true;
5159
+ }
5160
+ return false;
5161
+ }
5004
5162
  function openPoppers() {
5005
5163
  if (!hasDocument())
5006
5164
  return [];
@@ -5018,18 +5176,224 @@ function prefersReducedMotion() {
5018
5176
  function isPhoneWidth() {
5019
5177
  return typeof window !== "undefined" && window.innerWidth < 768;
5020
5178
  }
5021
- /** Scrolls the target to the middle when it is out of view (instant under reduced motion). */
5179
+ /* ---------------- what is really on screen ---------------- */
5180
+ const INFINITE = 1e7;
5181
+ /** The overlap of two rectangles, or null when they do not meet. */
5182
+ function intersectRect(a, b) {
5183
+ const x = Math.max(a.x, b.x);
5184
+ const y = Math.max(a.y, b.y);
5185
+ const right = Math.min(a.x + a.width, b.x + b.width);
5186
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
5187
+ if (right - x <= 0 || bottom - y <= 0)
5188
+ return null;
5189
+ return { x, y, width: right - x, height: bottom - y };
5190
+ }
5191
+ /** The part of `rect` inside every clip (scroll boxes, the viewport); null when none is. */
5192
+ function visibleRect(rect, clips) {
5193
+ let r = rect;
5194
+ for (const c of clips) {
5195
+ if (!r)
5196
+ return null;
5197
+ r = intersectRect(r, c);
5198
+ }
5199
+ return r;
5200
+ }
5201
+ const clipsX = (v) => v !== "visible";
5202
+ const scrollsX = (v) => v === "auto" || v === "scroll";
5203
+ /**
5204
+ * The ancestors that cut the element off: anything with overflow other than
5205
+ * visible on an axis. The body and the root scroll the page and are covered by
5206
+ * the viewport. Read once per target; their rectangles are read every frame.
5207
+ */
5208
+ function clipAncestors(el) {
5209
+ const out = [];
5210
+ if (typeof getComputedStyle !== "function")
5211
+ return out;
5212
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5213
+ if (n.hasAttribute(GUIDE_LAYER_ATTRIBUTE))
5214
+ break;
5215
+ const s = getComputedStyle(n);
5216
+ const x = clipsX(s.overflowX);
5217
+ const y = clipsX(s.overflowY);
5218
+ if (x || y)
5219
+ out.push({ el: n, x, y });
5220
+ if (s.position === "fixed")
5221
+ break;
5222
+ }
5223
+ return out;
5224
+ }
5225
+ /** An ancestor's clip as a rectangle: unbounded on an axis it does not clip. */
5226
+ function clipRectOf(c) {
5227
+ const r = c.el.getBoundingClientRect();
5228
+ return {
5229
+ x: c.x ? r.left : -INFINITE,
5230
+ y: c.y ? r.top : -INFINITE,
5231
+ width: c.x ? r.width : INFINITE * 2,
5232
+ height: c.y ? r.height : INFINITE * 2,
5233
+ };
5234
+ }
5235
+ function viewportRect() {
5236
+ const vv = typeof window !== "undefined" ? window.visualViewport : undefined;
5237
+ return {
5238
+ x: 0,
5239
+ y: vv ? vv.offsetTop : 0,
5240
+ width: window.innerWidth,
5241
+ height: vv ? vv.height : window.innerHeight,
5242
+ };
5243
+ }
5244
+ /**
5245
+ * The part of the element a person can see: its box cut by every scroll box
5246
+ * around it and by the viewport. The spotlight, its hole, the ring and the
5247
+ * hand use this, so a tab half out of a sideways-scrolling row of tabs is not
5248
+ * spotlit where it cannot be pressed. Null when none of it is on screen.
5249
+ */
5250
+ function visibleRectOf(el, ancestors = clipAncestors(el)) {
5251
+ return visibleRect(rectOf(el), [...ancestors.map(clipRectOf), viewportRect()]);
5252
+ }
5253
+ /** Inside a box that scrolls sideways (with somewhere to scroll) that is itself on screen. */
5254
+ function inSidewaysScroller(el) {
5255
+ if (typeof getComputedStyle !== "function")
5256
+ return false;
5257
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5258
+ const s = getComputedStyle(n);
5259
+ if (scrollsX(s.overflowX) && n.scrollWidth > n.clientWidth + 1) {
5260
+ const r = n.getBoundingClientRect();
5261
+ return r.width > 0 && r.right > 0 && r.left < window.innerWidth;
5262
+ }
5263
+ }
5264
+ return false;
5265
+ }
5266
+ /**
5267
+ * How to bring a target into view, or null when it already is: to the middle
5268
+ * when it is above or below the comfortable band (under the header, at the
5269
+ * bottom edge), the nearest edge when only a side is cut off (a tab out of a
5270
+ * sideways-scrolling row, on a phone).
5271
+ */
5272
+ function revealPlan(rect, visible, viewportHeight) {
5273
+ const offVertically = rect.y < 70 || rect.y + rect.height > viewportHeight - 20;
5274
+ const cutSideways = !visible || visible.width < rect.width - 1;
5275
+ if (!offVertically && !cutSideways)
5276
+ return null;
5277
+ return { block: offVertically ? "center" : "nearest", inline: "nearest" };
5278
+ }
5279
+ /** Room left beside a target scrolled into a sideways scroll box. */
5280
+ const SIDEWAYS_MARGIN = 12;
5281
+ /**
5282
+ * How far to scroll a sideways scroll box so the target sits inside its
5283
+ * visible part (`box`: the box cut by the viewport) with `margin` to spare on
5284
+ * both sides; 0 when it already does. Clamped to what the box can scroll.
5285
+ * A target wider than the room is aligned to its start.
5286
+ */
5287
+ function sidewaysDelta(target, box, scrollLeft, maxScrollLeft, margin = SIDEWAYS_MARGIN) {
5288
+ const left = box.x + margin;
5289
+ const right = box.x + box.width - margin;
5290
+ let delta = 0;
5291
+ if (target.x < left || target.width > right - left)
5292
+ delta = target.x - left;
5293
+ else if (target.x + target.width > right)
5294
+ delta = target.x + target.width - right;
5295
+ const next = Math.min(Math.max(scrollLeft + delta, 0), Math.max(maxScrollLeft, 0));
5296
+ return next - scrollLeft;
5297
+ }
5298
+ /** The nearest ancestor that scrolls sideways and has somewhere to scroll. */
5299
+ function sidewaysScroller(el) {
5300
+ if (typeof getComputedStyle !== "function")
5301
+ return null;
5302
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5303
+ if (scrollsX(getComputedStyle(n).overflowX) && n.scrollWidth > n.clientWidth + 1)
5304
+ return n;
5305
+ }
5306
+ return null;
5307
+ }
5308
+ /**
5309
+ * Scrolls the target into view (instant under reduced motion). In a box that
5310
+ * scrolls sideways (a row of tabs on a phone) the box is scrolled first,
5311
+ * leaving SIDEWAYS_MARGIN beside the target so its whole label shows; then
5312
+ * the page, up or down only.
5313
+ */
5022
5314
  function revealTarget(el) {
5023
- const r = el.getBoundingClientRect();
5315
+ const behavior = prefersReducedMotion() ? "auto" : "smooth";
5316
+ const scroller = sidewaysScroller(el);
5317
+ if (scroller) {
5318
+ const box = intersectRect(rectOf(scroller), viewportRect()) ?? rectOf(scroller);
5319
+ const dx = sidewaysDelta(rectOf(el), box, scroller.scrollLeft, scroller.scrollWidth - scroller.clientWidth);
5320
+ // Instant: the page's own scroll below must not cancel it.
5321
+ if (Math.abs(dx) >= 1)
5322
+ scroller.scrollLeft += dx;
5323
+ }
5024
5324
  const vh = window.visualViewport?.height ?? window.innerHeight;
5025
- if (r.top < 70 || r.bottom > vh - 20) {
5026
- try {
5027
- el.scrollIntoView({ block: "center", behavior: prefersReducedMotion() ? "auto" : "smooth" });
5325
+ const plan = revealPlan(rectOf(el), visibleRectOf(el), vh);
5326
+ if (!plan)
5327
+ return;
5328
+ try {
5329
+ el.scrollIntoView({ ...plan, behavior });
5330
+ }
5331
+ catch {
5332
+ el.scrollIntoView();
5333
+ }
5334
+ }
5335
+ /* ---------------- the scrim's holes, as one union ---------------- */
5336
+ /**
5337
+ * The scrim's blocking area: the screen (with a 10 px margin) minus the union
5338
+ * of the holes, as rectangles that do not overlap. Cutting each hole out with
5339
+ * an even-odd fill made the place where two holes overlap (a field and the
5340
+ * select list open over it) solid again, and the option there could not be
5341
+ * pressed.
5342
+ *
5343
+ * Every hole edge splits the screen into a grid; a cell is blocked when no
5344
+ * hole covers it. Cells in a row are joined, and identical rows stacked.
5345
+ */
5346
+ function blockedRects(holes, width, height) {
5347
+ const x0 = -10;
5348
+ const y0 = -10;
5349
+ const x1 = width + 10;
5350
+ const y1 = height + 10;
5351
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
5352
+ const hs = holes
5353
+ .filter(h => h.width > 0 && h.height > 0)
5354
+ .map(h => ({
5355
+ l: clamp(h.x, x0, x1),
5356
+ t: clamp(h.y, y0, y1),
5357
+ r: clamp(h.x + h.width, x0, x1),
5358
+ b: clamp(h.y + h.height, y0, y1),
5359
+ }))
5360
+ .filter(h => h.r > h.l && h.b > h.t);
5361
+ if (!hs.length)
5362
+ return [{ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }];
5363
+ const uniq = (v) => Array.from(new Set(v)).sort((a, b) => a - b);
5364
+ const xs = uniq([x0, x1, ...hs.flatMap(h => [h.l, h.r])]);
5365
+ const ys = uniq([y0, y1, ...hs.flatMap(h => [h.t, h.b])]);
5366
+ const out = [];
5367
+ let prev = null;
5368
+ for (let j = 0; j < ys.length - 1; j++) {
5369
+ const top = ys[j];
5370
+ const bottom = ys[j + 1];
5371
+ const cy = (top + bottom) / 2;
5372
+ const row = [];
5373
+ for (let i = 0; i < xs.length - 1; i++) {
5374
+ const left = xs[i];
5375
+ const right = xs[i + 1];
5376
+ const cx = (left + right) / 2;
5377
+ const covered = hs.some(h => cx > h.l && cx < h.r && cy > h.t && cy < h.b);
5378
+ if (covered)
5379
+ continue;
5380
+ const last = row[row.length - 1];
5381
+ if (last && last.x + last.width === left)
5382
+ last.width = right - last.x;
5383
+ else
5384
+ row.push({ x: left, y: top, width: right - left, height: bottom - top });
5028
5385
  }
5029
- catch {
5030
- el.scrollIntoView();
5386
+ const key = row.map(r => `${r.x},${r.width}`).join("|");
5387
+ if (prev && prev.key === key) {
5388
+ for (const r of prev.rects)
5389
+ r.height += bottom - top;
5390
+ }
5391
+ else {
5392
+ out.push(...row);
5393
+ prev = { key, rects: row };
5031
5394
  }
5032
5395
  }
5396
+ return out;
5033
5397
  }
5034
5398
 
5035
5399
  /**
@@ -5051,7 +5415,8 @@ const GUIDE_CSS = `
5051
5415
  .eg-i{pointer-events:auto}
5052
5416
  .eg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
5053
5417
  .eg-scrim-svg{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
5054
- .eg-scrim-svg path{fill:var(--eg-scrim);pointer-events:visiblePainted;fill-rule:evenodd}
5418
+ .eg-scrim-svg .eg-scrim-fill{fill:var(--eg-scrim);pointer-events:none}
5419
+ .eg-scrim-svg .eg-scrim-hit{fill:transparent;pointer-events:all}
5055
5420
  .eg-scrim{position:fixed;inset:0;background:var(--eg-scrim);pointer-events:auto}
5056
5421
  .eg-scrim.eg-light{background:rgba(12,20,34,.45)}
5057
5422
  .eg-scrim.eg-faint{background:rgba(12,20,34,.35)}
@@ -5160,6 +5525,7 @@ const GUIDE_CSS = `
5160
5525
  .eg-q-item.eg-done .eg-t b{color:var(--eg-muted)}
5161
5526
  .eg-q-item .eg-t small{display:block;font-size:12px;color:var(--eg-muted)}
5162
5527
  .eg-q-item .eg-t small.eg-sk{color:#b06a00}
5528
+ .eg-q-item .eg-t small.eg-lock{color:var(--eg-fg);font-weight:500}
5163
5529
  .eg-q-item .eg-btn{height:32px;padding:0 12px;font-size:13px}
5164
5530
  .eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
5165
5531
  .eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
@@ -5224,15 +5590,53 @@ function roundRect(r, radius) {
5224
5590
  `H${x + rad}A${rad} ${rad} 0 0 1 ${x} ${y + h - rad}` +
5225
5591
  `V${y + rad}A${rad} ${rad} 0 0 1 ${x + rad} ${y}Z`);
5226
5592
  }
5593
+ const rectPath = (r) => `M${r.x} ${r.y}H${r.x + r.width}V${r.y + r.height}H${r.x}Z`;
5594
+ /**
5595
+ * The dim layer that eats every press outside the holes. The holes are a
5596
+ * union: where two overlap (a field and the select list open over it) the
5597
+ * page stays open.
5598
+ *
5599
+ * - The look: one rectangle masked by the rounded holes (a mask adds holes
5600
+ * up, so an overlap is not filled back in).
5601
+ * - The presses: a transparent path made of the screen minus the holes, as
5602
+ * rectangles that do not overlap (blockedRects). A press inside a hole
5603
+ * reaches the page underneath; any other lands on the path (marked
5604
+ * data-eg-block) and is cancelled.
5605
+ * - `open`: the holes a press may go through, when not all of them (a "view"
5606
+ * step's target is shown but not pressable). Default: every hole.
5607
+ */
5608
+ function Scrim({ holes, open, width, height }) {
5609
+ const maskId = "eg-scrim-mask-" + React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
5610
+ const block = blockedRects(open ?? holes, width, height).map(rectPath).join("");
5611
+ return (jsxs("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: [holes.length ? (jsx("defs", { children: jsxs("mask", { id: maskId, maskUnits: "userSpaceOnUse", x: -10, y: -10, width: width + 20, height: height + 20, children: [jsx("rect", { x: -10, y: -10, width: width + 20, height: height + 20, fill: "#fff" }), holes.map((h, i) => (jsx("path", { d: roundRect(h, 12), fill: "#000" }, i)))] }) })) : null, jsx("rect", { className: "eg-scrim-fill", x: -10, y: -10, width: width + 20, height: height + 20, mask: holes.length ? `url(#${maskId})` : undefined }), jsx("path", { className: "eg-scrim-hit", d: block, "data-eg-block": "" })] }));
5612
+ }
5613
+ /** Whether two rectangles overlap. */
5614
+ const meets = (a, b) => !!intersectRect(a, b);
5615
+ /** The smallest rectangle around all of them. */
5616
+ function boundingRect(rs) {
5617
+ const x = Math.min(...rs.map(r => r.x));
5618
+ const y = Math.min(...rs.map(r => r.y));
5619
+ const right = Math.max(...rs.map(r => r.x + r.width));
5620
+ const bottom = Math.max(...rs.map(r => r.y + r.height));
5621
+ return { x, y, width: right - x, height: bottom - y };
5622
+ }
5623
+ /** Where the bubble may go, in order of preference. */
5624
+ const BUBBLE_PLACEMENTS = ["right", "bottom", "top", "left"];
5227
5625
  /**
5228
- * The dim layer that eats every press outside the holes. One SVG path with
5229
- * even-odd holes: a press inside a hole reaches the page underneath, any
5230
- * other lands on the path (marked data-eg-block) and is cancelled.
5626
+ * The first placement (in BUBBLE_PLACEMENTS order) whose bubble is on screen,
5627
+ * off the target (with its open list), and off everything to keep clear;
5628
+ * failing that, the first that is only on screen and off the target; -1 when
5629
+ * none is.
5231
5630
  */
5232
- function Scrim({ holes, width, height }) {
5233
- const outer = `M-10 -10H${width + 10}V${height + 10}H-10Z`;
5234
- const d = outer + holes.map(h => roundRect(h, 12)).join("");
5235
- return (jsx("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: jsx("path", { d: d, "data-eg-block": "" }) }));
5631
+ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
5632
+ const box = (r) => ({ x: r.x, y: r.y, width: bw, height: bh });
5633
+ 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;
5634
+ const ok = results.map(r => {
5635
+ const b = box(r);
5636
+ return fits(b) && !meets(b, anchor);
5637
+ });
5638
+ const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
5639
+ return best >= 0 ? best : ok.indexOf(true);
5236
5640
  }
5237
5641
  const BUBBLE_TITLE_ID = "eg-bubble-title";
5238
5642
  const BUBBLE_WHY_ID = "eg-bubble-why";
@@ -5272,12 +5676,21 @@ function StepLayer(p) {
5272
5676
  const gap = p.showHand ? 34 : 14;
5273
5677
  const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
5274
5678
  const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
5679
+ // What the bubble sits beside: the target, with an open list over it (the
5680
+ // list is what the person is choosing from now). What it must not cover:
5681
+ // open lists, and the other anchors the step allows (a map step's
5682
+ // coordinate fields).
5683
+ const popoverList = p.popovers ?? [];
5684
+ const anchor = spot ? boundingRect([spot, ...popoverList.filter(o => meets(o, spot))]) : null;
5685
+ const clear = [...popoverList, ...p.holes];
5686
+ const anchorKey = anchor ? `${anchor.x}|${anchor.y}|${anchor.width}|${anchor.height}` : "";
5687
+ const clearKey = clear.map(r => `${r.x},${r.y},${r.width},${r.height}`).join(";");
5275
5688
  useLayoutEffect(() => {
5276
5689
  const el = bubbleRef.current;
5277
5690
  if (!el || !showBubble)
5278
5691
  return;
5279
5692
  const m = 16;
5280
- if (!spot) {
5693
+ if (!spot || !anchor) {
5281
5694
  // Nothing to point at: the middle of the screen (the bottom on a phone).
5282
5695
  const bw = el.offsetWidth;
5283
5696
  const bh = el.offsetHeight;
@@ -5288,13 +5701,13 @@ function StepLayer(p) {
5288
5701
  }
5289
5702
  if (phone) {
5290
5703
  // Docked to the top or the bottom of the screen, whichever covers less
5291
- // of the target (target in the lower half: top).
5704
+ // of the target and its open list (target in the lower half: top).
5292
5705
  const vh = viewport.height;
5293
5706
  const oy = viewport.offsetTop;
5294
5707
  const bh = el.offsetHeight;
5295
5708
  const topT = oy + m + 4;
5296
5709
  const botT = oy + vh - bh - m;
5297
- const { y, height: h } = spot;
5710
+ const { y, height: h } = anchor;
5298
5711
  const overlapTop = Math.max(0, topT + bh + 8 - y);
5299
5712
  const overlapBot = Math.max(0, y + h + gap - botT);
5300
5713
  const top = y + h / 2 > vh / 2
@@ -5308,40 +5721,42 @@ function StepLayer(p) {
5308
5721
  return;
5309
5722
  }
5310
5723
  let cancelled = false;
5724
+ const a = anchor;
5311
5725
  const ref = {
5312
5726
  getBoundingClientRect: () => ({
5313
- x: spot.x,
5314
- y: spot.y,
5315
- left: spot.x,
5316
- top: spot.y,
5317
- width: spot.width,
5318
- height: spot.height,
5319
- right: spot.x + spot.width,
5320
- bottom: spot.y + spot.height,
5727
+ x: a.x,
5728
+ y: a.y,
5729
+ left: a.x,
5730
+ top: a.y,
5731
+ width: a.width,
5732
+ height: a.height,
5733
+ right: a.x + a.width,
5734
+ bottom: a.y + a.height,
5321
5735
  }),
5322
5736
  };
5323
- computePosition(ref, el, {
5737
+ const at = (placement) => computePosition(ref, el, {
5324
5738
  strategy: "fixed",
5325
- placement: "right",
5739
+ placement,
5326
5740
  middleware: [
5327
- offset(({ placement }) => (placement.startsWith("bottom") ? gap : 14)),
5328
- flip({ fallbackPlacements: ["bottom", "top", "left"], padding: m }),
5741
+ offset(placement.startsWith("bottom") ? gap : 14),
5329
5742
  shift({ padding: m }),
5330
5743
  ...(arrowRef.current ? [arrow({ element: arrowRef.current, padding: 14 })] : []),
5331
5744
  ],
5332
- })
5333
- .then(({ x, y, placement, middlewareData }) => {
5745
+ });
5746
+ Promise.all(BUBBLE_PLACEMENTS.map(at))
5747
+ .then(results => {
5334
5748
  if (cancelled)
5335
5749
  return;
5336
5750
  const bw = el.offsetWidth;
5337
5751
  const bh = el.offsetHeight;
5338
- const fits = x >= m - 1 && y >= m - 1 && x + bw <= window.innerWidth - m + 1 && y + bh <= viewport.height + viewport.offsetTop - m + 1;
5339
- const overlaps = x < spot.x + spot.width && x + bw > spot.x && y < spot.y + spot.height && y + bh > spot.y;
5340
- if (!fits || overlaps) {
5752
+ const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
5753
+ const i = pickPlacement(results, bw, bh, a, clear, bounds);
5754
+ if (i < 0) {
5341
5755
  // Nowhere beside it: the bottom-right corner.
5342
5756
  setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
5343
5757
  return;
5344
5758
  }
5759
+ const { x, y, placement, middlewareData } = results[i];
5345
5760
  const side = placement.split("-")[0];
5346
5761
  setPos({ left: x, top: y, side, ax: middlewareData.arrow?.x, ay: middlewareData.arrow?.y });
5347
5762
  })
@@ -5352,11 +5767,15 @@ function StepLayer(p) {
5352
5767
  return () => {
5353
5768
  cancelled = true;
5354
5769
  };
5355
- // spotKey/contentKey stand for spot and the bubble's size.
5770
+ // The keys stand for spot, the lists, the holes and the bubble's size.
5356
5771
  // eslint-disable-next-line react-hooks/exhaustive-deps
5357
- }, [spotKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
5358
- const holes = [...(spot ? [spot] : []), ...p.holes];
5359
- const hand = spot && p.showHand && p.phase === "ready" ? handPosition(spot, viewport.height) : null;
5772
+ }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
5773
+ const popovers = p.popovers ?? [];
5774
+ const covered = !!p.covered;
5775
+ const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
5776
+ const openHoles = p.targetPressable === false ? [...popovers, ...p.holes] : holes;
5777
+ const ringShown = !!spot && p.phase === "ready" && !covered && !popovers.some(o => meets(o, spot));
5778
+ const hand = spot && p.showHand && p.phase === "ready" && !covered && !popovers.length ? handPosition(spot, viewport.height) : null;
5360
5779
  let arrowStyle;
5361
5780
  if (pos && pos.side !== "dock") {
5362
5781
  if (pos.side === "right")
@@ -5368,7 +5787,7 @@ function StepLayer(p) {
5368
5787
  else
5369
5788
  arrowStyle = { bottom: -7, left: pos.ax };
5370
5789
  }
5371
- return (jsxs(Fragment$1, { children: [jsx(Scrim, { holes: p.phase === "ready" ? holes : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && p.phase === "ready" ? (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 ? (jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsx("svg", { viewBox: "0 0 24 24", children: jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (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: {
5790
+ return (jsxs(Fragment$1, { children: [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 ? (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 ? (jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsx("svg", { viewBox: "0 0 24 24", children: jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (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: {
5372
5791
  left: pos?.left ?? 0,
5373
5792
  top: pos?.top ?? 0,
5374
5793
  width: phone ? viewport.width - 32 : undefined,
@@ -5379,7 +5798,7 @@ function BubbleBody(p) {
5379
5798
  const b = p.bubble;
5380
5799
  const title = b.error ? b.error.title : b.title;
5381
5800
  const why = b.error ? (b.error.detail ?? "") : b.why;
5382
- return (jsxs(Fragment$1, { children: [jsxs("div", { className: "eg-b-top", children: [jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsx(GuideTitle, { text: title }) }), jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: why }), jsxs("div", { className: "eg-b-f", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (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 ? (jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
5801
+ return (jsxs(Fragment$1, { children: [jsxs("div", { className: "eg-b-top", children: [jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsx(GuideTitle, { text: title }) }), jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsx(GuideTitle, { text: why }) }), jsxs("div", { className: "eg-b-f", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (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 ? (jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
5383
5802
  }
5384
5803
  /** The fingertip at ≈62% across and down the target; upside down near the bottom. */
5385
5804
  function handPosition(spot, viewportHeight) {
@@ -5492,6 +5911,9 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
5492
5911
  meta = jsx("span", { children: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E27\u0E49" });
5493
5912
  else if (v === "waiting" && gate)
5494
5913
  meta = gate.pending;
5914
+ // Locked: what to do first gets its own line under the title, so the title
5915
+ // keeps the row's width and the button stays short.
5916
+ const lockHint = v === "locked" && item.lockedBy ? `ทำ ${item.lockedBy} ก่อน` : null;
5495
5917
  let button;
5496
5918
  if (v === "done")
5497
5919
  button = jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onStart(item.key, true), children: "\u0E17\u0E33\u0E0B\u0E49\u0E33" });
@@ -5500,11 +5922,11 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
5500
5922
  else if (v === "waiting" && gate)
5501
5923
  button = (jsx("a", { className: "eg-btn eg-ghost", href: gate.href, style: { textDecoration: "none" }, children: gate.label }));
5502
5924
  else if (v === "locked")
5503
- button = (jsxs("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, children: ["\u0E17\u0E33 ", item.lockedBy, " \u0E01\u0E48\u0E2D\u0E19"] }));
5925
+ button = (jsx("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, "aria-label": lockHint ? `เริ่ม (${lockHint})` : undefined, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" }));
5504
5926
  else
5505
5927
  button = (jsx("button", { type: "button", className: "eg-btn " + (v === "next" ? "eg-primary" : "eg-ghost"), onClick: () => onStart(item.key, false), children: v === "skipped" || v === "active" ? "ทำต่อ" : "เริ่ม" }));
5506
5928
  const dismissable = item.optional && (v === "todo" || v === "next");
5507
- return (jsxs("li", { className: cls, children: [jsx("span", { className: "eg-n", children: ticked ? jsx(IconCheck, {}) : n }), jsxs("div", { className: "eg-t", children: [jsx("b", { children: item.title }), jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxs("span", { className: "eg-q-acts", children: [button, 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)] }));
5929
+ return (jsxs("li", { className: cls, children: [jsx("span", { className: "eg-n", children: ticked ? jsx(IconCheck, {}) : n }), jsxs("div", { className: "eg-t", children: [jsx("b", { children: item.title }), lockHint ? jsx("small", { className: "eg-lock", children: lockHint }) : null, jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxs("span", { className: "eg-q-acts", children: [button, 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)] }));
5508
5930
  }
5509
5931
  function GuideToast({ text }) {
5510
5932
  return (jsx("div", { className: "eg-toast", role: "status", children: text }));
@@ -5647,6 +6069,45 @@ function usePrimaryTab(name) {
5647
6069
  return primary;
5648
6070
  }
5649
6071
  /* ------------------------------------------------------------------ */
6072
+ /* Putting the page back after a replay */
6073
+ /* ------------------------------------------------------------------ */
6074
+ const ABORT_DELAY_MS = 60;
6075
+ const ABORT_GAP_MS = 150;
6076
+ /**
6077
+ * Runs a replay's abort actions in order, once the tour has let go of the
6078
+ * page: press an anchor (passed over when it is not there), emit an app
6079
+ * event, or Escape to the open dialog (Radix listens on the document).
6080
+ */
6081
+ function runAbort(actions) {
6082
+ actions.forEach((a, i) => {
6083
+ setTimeout(() => {
6084
+ try {
6085
+ if ("click" in a) {
6086
+ findAnchor(a.click)?.click();
6087
+ }
6088
+ else if ("event" in a) {
6089
+ emitGuideEvent(a.event);
6090
+ }
6091
+ else if ("escape" in a) {
6092
+ const t = document.activeElement ?? document.body;
6093
+ t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
6094
+ }
6095
+ }
6096
+ catch {
6097
+ /* never breaks the app */
6098
+ }
6099
+ }, ABORT_DELAY_MS + i * ABORT_GAP_MS);
6100
+ });
6101
+ }
6102
+ /** A step's own target takes presses: not on a "view" step, unless the step allows it. */
6103
+ function targetPressable(step, bridge) {
6104
+ if (!step || bridge)
6105
+ return true;
6106
+ if (stepKind(step) !== "view")
6107
+ return true;
6108
+ return !!step.target && (step.allow ?? []).includes(step.target);
6109
+ }
6110
+ /* ------------------------------------------------------------------ */
5650
6111
  /* Host */
5651
6112
  /* ------------------------------------------------------------------ */
5652
6113
  const TARGET_PAD = 6;
@@ -5692,6 +6153,9 @@ function GuideHostInner({ config, organizationId, code }) {
5692
6153
  /* ---------------- machine ---------------- */
5693
6154
  const [state, setState] = useState(null);
5694
6155
  const stateRef = useRef(null);
6156
+ // What the server holds (payloadKey), so a save that would change nothing
6157
+ // is not sent and does not bump its rev.
6158
+ const savedKey = useRef(null);
5695
6159
  useEffect(() => {
5696
6160
  if (!answer || answer.audience === "none" || answer.newerOnServer) {
5697
6161
  stateRef.current = null;
@@ -5700,6 +6164,7 @@ function GuideHostInner({ config, organizationId, code }) {
5700
6164
  }
5701
6165
  if (stateRef.current)
5702
6166
  return;
6167
+ savedKey.current = answer.progress && answer.progress.rev > 0 ? payloadKey(answer.progress, suite.app) : null;
5703
6168
  // Resume from whichever copy is further along: the server's, or this
5704
6169
  // tab's session copy from before a refresh (its rev is the server's).
5705
6170
  const mirror = readMirror(organizationId, suite);
@@ -5756,7 +6221,11 @@ function GuideHostInner({ config, organizationId, code }) {
5756
6221
  const cur = stateRef.current;
5757
6222
  if (!cur)
5758
6223
  return;
6224
+ if (payloadKey(cur.progress, suite.app) === savedKey.current)
6225
+ continue;
5759
6226
  const row = await saveGuides(organizationId, suite, cur.progress);
6227
+ if (row)
6228
+ savedKey.current = payloadKey(row, suite.app);
5760
6229
  if (row && stateRef.current) {
5761
6230
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
5762
6231
  dispatchRef.current({ type: "serverRow", progress: row });
@@ -5797,13 +6266,9 @@ function GuideHostInner({ config, organizationId, code }) {
5797
6266
  else
5798
6267
  router.push(e.href);
5799
6268
  }
5800
- else if (e.type === "closeAppDialog") {
5801
- // After this render takes the guard away, Escape to the app's
5802
- // dialog (Radix listens on the document).
5803
- setTimeout(() => {
5804
- const t = document.activeElement ?? document.body;
5805
- t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
5806
- }, 60);
6269
+ else if (e.type === "abort") {
6270
+ // After this render takes the guard away, put the page back.
6271
+ runAbort(e.actions);
5807
6272
  }
5808
6273
  }
5809
6274
  catch {
@@ -5933,7 +6398,9 @@ function GuideHostInner({ config, organizationId, code }) {
5933
6398
  scrolling, dialogs animating in, the drawer sliding, the keyboard. */
5934
6399
  const [layout, setLayout] = useState({
5935
6400
  spot: null,
6401
+ covered: false,
5936
6402
  holes: [],
6403
+ popovers: [],
5937
6404
  vw: 0,
5938
6405
  vh: 0,
5939
6406
  oy: 0,
@@ -5945,9 +6412,22 @@ function GuideHostInner({ config, organizationId, code }) {
5945
6412
  }
5946
6413
  let raf = 0;
5947
6414
  let lostSince = null;
5948
- let last = { spot: null, holes: [], vw: 0, vh: 0, oy: 0 };
6415
+ let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
5949
6416
  targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
5950
6417
  const allow = stepInfo?.step.allow ?? [];
6418
+ // The spotlight is the part of the target a person can see: cut by the
6419
+ // scroll boxes around it (a row of tabs that scrolls sideways) and the
6420
+ // viewport. Wholly out of view (scrolled away up or down), its own box,
6421
+ // and the tour scrolls it back.
6422
+ let clipFor = null;
6423
+ let clips = [];
6424
+ const spotOf = (el) => {
6425
+ if (el !== clipFor) {
6426
+ clipFor = el;
6427
+ clips = clipAncestors(el);
6428
+ }
6429
+ return padRect(visibleRectOf(el, clips) ?? rectOf(el), TARGET_PAD);
6430
+ };
5951
6431
  const tick = () => {
5952
6432
  raf = requestAnimationFrame(tick);
5953
6433
  const vv = window.visualViewport;
@@ -5962,14 +6442,14 @@ function GuideHostInner({ config, organizationId, code }) {
5962
6442
  let spot = null;
5963
6443
  if (target && el && isPresent(el)) {
5964
6444
  lostSince = null;
5965
- spot = padRect(rectOf(el), TARGET_PAD);
6445
+ spot = spotOf(el);
5966
6446
  }
5967
6447
  else if (target) {
5968
6448
  // Maybe re-rendered under another node.
5969
6449
  const again = findAnchor(target.anchor, target.id);
5970
6450
  if (again) {
5971
6451
  targetElRef.current = again;
5972
- spot = padRect(rectOf(again), TARGET_PAD);
6452
+ spot = spotOf(again);
5973
6453
  lostSince = null;
5974
6454
  }
5975
6455
  else if (phase === "ready") {
@@ -5982,21 +6462,25 @@ function GuideHostInner({ config, organizationId, code }) {
5982
6462
  }
5983
6463
  if (phase === "waiting" && spot)
5984
6464
  dispatchRef.current({ type: "sync" });
5985
- const holes = [];
6465
+ const popovers = [];
5986
6466
  for (const p of openPoppers())
5987
- holes.push(padRect(rectOf(p), 2));
6467
+ popovers.push(padRect(rectOf(p), 2));
6468
+ const holes = [];
5988
6469
  for (const a of allow) {
5989
6470
  const ael = findAnchor(a);
5990
6471
  if (ael)
5991
6472
  holes.push(padRect(rectOf(ael), TARGET_PAD));
5992
6473
  }
5993
- const next = { spot, holes, vw, vh, oy };
6474
+ const covered = !!spot && coveredByDialog(targetElRef.current);
6475
+ const next = { spot, covered, holes, popovers, vw, vh, oy };
6476
+ const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
5994
6477
  if (!sameRect(next.spot, last.spot) ||
6478
+ next.covered !== last.covered ||
5995
6479
  next.vw !== last.vw ||
5996
6480
  next.vh !== last.vh ||
5997
6481
  next.oy !== last.oy ||
5998
- next.holes.length !== last.holes.length ||
5999
- next.holes.some((h, i) => !sameRect(h, last.holes[i]))) {
6482
+ !sameList(next.holes, last.holes) ||
6483
+ !sameList(next.popovers, last.popovers)) {
6000
6484
  last = next;
6001
6485
  setLayout(next);
6002
6486
  }
@@ -6127,7 +6611,10 @@ function GuideHostInner({ config, organizationId, code }) {
6127
6611
  if (!(node instanceof Node))
6128
6612
  return false;
6129
6613
  const el = targetElRef.current;
6130
- if (el && el.contains(node))
6614
+ // A "view" step's target is there to be read: ถัดไป is the way on.
6615
+ const sc = stateRef.current?.screen;
6616
+ const pressable = targetPressable(stepInfoRef.current?.step, sc?.name === "step" && sc.bridge);
6617
+ if (el && pressable && el.contains(node))
6131
6618
  return true;
6132
6619
  const elNode = node instanceof Element ? node : node.parentElement;
6133
6620
  if (!elNode)
@@ -6226,7 +6713,7 @@ function GuideHostInner({ config, organizationId, code }) {
6226
6713
  const el = targetElRef.current;
6227
6714
  const step = stepInfoRef.current?.step;
6228
6715
  const isInput = !!step && !sc.bridge && stepKind(step) === "input";
6229
- const inTargetList = el ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
6716
+ const inTargetList = el && targetPressable(step, sc.bridge) ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
6230
6717
  const bubble = layer?.querySelector("[data-eg-bubble]") ?? null;
6231
6718
  list = [...inTargetList, ...(bubble ? Array.from(bubble.querySelectorAll(FOCUSABLE)) : [])];
6232
6719
  for (const p of openPoppers())
@@ -6301,19 +6788,38 @@ function GuideHostInner({ config, organizationId, code }) {
6301
6788
  useEffect(() => {
6302
6789
  const el = document.createElement("div");
6303
6790
  el.setAttribute(GUIDE_LAYER_ATTRIBUTE, "");
6791
+ // A Radix modal (dialog, select, menu) hides everything outside it from
6792
+ // screen readers with aria-hidden's hideOthers. It leaves [aria-live]
6793
+ // elements and their insides alone, but walks into their ancestors and
6794
+ // hides the siblings: with only the status line live, the bubble and its
6795
+ // buttons were hidden. "off" announces nothing; it only makes the whole
6796
+ // layer one of the kept elements.
6797
+ el.setAttribute("aria-live", "off");
6304
6798
  document.body.appendChild(el);
6305
6799
  layerRef.current = el;
6306
6800
  setLayer(el);
6307
- // A Radix modal hides everything else from screen readers; not us.
6308
- const obs = new MutationObserver(() => {
6309
- if (el.getAttribute("aria-hidden") === "true")
6310
- el.removeAttribute("aria-hidden");
6311
- if (el.hasAttribute("data-aria-hidden"))
6312
- el.removeAttribute("data-aria-hidden");
6313
- if (el.hasAttribute("inert"))
6314
- el.removeAttribute("inert");
6801
+ // And should anything still hide the layer or a part of it (another
6802
+ // library, a later hideOthers), take it off again. hideOthers marks what
6803
+ // it hides with data-aria-hidden (the layer never sets that, or inert);
6804
+ // aria-hidden goes only where it was just added, so the layer's own
6805
+ // decorative aria-hidden stays.
6806
+ const obs = new MutationObserver(records => {
6807
+ for (const r of records) {
6808
+ const n = r.target;
6809
+ if (!(n instanceof Element))
6810
+ continue;
6811
+ if (r.attributeName === "inert" && n.hasAttribute("inert"))
6812
+ n.removeAttribute("inert");
6813
+ else if (r.attributeName === "data-aria-hidden" && n.hasAttribute("data-aria-hidden"))
6814
+ n.removeAttribute("data-aria-hidden");
6815
+ else if (r.attributeName === "aria-hidden" &&
6816
+ n.getAttribute("aria-hidden") === "true" &&
6817
+ (n === el || r.oldValue === null)) {
6818
+ n.removeAttribute("aria-hidden");
6819
+ }
6820
+ }
6315
6821
  });
6316
- obs.observe(el, { attributes: true, attributeFilter: ["aria-hidden", "data-aria-hidden", "inert"] });
6822
+ obs.observe(el, { attributes: true, attributeOldValue: true, subtree: true, attributeFilter: ["aria-hidden", "data-aria-hidden", "inert"] });
6317
6823
  return () => {
6318
6824
  obs.disconnect();
6319
6825
  el.remove();
@@ -6380,7 +6886,7 @@ function GuideHostInner({ config, organizationId, code }) {
6380
6886
  const { mission, step, missionNo } = stepInfo;
6381
6887
  const kind = stepKind(step);
6382
6888
  const bridge = screen.bridge;
6383
- content = (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: {
6889
+ content = (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: {
6384
6890
  missionNo,
6385
6891
  missionTitle: mission.title,
6386
6892
  replay: screen.replay,
@@ -6391,7 +6897,7 @@ function GuideHostInner({ config, organizationId, code }) {
6391
6897
  error: screen.error,
6392
6898
  showNext: !bridge && (kind === "input" || kind === "view"),
6393
6899
  showEndWithoutSave: !bridge && screen.replay && kind === "commit",
6394
- }, 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" }) }));
6900
+ }, 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" }) }));
6395
6901
  }
6396
6902
  else if (screen.name === "celebrate" && celebrateShown) {
6397
6903
  const mission = findMission(suite, screen.mission);