@eintrek/erp-shell 0.1.76 → 0.1.78

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.
@@ -1012,6 +1012,7 @@ function initialGuideState(audience, progress, pages) {
1012
1012
  pageRun: null,
1013
1013
  panelSection: "all",
1014
1014
  autoPassed: null,
1015
+ counter: null,
1015
1016
  audience,
1016
1017
  progress,
1017
1018
  screen: { name: "idle" },
@@ -1390,7 +1391,7 @@ function announceStep(d, suite, env) {
1390
1391
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1391
1392
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
1392
1393
  const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
1393
- const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
1394
+ const pos = s.pos ?? stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
1394
1395
  d.effects.push({
1395
1396
  type: "announce",
1396
1397
  text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
@@ -1424,6 +1425,54 @@ function stepPosition(mission, index, env, context, autoPassed = []) {
1424
1425
  });
1425
1426
  return { no: Math.max(no, 1), total: Math.max(total, 1) };
1426
1427
  }
1428
+ /**
1429
+ * The steps after `index` this person will see, as far as can be known from
1430
+ * here: on this page, everything is read (requires, skipIf, a click whose
1431
+ * outcome already holds); on another page, a step whose showing depends on
1432
+ * what is on that page (`skipIf`, `onMissing: "skip"`) is not counted until
1433
+ * its page is reached.
1434
+ */
1435
+ function stepsAhead(mission, index, env, context) {
1436
+ let n = 0;
1437
+ for (let j = index + 1; j < mission.steps.length; j++) {
1438
+ const step = mission.steps[j];
1439
+ if (step.requires && !safe(() => env.can(step.requires), true))
1440
+ continue;
1441
+ const home = stepHomeRoute(mission.steps, j);
1442
+ const here = !home || !!matchRoute(home, env.pathname, env.search, env.code);
1443
+ if (here) {
1444
+ const ctx = guideContext(env, context, routeParamsFor(step, env));
1445
+ if (step.skipIf && safe(() => step.skipIf(ctx), false))
1446
+ continue;
1447
+ if (wouldAutoPass(step, env))
1448
+ continue;
1449
+ }
1450
+ else if (step.skipIf || step.onMissing === "skip") {
1451
+ continue;
1452
+ }
1453
+ n++;
1454
+ }
1455
+ return n;
1456
+ }
1457
+ /**
1458
+ * The counter as shown when entering step `index` (shell ≥ 0.1.77). `no`
1459
+ * counts the steps actually shown in this run up to this one. The total is
1460
+ * worked out on entering a step on a new page — the shown steps so far plus
1461
+ * stepsAhead — and kept while the run stays on that page; it never goes
1462
+ * down (the larger of the old and the new), and is never below `no`.
1463
+ */
1464
+ function countStep(d, env, mission, index) {
1465
+ const prev = d.state.counter?.mission === mission.key ? d.state.counter : null;
1466
+ const shown = prev ? (prev.shown.includes(index) ? prev.shown : [...prev.shown, index]) : [index];
1467
+ const no = Math.max(1, shown.filter(i => i <= index).length);
1468
+ let total;
1469
+ if (prev && prev.path === env.pathname)
1470
+ total = Math.max(prev.total, no);
1471
+ else
1472
+ total = Math.max(prev?.total ?? 0, no + stepsAhead(mission, index, env, d.state.progress.context), no);
1473
+ d.state = { ...d.state, counter: { mission: mission.key, shown, total, path: env.pathname } };
1474
+ return { no, total };
1475
+ }
1427
1476
  /** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
1428
1477
  function wouldAutoPass(step, env) {
1429
1478
  const kind = stepKind(step);
@@ -1493,6 +1542,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1493
1542
  const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
1494
1543
  const bridge = !away && needsBridge(step, env, ctx);
1495
1544
  const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
1545
+ const pos = countStep(d, env, mission, i);
1496
1546
  setScreen(d, {
1497
1547
  name: "step",
1498
1548
  mission: mission.key,
@@ -1510,6 +1560,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1510
1560
  ...(page ? { page } : {}),
1511
1561
  ...(section ? { section } : {}),
1512
1562
  ...(flow ? { flow } : {}),
1563
+ pos,
1513
1564
  });
1514
1565
  if (!opts.replay) {
1515
1566
  const cp = checkpointAtOrBefore(steps, i);
@@ -1784,7 +1835,7 @@ function startMission(d, suite, env, key, replay) {
1784
1835
  const isReplay = replay || entry?.status === "done";
1785
1836
  const before = d.state.progress;
1786
1837
  // A new run: what passed by itself is counted afresh.
1787
- d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
1838
+ d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null, counter: null };
1788
1839
  if (d.state.progress.welcome === "not_seen")
1789
1840
  withProgress(d, { welcome: "started" });
1790
1841
  if (!isReplay && d.state.progress.mission_key !== key)
@@ -1934,24 +1985,33 @@ function pageStillHere(suite, key, env) {
1934
1985
  const page = findPage(suite, key);
1935
1986
  return !!page && pageMatchScore(page, whereOf(env)) !== null;
1936
1987
  }
1937
- /** Write a page guide's row (once per page and version): recorded here at once, saved by the host. */
1988
+ /**
1989
+ * Whether a row may be written now: none of this version yet, or — once —
1990
+ * a skipped one that the person has now finished (skipped → completed,
1991
+ * shell ≥ 0.1.77). A completed row is final; skipped again changes nothing.
1992
+ */
1993
+ function rowWritable(row, version, status) {
1994
+ if (!row || row.version < version)
1995
+ return true;
1996
+ return row.version === version && row.status === "skipped" && status === "completed";
1997
+ }
1998
+ /** Whether a run should keep its row to write at its end: no row yet, or only a skipped one. */
1999
+ function rowStillDue(row, version) {
2000
+ return rowWritable(row, version, "completed");
2001
+ }
2002
+ /** Write a page guide's row (once per page and version; skipped → completed once): recorded here at once, saved by the host. */
1938
2003
  function recordPage(d, suite, key, status) {
1939
2004
  const page = findPage(suite, key);
1940
- if (!page || pageRecorded(d.state, page))
2005
+ if (!page || !rowWritable(d.state.pages[key], page.version, status))
1941
2006
  return;
1942
2007
  const prev = d.state.pages[key];
1943
2008
  d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
1944
2009
  d.effects.push({ type: "savePage", key, version: page.version, status });
1945
2010
  }
1946
- /** The section guide's row says this person has ended this version of it. */
1947
- function sectionRecorded(state, section) {
1948
- const row = state.sections?.[section.key];
1949
- return !!row && row.version >= section.version;
1950
- }
1951
2011
  /** Write a section guide's row (once per section and version). */
1952
2012
  function recordSection(d, suite, key, status) {
1953
2013
  const section = findSection(suite, key);
1954
- if (!section || sectionRecorded(d.state, section))
2014
+ if (!section || !rowWritable(d.state.sections?.[key], section.version, status))
1955
2015
  return;
1956
2016
  const prev = d.state.sections?.[key];
1957
2017
  d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
@@ -1982,7 +2042,7 @@ function flowRecorded(state, flow) {
1982
2042
  /** Write a flow's row (once per flow and version). */
1983
2043
  function recordFlow(d, suite, key, status) {
1984
2044
  const flow = findFlow(suite, key);
1985
- if (!flow || flowRecorded(d.state, flow))
2045
+ if (!flow || !rowWritable(d.state.flows?.[key], flow.version, status))
1986
2046
  return;
1987
2047
  const prev = d.state.flows?.[key];
1988
2048
  d.state = { ...d.state, flows: { ...d.state.flows, [key]: { version: flow.version, status, rev: prev?.rev ?? 0 } } };
@@ -2067,6 +2127,7 @@ function startFlow(d, suite, env, key, write, at) {
2067
2127
  justDone: null,
2068
2128
  pageRun: { key, write, kind: "flow", context: saved },
2069
2129
  autoPassed: null,
2130
+ counter: null,
2070
2131
  replayPage: {
2071
2132
  mission: mission.key,
2072
2133
  anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
@@ -2097,6 +2158,7 @@ function startSection(d, suite, env, key, write) {
2097
2158
  justDone: null,
2098
2159
  pageRun: { key, write, kind: "section" },
2099
2160
  autoPassed: null,
2161
+ counter: null,
2100
2162
  replayPage: {
2101
2163
  mission: mission.key,
2102
2164
  anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
@@ -2116,6 +2178,7 @@ function startPage(d, suite, env, key, write) {
2116
2178
  justDone: null,
2117
2179
  pageRun: { key, write, kind: "page" },
2118
2180
  autoPassed: null,
2181
+ counter: null,
2119
2182
  // What is open now is the person's: ending the guide leaves it alone.
2120
2183
  replayPage: {
2121
2184
  mission: mission.key,
@@ -2159,6 +2222,28 @@ function backTarget(mission, index, env, context) {
2159
2222
  }
2160
2223
  return prev >= 0 ? { index: prev, close: false } : null;
2161
2224
  }
2225
+ /**
2226
+ * A flow's next step (the next one this person sees) when the person is on
2227
+ * its page and no longer on the current step's: they moved on by
2228
+ * themselves. -1 otherwise (still on this step's page, or elsewhere).
2229
+ */
2230
+ function flowStepArrivedAt(mission, index, env, context) {
2231
+ const here = (route) => !!route && !!matchRoute(route, env.pathname, env.search, env.code);
2232
+ const curHome = stepHomeRoute(mission.steps, index);
2233
+ if (!curHome || here(curHome))
2234
+ return -1;
2235
+ for (let j = index + 1; j < mission.steps.length; j++) {
2236
+ const st = mission.steps[j];
2237
+ const ctx = guideContext(env, context, routeParamsFor(st, env));
2238
+ if (st.requires && !safe(() => env.can(st.requires), true))
2239
+ continue;
2240
+ if (st.skipIf && safe(() => st.skipIf(ctx), false))
2241
+ continue;
2242
+ const where = st.route ?? stepHomeRoute(mission.steps, j);
2243
+ return here(where) ? j : -1;
2244
+ }
2245
+ return -1;
2246
+ }
2162
2247
  function reduceGuide(state, action, suite, env) {
2163
2248
  const d = { state, effects: [] };
2164
2249
  const s = state.screen;
@@ -2236,7 +2321,7 @@ function reduceGuide(state, action, suite, env) {
2236
2321
  const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
2237
2322
  const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
2238
2323
  const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
2239
- d.state = { ...d.state, autoPassed: null };
2324
+ d.state = { ...d.state, autoPassed: null, counter: null };
2240
2325
  enterStep(d, suite, env, mission, index, { replay: false });
2241
2326
  break;
2242
2327
  }
@@ -2279,6 +2364,15 @@ function reduceGuide(state, action, suite, env) {
2279
2364
  break;
2280
2365
  const step = mission.steps[s.index];
2281
2366
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
2367
+ if (s.flow && !s.confirmExit) {
2368
+ // Within the chain, the person went on to the next step's page by
2369
+ // themselves: carry on there, no "ทำต่อ" (shell ≥ 0.1.77).
2370
+ const next = flowStepArrivedAt(mission, s.index, env, d.state.progress.context);
2371
+ if (next >= 0) {
2372
+ enterStep(d, suite, env, mission, next, { replay: s.replay });
2373
+ break;
2374
+ }
2375
+ }
2282
2376
  if (s.phase === "away") {
2283
2377
  const where = placeRoute(mission.steps, s.index);
2284
2378
  if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
@@ -2673,7 +2767,7 @@ function reduceGuide(state, action, suite, env) {
2673
2767
  if (!flowsAvailable(suite, env).includes(action.flow))
2674
2768
  break;
2675
2769
  const flow = findFlow(suite, action.flow);
2676
- startFlow(d, suite, env, flow.key, !flowRecorded(state, flow));
2770
+ startFlow(d, suite, env, flow.key, rowStillDue(state.flows?.[flow.key], flow.version));
2677
2771
  break;
2678
2772
  }
2679
2773
  case "resumeFlow": {
@@ -2682,7 +2776,7 @@ function reduceGuide(state, action, suite, env) {
2682
2776
  const flow = findFlow(suite, action.flow);
2683
2777
  if (!flow || !flowShown(flow, guideContext(env, {})) || !inFlowChain(flow, whereOf(env)))
2684
2778
  break;
2685
- startFlow(d, suite, env, flow.key, action.write && !flowRecorded(state, flow), { step: action.step, context: action.context });
2779
+ startFlow(d, suite, env, flow.key, action.write && rowStillDue(state.flows?.[flow.key], flow.version), { step: action.step, context: action.context });
2686
2780
  break;
2687
2781
  }
2688
2782
  case "startPage": {
@@ -2694,7 +2788,7 @@ function reduceGuide(state, action, suite, env) {
2694
2788
  const page = pageHere(suite, env);
2695
2789
  if (!page || page.key !== action.page)
2696
2790
  break;
2697
- startPage(d, suite, env, page.key, !pageRecorded(state, page));
2791
+ startPage(d, suite, env, page.key, rowStillDue(state.pages[page.key], page.version));
2698
2792
  break;
2699
2793
  }
2700
2794
  case "back": {
@@ -2733,6 +2827,37 @@ function reduceGuide(state, action, suite, env) {
2733
2827
  d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
2734
2828
  break;
2735
2829
  }
2830
+ case "yield": {
2831
+ const run = d.state.pageRun;
2832
+ if (run?.kind === "flow" && run.context)
2833
+ withProgress(d, { context: run.context });
2834
+ d.state = { ...d.state, pageRun: null, replayPage: null, panelOpen: false, justDone: null, pendingCelebrate: [] };
2835
+ setScreen(d, { name: "idle" });
2836
+ break;
2837
+ }
2838
+ case "refresh": {
2839
+ const p = action.progress;
2840
+ const local = d.state.progress;
2841
+ // The server's row when it is this version and not behind this tab's.
2842
+ if (p.guide_version === local.guide_version && p.rev >= local.rev)
2843
+ d.state = { ...d.state, progress: p };
2844
+ const newer = (server, mine) => {
2845
+ const out = { ...(mine ?? {}) };
2846
+ for (const [k, r] of Object.entries(server ?? {})) {
2847
+ const m = out[k];
2848
+ if (!m || r.version > m.version || (r.version === m.version && r.rev >= m.rev))
2849
+ out[k] = r;
2850
+ }
2851
+ return out;
2852
+ };
2853
+ d.state = {
2854
+ ...d.state,
2855
+ pages: newer(action.pages, d.state.pages),
2856
+ sections: newer(action.sections, d.state.sections),
2857
+ flows: newer(action.flows, d.state.flows),
2858
+ };
2859
+ break;
2860
+ }
2736
2861
  case "startSection": {
2737
2862
  // Never offered: only on request. Never over a mission step, a
2738
2863
  // welcome, a gate or a celebration; only on screen, only theirs.
@@ -2743,7 +2868,7 @@ function reduceGuide(state, action, suite, env) {
2743
2868
  if (!sectionsAvailable(suite, env).includes(action.section))
2744
2869
  break;
2745
2870
  const section = findSection(suite, action.section);
2746
- startSection(d, suite, env, section.key, !sectionRecorded(state, section));
2871
+ startSection(d, suite, env, section.key, rowStillDue(state.sections?.[section.key], section.version));
2747
2872
  break;
2748
2873
  }
2749
2874
  case "idleCelebrate": {
@@ -3102,9 +3227,9 @@ function conflictOf(err) {
3102
3227
  return null;
3103
3228
  }
3104
3229
  /** The GET, parsed for this suite; null (show nothing) on any failure or odd answer. */
3105
- async function loadGuides(adapter, suite, signal) {
3230
+ async function loadGuides(adapter, suite, signal, fresh = false) {
3106
3231
  try {
3107
- const raw = await adapter.fetchProgress({ app: suite.app, guideKey: suite.key, signal });
3232
+ const raw = await adapter.fetchProgress({ app: suite.app, guideKey: suite.key, signal, ...(fresh ? { fresh: true } : {}) });
3108
3233
  return parseGuidesAnswer(raw, suite);
3109
3234
  }
3110
3235
  catch {
@@ -3189,7 +3314,10 @@ async function storePageRow(adapter, app, key, version, status, rev, kind = "pag
3189
3314
  }
3190
3315
  if (current.version > version)
3191
3316
  return null;
3192
- if (current.version === version && current.status !== "in_progress")
3317
+ // This version already recorded: kept, except a skipped row this run
3318
+ // finished (skipped → completed, once, shell ≥ 0.1.77).
3319
+ const finishing = status === "completed" && current.status === "skipped";
3320
+ if (current.version === version && current.status !== "in_progress" && !finishing)
3193
3321
  return current;
3194
3322
  at = current.rev;
3195
3323
  }
@@ -3856,6 +3984,9 @@ function useNudge(nudge, ref) {
3856
3984
  }
3857
3985
  function StepLayer(p) {
3858
3986
  const { spot, phone, viewport } = p;
3987
+ // Where the bubble may sit: the content column when the app has one.
3988
+ const col = p.bounds && p.bounds.width > 0 ? p.bounds : { x: 0, width: viewport.width || (typeof window !== "undefined" ? window.innerWidth : 0)};
3989
+ const colKey = `${col.x}|${col.width}`;
3859
3990
  const bubbleRef = React.useRef(null);
3860
3991
  const arrowRef = React.useRef(null);
3861
3992
  const ringRef = React.useRef(null);
@@ -3896,8 +4027,8 @@ function StepLayer(p) {
3896
4027
  const bw = el.offsetWidth;
3897
4028
  const bh = el.offsetHeight;
3898
4029
  setPos(phone
3899
- ? { left: m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
3900
- : { left: (window.innerWidth - bw) / 2, top: viewport.offsetTop + (viewport.height - bh) / 2, side: "dock" });
4030
+ ? { left: col.x + m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
4031
+ : { left: col.x + (col.width - bw) / 2, top: viewport.offsetTop + (viewport.height - bh) / 2, side: "dock" });
3901
4032
  return;
3902
4033
  }
3903
4034
  if (phone) {
@@ -3918,7 +4049,7 @@ function StepLayer(p) {
3918
4049
  : overlapBot <= overlapTop
3919
4050
  ? botT
3920
4051
  : topT;
3921
- setPos({ left: m, top, side: "dock" });
4052
+ setPos({ left: col.x + m, top, side: "dock" });
3922
4053
  return;
3923
4054
  }
3924
4055
  let cancelled = false;
@@ -3940,7 +4071,7 @@ function StepLayer(p) {
3940
4071
  placement,
3941
4072
  middleware: [
3942
4073
  dom.offset(placement.startsWith("bottom") ? gap : 14),
3943
- dom.shift({ padding: m }),
4074
+ dom.shift({ padding: m, rootBoundary: { x: col.x, y: 0, width: col.width, height: window.innerHeight } }),
3944
4075
  ...(arrowRef.current ? [dom.arrow({ element: arrowRef.current, padding: 14 })] : []),
3945
4076
  ],
3946
4077
  });
@@ -3950,11 +4081,11 @@ function StepLayer(p) {
3950
4081
  return;
3951
4082
  const bw = el.offsetWidth;
3952
4083
  const bh = el.offsetHeight;
3953
- const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
4084
+ const bounds = { x: col.x + m, y: viewport.offsetTop + m, width: col.width - 2 * m, height: viewport.height - 2 * m };
3954
4085
  const i = pickPlacement(results, bw, bh, a, clear, bounds);
3955
4086
  if (i < 0) {
3956
4087
  // Nowhere beside it: the bottom-right corner.
3957
- setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
4088
+ setPos({ left: col.x + col.width - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
3958
4089
  return;
3959
4090
  }
3960
4091
  const { x, y, placement, middlewareData } = results[i];
@@ -3970,7 +4101,7 @@ function StepLayer(p) {
3970
4101
  };
3971
4102
  // The keys stand for spot, the lists, the holes and the bubble's size.
3972
4103
  // eslint-disable-next-line react-hooks/exhaustive-deps
3973
- }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
4104
+ }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop, colKey]);
3974
4105
  const popovers = p.popovers ?? [];
3975
4106
  const covered = !!p.covered;
3976
4107
  const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
@@ -3991,7 +4122,7 @@ function StepLayer(p) {
3991
4122
  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: {
3992
4123
  left: pos?.left ?? 0,
3993
4124
  top: pos?.top ?? 0,
3994
- width: phone ? viewport.width - 32 : undefined,
4125
+ width: phone ? col.width - 32 : col.width < 352 ? col.width - 32 : undefined,
3995
4126
  visibility: pos ? "visible" : "hidden",
3996
4127
  }, children: [jsxRuntime.jsx(BubbleBody, { ...p }), jsxRuntime.jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxRuntime.jsxs("div", { className: "eg-loading", children: [jsxRuntime.jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsxRuntime.jsx("div", { className: "eg-row", children: jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }) })] })) : null, p.phase === "missing" ? (jsxRuntime.jsxs("div", { className: "eg-card eg-err", role: "alertdialog", "aria-labelledby": "eg-missing-t", "data-eg-card": "", tabIndex: -1, children: [jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-missing-t", children: "\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E2A\u0E48\u0E27\u0E19\u0E19\u0E35\u0E49\u0E1A\u0E19\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onRetry, children: "\u0E25\u0E2D\u0E07\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07" })] })] })) : null, p.phase === "away" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "dialog", "aria-modal": "false", "aria-labelledby": "eg-away-t", "data-eg-card": "", tabIndex: -1, children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsx("span", { className: "eg-mis", children: headingOf(p.bubble) }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", p.bubble.index + 1, "/", p.bubble.total] })] }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-away-t", style: { marginTop: 8 }, children: jsxRuntime.jsx(GuideTitle, { text: p.bubble.title }) }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onGoResume, children: "\u0E17\u0E33\u0E15\u0E48\u0E2D" })] })] })) : null, p.confirmExit ? (jsxRuntime.jsx(ConfirmModal, { title: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49?", line: p.reopenHint || REOPEN_HINT, leaveLabel: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C", stayLabel: "\u0E17\u0E33\u0E15\u0E48\u0E2D", onLeave: p.onExitConfirm, onStay: p.onExitCancel })) : null] }));
3997
4128
  }
@@ -4297,36 +4428,81 @@ const flowMirrorKey = (orgId, suite) => `erp-guide-flow-run:${orgId}:${suite.app
4297
4428
  /* One tab at a time */
4298
4429
  /* ------------------------------------------------------------------ */
4299
4430
  /**
4300
- * Whether this tab runs the tour. With two tabs open, the first holds a Web
4301
- * Lock and the second shows nothing until the first goes away.
4431
+ * Whether this tab runs the tour: one tab at a time holds a Web Lock and
4432
+ * runs steps, offers, the welcome and saves. Every other tab still reads
4433
+ * progress and shows the "?" menu (shell ≥ 0.1.78); pressing one of its
4434
+ * guide items calls `takeover`, which steals the lock. The tab it was
4435
+ * stolen from goes back to waiting its turn (and yields what it was running).
4436
+ * Without Web Locks every tab runs, as before.
4302
4437
  */
4303
4438
  function usePrimaryTab(name) {
4304
4439
  const [primary, setPrimary] = React.useState(false);
4440
+ const primaryRef = React.useRef(false);
4441
+ const takeoverRef = React.useRef(() => Promise.resolve());
4305
4442
  React.useEffect(() => {
4306
4443
  const locks = (typeof navigator !== "undefined" ? navigator.locks : undefined);
4444
+ const set = (v) => {
4445
+ primaryRef.current = v;
4446
+ setPrimary(v);
4447
+ };
4307
4448
  if (!locks?.request) {
4308
- setPrimary(true);
4449
+ set(true);
4450
+ takeoverRef.current = () => Promise.resolve();
4309
4451
  return;
4310
4452
  }
4311
- const abort = new AbortController();
4453
+ let alive = true;
4454
+ let gen = 0;
4455
+ let pending = null;
4312
4456
  let release = null;
4313
- locks
4314
- .request(name, { signal: abort.signal }, () => {
4315
- setPrimary(true);
4316
- return new Promise(resolve => {
4317
- release = resolve;
4457
+ let waiters = [];
4458
+ const queue = (steal) => {
4459
+ const my = ++gen;
4460
+ const ac = new AbortController();
4461
+ pending = steal ? null : ac;
4462
+ locks
4463
+ .request(name, steal ? { steal: true } : { signal: ac.signal }, () => {
4464
+ if (!alive || my !== gen)
4465
+ return undefined;
4466
+ pending = null;
4467
+ set(true);
4468
+ const w = waiters;
4469
+ waiters = [];
4470
+ w.forEach(f => f());
4471
+ return new Promise(resolve => {
4472
+ release = resolve;
4473
+ });
4474
+ })
4475
+ .catch(() => {
4476
+ // Stolen by another tab (or this request aborted): not the runner;
4477
+ // wait for the lock again.
4478
+ if (!alive || my !== gen)
4479
+ return;
4480
+ release = null;
4481
+ set(false);
4482
+ queue(false);
4318
4483
  });
4319
- })
4320
- .catch(() => {
4321
- /* aborted on unmount */
4484
+ };
4485
+ queue(false);
4486
+ takeoverRef.current = () => new Promise(resolve => {
4487
+ if (primaryRef.current) {
4488
+ resolve();
4489
+ return;
4490
+ }
4491
+ waiters.push(resolve);
4492
+ pending?.abort();
4493
+ queue(true);
4322
4494
  });
4323
4495
  return () => {
4324
- abort.abort();
4496
+ alive = false;
4497
+ pending?.abort();
4325
4498
  release?.();
4326
- setPrimary(false);
4499
+ waiters.forEach(f => f());
4500
+ waiters = [];
4501
+ set(false);
4327
4502
  };
4328
4503
  }, [name]);
4329
- return primary;
4504
+ const takeover = React.useCallback(() => takeoverRef.current(), []);
4505
+ return { primary, takeover };
4330
4506
  }
4331
4507
  /* ------------------------------------------------------------------ */
4332
4508
  /* Putting the page back after a replay */
@@ -4359,6 +4535,42 @@ function runAbort(actions) {
4359
4535
  }, ABORT_DELAY_MS + i * ABORT_GAP_MS);
4360
4536
  });
4361
4537
  }
4538
+ /** The app's content column (GuideConfig.contentRoot), cut to the viewport; null when none is declared or found. */
4539
+ function contentColumn(selector, vw) {
4540
+ if (!selector || typeof document === "undefined")
4541
+ return null;
4542
+ let el = null;
4543
+ try {
4544
+ el = document.querySelector(selector);
4545
+ }
4546
+ catch {
4547
+ return null;
4548
+ }
4549
+ if (!el)
4550
+ return null;
4551
+ const r = el.getBoundingClientRect();
4552
+ const x = Math.max(0, r.left);
4553
+ const right = Math.min(vw || window.innerWidth, r.right);
4554
+ if (right - x <= 0)
4555
+ return null;
4556
+ return { x, y: 0, width: right - x, height: 0 };
4557
+ }
4558
+ /** The control a form's implicit submit (Enter in a field) presses: its first submit button. */
4559
+ function defaultSubmitControl(form) {
4560
+ for (const el of Array.from(form.elements)) {
4561
+ if (el instanceof HTMLButtonElement && (el.type === "submit" || !el.getAttribute("type")))
4562
+ return el;
4563
+ if (el instanceof HTMLInputElement && (el.type === "submit" || el.type === "image"))
4564
+ return el;
4565
+ }
4566
+ return null;
4567
+ }
4568
+ /** A field where Enter submits its form (not a textarea, a button or a select). */
4569
+ function submitsOnEnter(el) {
4570
+ if (!(el instanceof HTMLInputElement))
4571
+ return false;
4572
+ return !["button", "submit", "reset", "image", "file", "hidden"].includes(el.type);
4573
+ }
4362
4574
  /** A step's own target takes presses: not on a "view" step, unless the step allows it. */
4363
4575
  function targetPressable(step, bridge) {
4364
4576
  if (!step || bridge)
@@ -4399,7 +4611,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4399
4611
  busyRef.current = busy;
4400
4612
  // No menu: never a menu step (the machine sees it as always open).
4401
4613
  const sidebarOpen = menu ? !!menu.open : true;
4402
- const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
4614
+ const { primary, takeover } = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
4615
+ const primaryRef = React.useRef(primary);
4616
+ primaryRef.current = primary;
4403
4617
  // Progress: read once this tab runs the tour and the app is ready (for
4404
4618
  // the missions, or for the page guides alone: config.pagesReady).
4405
4619
  const [answer, setAnswer] = React.useState(null);
@@ -4408,7 +4622,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4408
4622
  const hasPages = React.useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0 || validFlows(suite).length > 0, [suite]);
4409
4623
  const hasFlows = React.useMemo(() => validFlows(suite).length > 0, [suite]);
4410
4624
  const hasSections = React.useMemo(() => validSections(suite).length > 0, [suite]);
4411
- const enabled = primary && (missionsReady || (hasPages && pagesReady));
4625
+ // Every tab reads progress (0.1.78): the "?" menu is shown in all of them.
4626
+ const enabled = missionsReady || (hasPages && pagesReady);
4412
4627
  const loaded = React.useRef(false);
4413
4628
  React.useEffect(() => {
4414
4629
  if (!enabled || loaded.current)
@@ -4432,8 +4647,12 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4432
4647
  }, [enabled, suite]);
4433
4648
  // The missions run for their audience; the page guides for theirs (every
4434
4649
  // member while pages are on), each with its own ready.
4435
- const missionsLive = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && missionsReady && !hidden;
4436
- const pagesLive = !!answer && hasPages && answer.pageAudience !== "none" && primary && pagesReady && !hidden;
4650
+ // On: this person has them here (every tab, for the "?" menu). Live: this
4651
+ // tab runs them (the primary tab only): steps, offers, the welcome, saves.
4652
+ const missionsOn = !!answer && answer.audience !== "none" && !answer.newerOnServer && missionsReady && !hidden;
4653
+ const pagesOn = !!answer && hasPages && answer.pageAudience !== "none" && pagesReady && !hidden;
4654
+ const missionsLive = missionsOn && primary;
4655
+ const pagesLive = pagesOn && primary;
4437
4656
  const live = missionsLive || pagesLive;
4438
4657
  /* ---------------- machine ---------------- */
4439
4658
  const [state, setState] = React.useState(null);
@@ -4526,6 +4745,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4526
4745
  const row = await storeGuides(adapterRef.current, suite, cur.progress);
4527
4746
  if (row)
4528
4747
  savedKey.current = payloadKey(row, suite.app);
4748
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
4749
+ if (row)
4750
+ announceSaved();
4529
4751
  if (row && stateRef.current) {
4530
4752
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
4531
4753
  dispatchRef.current({ type: "serverRow", progress: row });
@@ -4582,6 +4804,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4582
4804
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
4583
4805
  if (row)
4584
4806
  dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
4807
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
4808
+ if (row)
4809
+ announceSaved();
4585
4810
  });
4586
4811
  }
4587
4812
  }
@@ -4628,6 +4853,95 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4628
4853
  clearTimeout(toastTimer.current);
4629
4854
  };
4630
4855
  }, [flushSave]);
4856
+ /* ---------------- other tabs (0.1.78) ---------------- */
4857
+ // Progress saved in one tab reaches the others: a message on a channel
4858
+ // per organisation and app, and a fresh read when a tab comes back into
4859
+ // view. Only the server's rows are read, never another tab's state.
4860
+ const channelRef = React.useRef(null);
4861
+ const refreshNow = React.useCallback(async () => {
4862
+ const a = await loadGuides(adapterRef.current, suite, undefined, true);
4863
+ const cur = stateRef.current;
4864
+ if (!a || !cur)
4865
+ return;
4866
+ // The runner's own state wins while it is running something.
4867
+ if (primaryRef.current && (cur.screen.name !== "idle" || saving.current))
4868
+ return;
4869
+ if (a.progress.guide_version === cur.progress.guide_version && a.progress.rev >= cur.progress.rev) {
4870
+ savedKey.current = a.progress.rev > 0 ? payloadKey(a.progress, suite.app) : null;
4871
+ }
4872
+ dispatchRef.current({ type: "refresh", progress: a.progress, pages: a.pages, sections: a.sections, flows: a.flows });
4873
+ }, [suite]);
4874
+ const refreshRef = React.useRef(refreshNow);
4875
+ refreshRef.current = refreshNow;
4876
+ React.useEffect(() => {
4877
+ if (typeof BroadcastChannel === "undefined")
4878
+ return;
4879
+ let ch;
4880
+ try {
4881
+ ch = new BroadcastChannel(`erp-guide:${organizationId}:${suite.app}`);
4882
+ }
4883
+ catch {
4884
+ return;
4885
+ }
4886
+ channelRef.current = ch;
4887
+ ch.onmessage = (e) => {
4888
+ if (e.data?.type === "saved")
4889
+ void refreshRef.current();
4890
+ };
4891
+ return () => {
4892
+ channelRef.current = null;
4893
+ ch.close();
4894
+ };
4895
+ }, [organizationId, suite.app]);
4896
+ React.useEffect(() => {
4897
+ const onVisible = () => {
4898
+ if (document.visibilityState === "visible" && stateRef.current)
4899
+ void refreshRef.current();
4900
+ };
4901
+ document.addEventListener("visibilitychange", onVisible);
4902
+ return () => document.removeEventListener("visibilitychange", onVisible);
4903
+ }, []);
4904
+ function announceSaved() {
4905
+ try {
4906
+ channelRef.current?.postMessage({ type: "saved" });
4907
+ }
4908
+ catch {
4909
+ /* a closed channel: the next visibility change reads again */
4910
+ }
4911
+ }
4912
+ // The tab that lost the lock stops cleanly: whatever it was running ends
4913
+ // silently, without writing.
4914
+ React.useEffect(() => {
4915
+ if (primary || !stateRef.current)
4916
+ return;
4917
+ const cur = stateRef.current;
4918
+ if (saveTimer.current) {
4919
+ clearTimeout(saveTimer.current);
4920
+ saveTimer.current = null;
4921
+ }
4922
+ if (cur.screen.name !== "idle" || cur.panelOpen)
4923
+ dispatchRef.current({ type: "yield" });
4924
+ }, [primary]);
4925
+ /**
4926
+ * A guide item pressed in this tab: run it here. In a tab that is not the
4927
+ * runner, take the lock first (the other tab stops), read progress fresh,
4928
+ * then do it. Nothing resumes by itself in a tab taken over this way.
4929
+ */
4930
+ const viaRunner = React.useCallback((fn) => {
4931
+ if (primaryRef.current) {
4932
+ fn();
4933
+ return;
4934
+ }
4935
+ resumed.current = true;
4936
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
4937
+ flowResumed.current = true;
4938
+ void takeover()
4939
+ .then(() => refreshRef.current().catch(() => undefined))
4940
+ .then(() => {
4941
+ if (primaryRef.current)
4942
+ fn();
4943
+ });
4944
+ }, [takeover]);
4631
4945
  /* ---------------- boot: resume, welcome ---------------- */
4632
4946
  const screen = state?.screen ?? { name: "idle" };
4633
4947
  const resumed = React.useRef(false);
@@ -4737,6 +5051,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4737
5051
  const stepId = screen.name === "step" ? `${screen.mission}:${screen.index}:${screen.bridge ? "b" : ""}${screen.nothingToSave ? "n" : ""}` : "";
4738
5052
  /* Measure every frame while a step is shown: the spotlight follows
4739
5053
  scrolling, dialogs animating in, the drawer sliding, the keyboard. */
5054
+ const contentRoot = config.contentRoot;
4740
5055
  const [layout, setLayout] = React.useState({
4741
5056
  spot: null,
4742
5057
  covered: false,
@@ -4745,6 +5060,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4745
5060
  vw: 0,
4746
5061
  vh: 0,
4747
5062
  oy: 0,
5063
+ col: null,
4748
5064
  });
4749
5065
  React.useEffect(() => {
4750
5066
  if (!inStep) {
@@ -4753,7 +5069,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4753
5069
  }
4754
5070
  let raf = 0;
4755
5071
  let lostSince = null;
4756
- let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
5072
+ let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0, col: null };
4757
5073
  targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
4758
5074
  const allow = stepInfo?.step.allow ?? [];
4759
5075
  // The spotlight is the part of the target a person can see: cut by the
@@ -4813,13 +5129,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4813
5129
  holes.push(padRect(rectOf(ael), TARGET_PAD));
4814
5130
  }
4815
5131
  const covered = !!spot && coveredByDialog(targetElRef.current);
4816
- const next = { spot, covered, holes, popovers, vw, vh, oy };
5132
+ const col = contentColumn(contentRoot, vw);
5133
+ const next = { spot, covered, holes, popovers, vw, vh, oy, col };
4817
5134
  const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
4818
5135
  if (!sameRect(next.spot, last.spot) ||
4819
5136
  next.covered !== last.covered ||
4820
5137
  next.vw !== last.vw ||
4821
5138
  next.vh !== last.vh ||
4822
5139
  next.oy !== last.oy ||
5140
+ !sameRect(next.col, last.col) ||
4823
5141
  !sameList(next.holes, last.holes) ||
4824
5142
  !sameList(next.popovers, last.popovers)) {
4825
5143
  last = next;
@@ -4974,6 +5292,43 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4974
5292
  }
4975
5293
  return false;
4976
5294
  };
5295
+ /**
5296
+ * Whether a form may be submitted during this step (shell ≥ 0.1.77): only
5297
+ * when its submit control — the button pressed, or the one Enter in a
5298
+ * field would press — is the step's pressable target or in its `allow`.
5299
+ * Otherwise Enter in an allowed field would save a draft (and take a
5300
+ * document number) behind the guide's back.
5301
+ */
5302
+ const submitAllowed = (form, submitter) => {
5303
+ const btn = submitter ?? defaultSubmitControl(form);
5304
+ if (!btn)
5305
+ return false;
5306
+ const el = targetElRef.current;
5307
+ const sc = stateRef.current?.screen;
5308
+ const pressable = targetPressable(stepInfoRef.current?.step, sc?.name === "step" && sc.bridge);
5309
+ if (el && pressable && el.contains(btn))
5310
+ return true;
5311
+ for (const a of stepInfoRef.current?.step.allow ?? []) {
5312
+ const ael = findAnchor(a);
5313
+ if (ael && ael.contains(btn))
5314
+ return true;
5315
+ }
5316
+ return false;
5317
+ };
5318
+ const onSubmit = (e) => {
5319
+ const sc = stateRef.current?.screen;
5320
+ if (!guardingRef.current || sc?.name !== "step")
5321
+ return;
5322
+ const form = e.target;
5323
+ if (!(form instanceof HTMLFormElement) || inGuideLayer(form))
5324
+ return;
5325
+ const submitter = e.submitter ?? null;
5326
+ if (submitAllowed(form, submitter))
5327
+ return;
5328
+ e.preventDefault();
5329
+ e.stopPropagation();
5330
+ dispatchRef.current({ type: "blocked" });
5331
+ };
4977
5332
  const onPointer = (e) => {
4978
5333
  const t = e.target;
4979
5334
  if (inGuideLayer(t)) {
@@ -5093,8 +5448,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5093
5448
  return;
5094
5449
  }
5095
5450
  }
5096
- if (modalRef.current || !allowedTarget(active))
5451
+ if (modalRef.current || !allowedTarget(active)) {
5097
5452
  stop();
5453
+ return;
5454
+ }
5455
+ // Enter in a field the step lets the person type in: no implicit
5456
+ // submit unless the form's submit control is allowed now.
5457
+ if (e.key === "Enter" && sc.name === "step" && submitsOnEnter(active) && active.form && !submitAllowed(active.form, null)) {
5458
+ e.preventDefault();
5459
+ }
5098
5460
  }
5099
5461
  };
5100
5462
  window.addEventListener("pointerdown", onPointer, true);
@@ -5104,7 +5466,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5104
5466
  window.addEventListener("focusin", onFocusIn, true);
5105
5467
  window.addEventListener("focusout", onFocusOut, true);
5106
5468
  window.addEventListener("keydown", onKey, true);
5469
+ window.addEventListener("submit", onSubmit, true);
5107
5470
  return () => {
5471
+ window.removeEventListener("submit", onSubmit, true);
5108
5472
  window.removeEventListener("pointerdown", onPointer, true);
5109
5473
  window.removeEventListener("mousedown", onPointer, true);
5110
5474
  window.removeEventListener("touchstart", onPointer, true);
@@ -5226,12 +5590,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5226
5590
  !due;
5227
5591
  useAfterIdle(passiveDue, PASSIVE_IDLE_MS, busy, () => dispatchRef.current({ type: "idleCelebrate" }), [state?.pendingCelebrate?.join(",")]);
5228
5592
  React.useEffect(() => {
5229
- if (!missionsLive || !state) {
5593
+ // Every tab shows "ไกด์เริ่มต้น n/N"; pressing it runs it in this tab.
5594
+ if (!missionsOn || !state) {
5230
5595
  publishGuideEntry(null, null);
5231
5596
  return;
5232
5597
  }
5233
- publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
5234
- }, [missionsLive, state, done, total]);
5598
+ publishGuideEntry({ done, total }, () => viaRunner(() => dispatchRef.current({ type: "openPanel" })));
5599
+ }, [missionsOn, state, done, total, viaRunner]);
5235
5600
  React.useEffect(() => () => publishGuideEntry(null, null), []);
5236
5601
  /* ---------------- page guides: this page's, the offer, the list ---------------- */
5237
5602
  // `when` anchors (LIFF's sub-screens) come and go without a route change:
@@ -5239,7 +5604,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5239
5604
  const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
5240
5605
  const [domTick, setDomTick] = React.useState(0);
5241
5606
  React.useEffect(() => {
5242
- if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
5607
+ if (!pagesOn || !hasWhen || typeof MutationObserver === "undefined")
5243
5608
  return;
5244
5609
  let t = null;
5245
5610
  const obs = new MutationObserver(records => {
@@ -5263,18 +5628,18 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5263
5628
  if (t)
5264
5629
  clearTimeout(t);
5265
5630
  };
5266
- }, [pagesLive, hasWhen]);
5631
+ }, [pagesOn, hasWhen]);
5267
5632
  const hasState = !!state;
5268
- const pageNow = React.useMemo(() => (pagesLive && hasState ? pageHere(suite, env()) : null),
5633
+ const pageNow = React.useMemo(() => (pagesOn && hasState ? pageHere(suite, env()) : null),
5269
5634
  // pathname/search/factsKey/domTick make it current.
5270
5635
  // eslint-disable-next-line react-hooks/exhaustive-deps
5271
- [pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
5272
- const listed = React.useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
5273
- const sectionsNow = React.useMemo(() => pagesLive && hasState && hasSections
5636
+ [pagesOn, hasState, suite, env, pathname, search, factsKey, domTick]);
5637
+ const listed = React.useMemo(() => (pagesOn && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesOn, suite, ctxIdle]);
5638
+ const sectionsNow = React.useMemo(() => pagesOn && hasState && hasSections
5274
5639
  ? sectionsAvailable(suite, env()).map(k => ({ key: k, title: findSection(suite, k)?.title ?? "" }))
5275
5640
  : [],
5276
5641
  // eslint-disable-next-line react-hooks/exhaustive-deps
5277
- [pagesLive, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
5642
+ [pagesOn, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
5278
5643
  const sectionsSig = sectionsNow.map(x => x.key).join(",");
5279
5644
  const openSection = React.useCallback((key) => {
5280
5645
  openingSection.current = key;
@@ -5285,14 +5650,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5285
5650
  openingSection.current = null;
5286
5651
  }
5287
5652
  }, []);
5288
- const flowsNow = React.useMemo(() => pagesLive && hasState && hasFlows
5653
+ const flowsNow = React.useMemo(() => pagesOn && hasState && hasFlows
5289
5654
  ? flowsAvailable(suite, env()).map(k => ({ key: k, title: findFlow(suite, k)?.title ?? "" }))
5290
5655
  : [],
5291
5656
  // eslint-disable-next-line react-hooks/exhaustive-deps
5292
- [pagesLive, hasState, hasFlows, suite, env, pathname, search, factsKey]);
5657
+ [pagesOn, hasState, hasFlows, suite, env, pathname, search, factsKey]);
5293
5658
  const flowsSig = flowsNow.map(x => x.key).join(",");
5294
5659
  /** The flows the panel lists: this person's, startable here or with somewhere to go. */
5295
- const flowsListed = React.useMemo(() => (pagesLive && hasFlows && ctxIdle ? shownFlows(suite, ctxIdle).filter(f => onFlowStart(f, { code, pathname, search: new URLSearchParams(search) }) || !!flowHref(f, ctxIdle)) : []), [pagesLive, hasFlows, suite, ctxIdle, code, pathname, search]);
5660
+ const flowsListed = React.useMemo(() => (pagesOn && hasFlows && ctxIdle ? shownFlows(suite, ctxIdle).filter(f => onFlowStart(f, { code, pathname, search: new URLSearchParams(search) }) || !!flowHref(f, ctxIdle)) : []), [pagesOn, hasFlows, suite, ctxIdle, code, pathname, search]);
5296
5661
  const openFlowHere = React.useCallback((key) => dispatchRef.current({ type: "startFlow", flow: key }), []);
5297
5662
  const openPageHere = React.useCallback(() => {
5298
5663
  const p = pageHere(suite, env());
@@ -5300,7 +5665,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5300
5665
  dispatchRef.current({ type: "startPage", page: p.key });
5301
5666
  }, [suite, env]);
5302
5667
  React.useEffect(() => {
5303
- if (!pagesLive || !hasState) {
5668
+ if (!pagesOn || !hasState) {
5304
5669
  publishPageGuides(null, null, null);
5305
5670
  return;
5306
5671
  }
@@ -5310,10 +5675,10 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5310
5675
  sections: sectionsNow,
5311
5676
  flows: flowsNow,
5312
5677
  flowsListed: flowsListed.length,
5313
- }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection, openFlowHere, () => dispatchRef.current({ type: "openPanel", section: "flows" }));
5678
+ }, () => viaRunner(openPageHere), () => viaRunner(() => dispatchRef.current({ type: "openPanel", section: "pages" })), (key) => viaRunner(() => openSection(key)), (key) => viaRunner(() => openFlowHere(key)), () => viaRunner(() => dispatchRef.current({ type: "openPanel", section: "flows" })));
5314
5679
  // sectionsSig / flowsSig stand for sectionsNow / flowsNow.
5315
5680
  // eslint-disable-next-line react-hooks/exhaustive-deps
5316
- }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere]);
5681
+ }, [pagesOn, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere, viaRunner]);
5317
5682
  React.useEffect(() => () => publishPageGuides(null, null, null), []);
5318
5683
  // The first-visit offer: in "offer" mode, nothing else up, the welcome
5319
5684
  // not due here, and a moment of nothing loading on this page.
@@ -5532,7 +5897,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5532
5897
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
5533
5898
  const kind = stepKind(step);
5534
5899
  const bridge = screen.bridge;
5535
- const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
5900
+ // What is ahead now decides เสร็จ; the number shown is the run's counter,
5901
+ // which never goes down (0.1.77).
5902
+ const raw = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
5903
+ const last = raw.no >= raw.total;
5904
+ const pos = screen.pos ?? raw;
5536
5905
  const page = screen.page ? findPage(suite, screen.page) : undefined;
5537
5906
  const section = screen.section ? findSection(suite, screen.section) : undefined;
5538
5907
  const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
@@ -5541,16 +5910,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5541
5910
  ? {
5542
5911
  ...read,
5543
5912
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
5544
- nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
5913
+ nextLabel: last ? "เสร็จ" : undefined,
5545
5914
  }
5546
5915
  : flow
5547
5916
  ? {
5548
5917
  heading: `พาทำ · ${flow.title}`,
5549
5918
  exitLabel: "ออกจากการพาทำ",
5550
- nextLabel: kind === "view" && pos.no >= pos.total ? "เสร็จ" : undefined,
5919
+ nextLabel: kind === "view" && last ? "เสร็จ" : undefined,
5551
5920
  }
5552
5921
  : {};
5553
- 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: {
5922
+ 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 }, bounds: layout.col, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
5554
5923
  missionNo,
5555
5924
  missionTitle: mission.title,
5556
5925
  replay: screen.replay,
@@ -5766,4 +6135,4 @@ exports.useGuideEntry = useGuideEntry;
5766
6135
  exports.usePageGuideEntry = usePageGuideEntry;
5767
6136
  exports.usePageGuides = usePageGuides;
5768
6137
  exports.useSectionGuideValues = useSectionGuideValues;
5769
- //# sourceMappingURL=format-BVoVc7Yk.cjs.map
6138
+ //# sourceMappingURL=format-BmTSD3DB.cjs.map