@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.
@@ -992,6 +992,7 @@ function initialGuideState(audience, progress, pages) {
992
992
  pageRun: null,
993
993
  panelSection: "all",
994
994
  autoPassed: null,
995
+ counter: null,
995
996
  audience,
996
997
  progress,
997
998
  screen: { name: "idle" },
@@ -1370,7 +1371,7 @@ function announceStep(d, suite, env) {
1370
1371
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1371
1372
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
1372
1373
  const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
1373
- const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
1374
+ const pos = s.pos ?? stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
1374
1375
  d.effects.push({
1375
1376
  type: "announce",
1376
1377
  text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
@@ -1404,6 +1405,54 @@ function stepPosition(mission, index, env, context, autoPassed = []) {
1404
1405
  });
1405
1406
  return { no: Math.max(no, 1), total: Math.max(total, 1) };
1406
1407
  }
1408
+ /**
1409
+ * The steps after `index` this person will see, as far as can be known from
1410
+ * here: on this page, everything is read (requires, skipIf, a click whose
1411
+ * outcome already holds); on another page, a step whose showing depends on
1412
+ * what is on that page (`skipIf`, `onMissing: "skip"`) is not counted until
1413
+ * its page is reached.
1414
+ */
1415
+ function stepsAhead(mission, index, env, context) {
1416
+ let n = 0;
1417
+ for (let j = index + 1; j < mission.steps.length; j++) {
1418
+ const step = mission.steps[j];
1419
+ if (step.requires && !safe(() => env.can(step.requires), true))
1420
+ continue;
1421
+ const home = stepHomeRoute(mission.steps, j);
1422
+ const here = !home || !!matchRoute(home, env.pathname, env.search, env.code);
1423
+ if (here) {
1424
+ const ctx = guideContext(env, context, routeParamsFor(step, env));
1425
+ if (step.skipIf && safe(() => step.skipIf(ctx), false))
1426
+ continue;
1427
+ if (wouldAutoPass(step, env))
1428
+ continue;
1429
+ }
1430
+ else if (step.skipIf || step.onMissing === "skip") {
1431
+ continue;
1432
+ }
1433
+ n++;
1434
+ }
1435
+ return n;
1436
+ }
1437
+ /**
1438
+ * The counter as shown when entering step `index` (shell ≥ 0.1.77). `no`
1439
+ * counts the steps actually shown in this run up to this one. The total is
1440
+ * worked out on entering a step on a new page — the shown steps so far plus
1441
+ * stepsAhead — and kept while the run stays on that page; it never goes
1442
+ * down (the larger of the old and the new), and is never below `no`.
1443
+ */
1444
+ function countStep(d, env, mission, index) {
1445
+ const prev = d.state.counter?.mission === mission.key ? d.state.counter : null;
1446
+ const shown = prev ? (prev.shown.includes(index) ? prev.shown : [...prev.shown, index]) : [index];
1447
+ const no = Math.max(1, shown.filter(i => i <= index).length);
1448
+ let total;
1449
+ if (prev && prev.path === env.pathname)
1450
+ total = Math.max(prev.total, no);
1451
+ else
1452
+ total = Math.max(prev?.total ?? 0, no + stepsAhead(mission, index, env, d.state.progress.context), no);
1453
+ d.state = { ...d.state, counter: { mission: mission.key, shown, total, path: env.pathname } };
1454
+ return { no, total };
1455
+ }
1407
1456
  /** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
1408
1457
  function wouldAutoPass(step, env) {
1409
1458
  const kind = stepKind(step);
@@ -1473,6 +1522,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1473
1522
  const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
1474
1523
  const bridge = !away && needsBridge(step, env, ctx);
1475
1524
  const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
1525
+ const pos = countStep(d, env, mission, i);
1476
1526
  setScreen(d, {
1477
1527
  name: "step",
1478
1528
  mission: mission.key,
@@ -1490,6 +1540,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1490
1540
  ...(page ? { page } : {}),
1491
1541
  ...(section ? { section } : {}),
1492
1542
  ...(flow ? { flow } : {}),
1543
+ pos,
1493
1544
  });
1494
1545
  if (!opts.replay) {
1495
1546
  const cp = checkpointAtOrBefore(steps, i);
@@ -1764,7 +1815,7 @@ function startMission(d, suite, env, key, replay) {
1764
1815
  const isReplay = replay || entry?.status === "done";
1765
1816
  const before = d.state.progress;
1766
1817
  // A new run: what passed by itself is counted afresh.
1767
- d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
1818
+ d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null, counter: null };
1768
1819
  if (d.state.progress.welcome === "not_seen")
1769
1820
  withProgress(d, { welcome: "started" });
1770
1821
  if (!isReplay && d.state.progress.mission_key !== key)
@@ -1914,24 +1965,33 @@ function pageStillHere(suite, key, env) {
1914
1965
  const page = findPage(suite, key);
1915
1966
  return !!page && pageMatchScore(page, whereOf(env)) !== null;
1916
1967
  }
1917
- /** Write a page guide's row (once per page and version): recorded here at once, saved by the host. */
1968
+ /**
1969
+ * Whether a row may be written now: none of this version yet, or — once —
1970
+ * a skipped one that the person has now finished (skipped → completed,
1971
+ * shell ≥ 0.1.77). A completed row is final; skipped again changes nothing.
1972
+ */
1973
+ function rowWritable(row, version, status) {
1974
+ if (!row || row.version < version)
1975
+ return true;
1976
+ return row.version === version && row.status === "skipped" && status === "completed";
1977
+ }
1978
+ /** Whether a run should keep its row to write at its end: no row yet, or only a skipped one. */
1979
+ function rowStillDue(row, version) {
1980
+ return rowWritable(row, version, "completed");
1981
+ }
1982
+ /** Write a page guide's row (once per page and version; skipped → completed once): recorded here at once, saved by the host. */
1918
1983
  function recordPage(d, suite, key, status) {
1919
1984
  const page = findPage(suite, key);
1920
- if (!page || pageRecorded(d.state, page))
1985
+ if (!page || !rowWritable(d.state.pages[key], page.version, status))
1921
1986
  return;
1922
1987
  const prev = d.state.pages[key];
1923
1988
  d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
1924
1989
  d.effects.push({ type: "savePage", key, version: page.version, status });
1925
1990
  }
1926
- /** The section guide's row says this person has ended this version of it. */
1927
- function sectionRecorded(state, section) {
1928
- const row = state.sections?.[section.key];
1929
- return !!row && row.version >= section.version;
1930
- }
1931
1991
  /** Write a section guide's row (once per section and version). */
1932
1992
  function recordSection(d, suite, key, status) {
1933
1993
  const section = findSection(suite, key);
1934
- if (!section || sectionRecorded(d.state, section))
1994
+ if (!section || !rowWritable(d.state.sections?.[key], section.version, status))
1935
1995
  return;
1936
1996
  const prev = d.state.sections?.[key];
1937
1997
  d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
@@ -1962,7 +2022,7 @@ function flowRecorded(state, flow) {
1962
2022
  /** Write a flow's row (once per flow and version). */
1963
2023
  function recordFlow(d, suite, key, status) {
1964
2024
  const flow = findFlow(suite, key);
1965
- if (!flow || flowRecorded(d.state, flow))
2025
+ if (!flow || !rowWritable(d.state.flows?.[key], flow.version, status))
1966
2026
  return;
1967
2027
  const prev = d.state.flows?.[key];
1968
2028
  d.state = { ...d.state, flows: { ...d.state.flows, [key]: { version: flow.version, status, rev: prev?.rev ?? 0 } } };
@@ -2047,6 +2107,7 @@ function startFlow(d, suite, env, key, write, at) {
2047
2107
  justDone: null,
2048
2108
  pageRun: { key, write, kind: "flow", context: saved },
2049
2109
  autoPassed: null,
2110
+ counter: null,
2050
2111
  replayPage: {
2051
2112
  mission: mission.key,
2052
2113
  anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
@@ -2077,6 +2138,7 @@ function startSection(d, suite, env, key, write) {
2077
2138
  justDone: null,
2078
2139
  pageRun: { key, write, kind: "section" },
2079
2140
  autoPassed: null,
2141
+ counter: null,
2080
2142
  replayPage: {
2081
2143
  mission: mission.key,
2082
2144
  anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
@@ -2096,6 +2158,7 @@ function startPage(d, suite, env, key, write) {
2096
2158
  justDone: null,
2097
2159
  pageRun: { key, write, kind: "page" },
2098
2160
  autoPassed: null,
2161
+ counter: null,
2099
2162
  // What is open now is the person's: ending the guide leaves it alone.
2100
2163
  replayPage: {
2101
2164
  mission: mission.key,
@@ -2139,6 +2202,28 @@ function backTarget(mission, index, env, context) {
2139
2202
  }
2140
2203
  return prev >= 0 ? { index: prev, close: false } : null;
2141
2204
  }
2205
+ /**
2206
+ * A flow's next step (the next one this person sees) when the person is on
2207
+ * its page and no longer on the current step's: they moved on by
2208
+ * themselves. -1 otherwise (still on this step's page, or elsewhere).
2209
+ */
2210
+ function flowStepArrivedAt(mission, index, env, context) {
2211
+ const here = (route) => !!route && !!matchRoute(route, env.pathname, env.search, env.code);
2212
+ const curHome = stepHomeRoute(mission.steps, index);
2213
+ if (!curHome || here(curHome))
2214
+ return -1;
2215
+ for (let j = index + 1; j < mission.steps.length; j++) {
2216
+ const st = mission.steps[j];
2217
+ const ctx = guideContext(env, context, routeParamsFor(st, env));
2218
+ if (st.requires && !safe(() => env.can(st.requires), true))
2219
+ continue;
2220
+ if (st.skipIf && safe(() => st.skipIf(ctx), false))
2221
+ continue;
2222
+ const where = st.route ?? stepHomeRoute(mission.steps, j);
2223
+ return here(where) ? j : -1;
2224
+ }
2225
+ return -1;
2226
+ }
2142
2227
  function reduceGuide(state, action, suite, env) {
2143
2228
  const d = { state, effects: [] };
2144
2229
  const s = state.screen;
@@ -2216,7 +2301,7 @@ function reduceGuide(state, action, suite, env) {
2216
2301
  const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
2217
2302
  const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
2218
2303
  const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
2219
- d.state = { ...d.state, autoPassed: null };
2304
+ d.state = { ...d.state, autoPassed: null, counter: null };
2220
2305
  enterStep(d, suite, env, mission, index, { replay: false });
2221
2306
  break;
2222
2307
  }
@@ -2259,6 +2344,15 @@ function reduceGuide(state, action, suite, env) {
2259
2344
  break;
2260
2345
  const step = mission.steps[s.index];
2261
2346
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
2347
+ if (s.flow && !s.confirmExit) {
2348
+ // Within the chain, the person went on to the next step's page by
2349
+ // themselves: carry on there, no "ทำต่อ" (shell ≥ 0.1.77).
2350
+ const next = flowStepArrivedAt(mission, s.index, env, d.state.progress.context);
2351
+ if (next >= 0) {
2352
+ enterStep(d, suite, env, mission, next, { replay: s.replay });
2353
+ break;
2354
+ }
2355
+ }
2262
2356
  if (s.phase === "away") {
2263
2357
  const where = placeRoute(mission.steps, s.index);
2264
2358
  if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
@@ -2653,7 +2747,7 @@ function reduceGuide(state, action, suite, env) {
2653
2747
  if (!flowsAvailable(suite, env).includes(action.flow))
2654
2748
  break;
2655
2749
  const flow = findFlow(suite, action.flow);
2656
- startFlow(d, suite, env, flow.key, !flowRecorded(state, flow));
2750
+ startFlow(d, suite, env, flow.key, rowStillDue(state.flows?.[flow.key], flow.version));
2657
2751
  break;
2658
2752
  }
2659
2753
  case "resumeFlow": {
@@ -2662,7 +2756,7 @@ function reduceGuide(state, action, suite, env) {
2662
2756
  const flow = findFlow(suite, action.flow);
2663
2757
  if (!flow || !flowShown(flow, guideContext(env, {})) || !inFlowChain(flow, whereOf(env)))
2664
2758
  break;
2665
- startFlow(d, suite, env, flow.key, action.write && !flowRecorded(state, flow), { step: action.step, context: action.context });
2759
+ startFlow(d, suite, env, flow.key, action.write && rowStillDue(state.flows?.[flow.key], flow.version), { step: action.step, context: action.context });
2666
2760
  break;
2667
2761
  }
2668
2762
  case "startPage": {
@@ -2674,7 +2768,7 @@ function reduceGuide(state, action, suite, env) {
2674
2768
  const page = pageHere(suite, env);
2675
2769
  if (!page || page.key !== action.page)
2676
2770
  break;
2677
- startPage(d, suite, env, page.key, !pageRecorded(state, page));
2771
+ startPage(d, suite, env, page.key, rowStillDue(state.pages[page.key], page.version));
2678
2772
  break;
2679
2773
  }
2680
2774
  case "back": {
@@ -2713,6 +2807,37 @@ function reduceGuide(state, action, suite, env) {
2713
2807
  d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
2714
2808
  break;
2715
2809
  }
2810
+ case "yield": {
2811
+ const run = d.state.pageRun;
2812
+ if (run?.kind === "flow" && run.context)
2813
+ withProgress(d, { context: run.context });
2814
+ d.state = { ...d.state, pageRun: null, replayPage: null, panelOpen: false, justDone: null, pendingCelebrate: [] };
2815
+ setScreen(d, { name: "idle" });
2816
+ break;
2817
+ }
2818
+ case "refresh": {
2819
+ const p = action.progress;
2820
+ const local = d.state.progress;
2821
+ // The server's row when it is this version and not behind this tab's.
2822
+ if (p.guide_version === local.guide_version && p.rev >= local.rev)
2823
+ d.state = { ...d.state, progress: p };
2824
+ const newer = (server, mine) => {
2825
+ const out = { ...(mine ?? {}) };
2826
+ for (const [k, r] of Object.entries(server ?? {})) {
2827
+ const m = out[k];
2828
+ if (!m || r.version > m.version || (r.version === m.version && r.rev >= m.rev))
2829
+ out[k] = r;
2830
+ }
2831
+ return out;
2832
+ };
2833
+ d.state = {
2834
+ ...d.state,
2835
+ pages: newer(action.pages, d.state.pages),
2836
+ sections: newer(action.sections, d.state.sections),
2837
+ flows: newer(action.flows, d.state.flows),
2838
+ };
2839
+ break;
2840
+ }
2716
2841
  case "startSection": {
2717
2842
  // Never offered: only on request. Never over a mission step, a
2718
2843
  // welcome, a gate or a celebration; only on screen, only theirs.
@@ -2723,7 +2848,7 @@ function reduceGuide(state, action, suite, env) {
2723
2848
  if (!sectionsAvailable(suite, env).includes(action.section))
2724
2849
  break;
2725
2850
  const section = findSection(suite, action.section);
2726
- startSection(d, suite, env, section.key, !sectionRecorded(state, section));
2851
+ startSection(d, suite, env, section.key, rowStillDue(state.sections?.[section.key], section.version));
2727
2852
  break;
2728
2853
  }
2729
2854
  case "idleCelebrate": {
@@ -3082,9 +3207,9 @@ function conflictOf(err) {
3082
3207
  return null;
3083
3208
  }
3084
3209
  /** The GET, parsed for this suite; null (show nothing) on any failure or odd answer. */
3085
- async function loadGuides(adapter, suite, signal) {
3210
+ async function loadGuides(adapter, suite, signal, fresh = false) {
3086
3211
  try {
3087
- const raw = await adapter.fetchProgress({ app: suite.app, guideKey: suite.key, signal });
3212
+ const raw = await adapter.fetchProgress({ app: suite.app, guideKey: suite.key, signal, ...(fresh ? { fresh: true } : {}) });
3088
3213
  return parseGuidesAnswer(raw, suite);
3089
3214
  }
3090
3215
  catch {
@@ -3169,7 +3294,10 @@ async function storePageRow(adapter, app, key, version, status, rev, kind = "pag
3169
3294
  }
3170
3295
  if (current.version > version)
3171
3296
  return null;
3172
- if (current.version === version && current.status !== "in_progress")
3297
+ // This version already recorded: kept, except a skipped row this run
3298
+ // finished (skipped → completed, once, shell ≥ 0.1.77).
3299
+ const finishing = status === "completed" && current.status === "skipped";
3300
+ if (current.version === version && current.status !== "in_progress" && !finishing)
3173
3301
  return current;
3174
3302
  at = current.rev;
3175
3303
  }
@@ -3836,6 +3964,9 @@ function useNudge(nudge, ref) {
3836
3964
  }
3837
3965
  function StepLayer(p) {
3838
3966
  const { spot, phone, viewport } = p;
3967
+ // Where the bubble may sit: the content column when the app has one.
3968
+ const col = p.bounds && p.bounds.width > 0 ? p.bounds : { x: 0, width: viewport.width || (typeof window !== "undefined" ? window.innerWidth : 0)};
3969
+ const colKey = `${col.x}|${col.width}`;
3839
3970
  const bubbleRef = useRef(null);
3840
3971
  const arrowRef = useRef(null);
3841
3972
  const ringRef = useRef(null);
@@ -3876,8 +4007,8 @@ function StepLayer(p) {
3876
4007
  const bw = el.offsetWidth;
3877
4008
  const bh = el.offsetHeight;
3878
4009
  setPos(phone
3879
- ? { left: m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
3880
- : { left: (window.innerWidth - bw) / 2, top: viewport.offsetTop + (viewport.height - bh) / 2, side: "dock" });
4010
+ ? { left: col.x + m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
4011
+ : { left: col.x + (col.width - bw) / 2, top: viewport.offsetTop + (viewport.height - bh) / 2, side: "dock" });
3881
4012
  return;
3882
4013
  }
3883
4014
  if (phone) {
@@ -3898,7 +4029,7 @@ function StepLayer(p) {
3898
4029
  : overlapBot <= overlapTop
3899
4030
  ? botT
3900
4031
  : topT;
3901
- setPos({ left: m, top, side: "dock" });
4032
+ setPos({ left: col.x + m, top, side: "dock" });
3902
4033
  return;
3903
4034
  }
3904
4035
  let cancelled = false;
@@ -3920,7 +4051,7 @@ function StepLayer(p) {
3920
4051
  placement,
3921
4052
  middleware: [
3922
4053
  offset(placement.startsWith("bottom") ? gap : 14),
3923
- shift({ padding: m }),
4054
+ shift({ padding: m, rootBoundary: { x: col.x, y: 0, width: col.width, height: window.innerHeight } }),
3924
4055
  ...(arrowRef.current ? [arrow({ element: arrowRef.current, padding: 14 })] : []),
3925
4056
  ],
3926
4057
  });
@@ -3930,11 +4061,11 @@ function StepLayer(p) {
3930
4061
  return;
3931
4062
  const bw = el.offsetWidth;
3932
4063
  const bh = el.offsetHeight;
3933
- const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
4064
+ const bounds = { x: col.x + m, y: viewport.offsetTop + m, width: col.width - 2 * m, height: viewport.height - 2 * m };
3934
4065
  const i = pickPlacement(results, bw, bh, a, clear, bounds);
3935
4066
  if (i < 0) {
3936
4067
  // Nowhere beside it: the bottom-right corner.
3937
- setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
4068
+ setPos({ left: col.x + col.width - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
3938
4069
  return;
3939
4070
  }
3940
4071
  const { x, y, placement, middlewareData } = results[i];
@@ -3950,7 +4081,7 @@ function StepLayer(p) {
3950
4081
  };
3951
4082
  // The keys stand for spot, the lists, the holes and the bubble's size.
3952
4083
  // eslint-disable-next-line react-hooks/exhaustive-deps
3953
- }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
4084
+ }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop, colKey]);
3954
4085
  const popovers = p.popovers ?? [];
3955
4086
  const covered = !!p.covered;
3956
4087
  const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
@@ -3971,7 +4102,7 @@ function StepLayer(p) {
3971
4102
  return (jsxs(Fragment, { 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: {
3972
4103
  left: pos?.left ?? 0,
3973
4104
  top: pos?.top ?? 0,
3974
- width: phone ? viewport.width - 32 : undefined,
4105
+ width: phone ? col.width - 32 : col.width < 352 ? col.width - 32 : undefined,
3975
4106
  visibility: pos ? "visible" : "hidden",
3976
4107
  }, children: [jsx(BubbleBody, { ...p }), jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxs("div", { className: "eg-loading", children: [jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsx("div", { className: "eg-row", children: jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }) })] })) : null, p.phase === "missing" ? (jsxs("div", { className: "eg-card eg-err", role: "alertdialog", "aria-labelledby": "eg-missing-t", "data-eg-card": "", tabIndex: -1, children: [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" }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), 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" ? (jsxs("div", { className: "eg-card", role: "dialog", "aria-modal": "false", "aria-labelledby": "eg-away-t", "data-eg-card": "", tabIndex: -1, children: [jsxs("div", { className: "eg-b-top", children: [jsx("span", { className: "eg-mis", children: headingOf(p.bubble) }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", p.bubble.index + 1, "/", p.bubble.total] })] }), jsx("p", { className: "eg-b-t", id: "eg-away-t", style: { marginTop: 8 }, children: jsx(GuideTitle, { text: p.bubble.title }) }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onGoResume, children: "\u0E17\u0E33\u0E15\u0E48\u0E2D" })] })] })) : null, p.confirmExit ? (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] }));
3977
4108
  }
@@ -4277,36 +4408,81 @@ const flowMirrorKey = (orgId, suite) => `erp-guide-flow-run:${orgId}:${suite.app
4277
4408
  /* One tab at a time */
4278
4409
  /* ------------------------------------------------------------------ */
4279
4410
  /**
4280
- * Whether this tab runs the tour. With two tabs open, the first holds a Web
4281
- * Lock and the second shows nothing until the first goes away.
4411
+ * Whether this tab runs the tour: one tab at a time holds a Web Lock and
4412
+ * runs steps, offers, the welcome and saves. Every other tab still reads
4413
+ * progress and shows the "?" menu (shell ≥ 0.1.78); pressing one of its
4414
+ * guide items calls `takeover`, which steals the lock. The tab it was
4415
+ * stolen from goes back to waiting its turn (and yields what it was running).
4416
+ * Without Web Locks every tab runs, as before.
4282
4417
  */
4283
4418
  function usePrimaryTab(name) {
4284
4419
  const [primary, setPrimary] = useState(false);
4420
+ const primaryRef = useRef(false);
4421
+ const takeoverRef = useRef(() => Promise.resolve());
4285
4422
  useEffect(() => {
4286
4423
  const locks = (typeof navigator !== "undefined" ? navigator.locks : undefined);
4424
+ const set = (v) => {
4425
+ primaryRef.current = v;
4426
+ setPrimary(v);
4427
+ };
4287
4428
  if (!locks?.request) {
4288
- setPrimary(true);
4429
+ set(true);
4430
+ takeoverRef.current = () => Promise.resolve();
4289
4431
  return;
4290
4432
  }
4291
- const abort = new AbortController();
4433
+ let alive = true;
4434
+ let gen = 0;
4435
+ let pending = null;
4292
4436
  let release = null;
4293
- locks
4294
- .request(name, { signal: abort.signal }, () => {
4295
- setPrimary(true);
4296
- return new Promise(resolve => {
4297
- release = resolve;
4437
+ let waiters = [];
4438
+ const queue = (steal) => {
4439
+ const my = ++gen;
4440
+ const ac = new AbortController();
4441
+ pending = steal ? null : ac;
4442
+ locks
4443
+ .request(name, steal ? { steal: true } : { signal: ac.signal }, () => {
4444
+ if (!alive || my !== gen)
4445
+ return undefined;
4446
+ pending = null;
4447
+ set(true);
4448
+ const w = waiters;
4449
+ waiters = [];
4450
+ w.forEach(f => f());
4451
+ return new Promise(resolve => {
4452
+ release = resolve;
4453
+ });
4454
+ })
4455
+ .catch(() => {
4456
+ // Stolen by another tab (or this request aborted): not the runner;
4457
+ // wait for the lock again.
4458
+ if (!alive || my !== gen)
4459
+ return;
4460
+ release = null;
4461
+ set(false);
4462
+ queue(false);
4298
4463
  });
4299
- })
4300
- .catch(() => {
4301
- /* aborted on unmount */
4464
+ };
4465
+ queue(false);
4466
+ takeoverRef.current = () => new Promise(resolve => {
4467
+ if (primaryRef.current) {
4468
+ resolve();
4469
+ return;
4470
+ }
4471
+ waiters.push(resolve);
4472
+ pending?.abort();
4473
+ queue(true);
4302
4474
  });
4303
4475
  return () => {
4304
- abort.abort();
4476
+ alive = false;
4477
+ pending?.abort();
4305
4478
  release?.();
4306
- setPrimary(false);
4479
+ waiters.forEach(f => f());
4480
+ waiters = [];
4481
+ set(false);
4307
4482
  };
4308
4483
  }, [name]);
4309
- return primary;
4484
+ const takeover = useCallback(() => takeoverRef.current(), []);
4485
+ return { primary, takeover };
4310
4486
  }
4311
4487
  /* ------------------------------------------------------------------ */
4312
4488
  /* Putting the page back after a replay */
@@ -4339,6 +4515,42 @@ function runAbort(actions) {
4339
4515
  }, ABORT_DELAY_MS + i * ABORT_GAP_MS);
4340
4516
  });
4341
4517
  }
4518
+ /** The app's content column (GuideConfig.contentRoot), cut to the viewport; null when none is declared or found. */
4519
+ function contentColumn(selector, vw) {
4520
+ if (!selector || typeof document === "undefined")
4521
+ return null;
4522
+ let el = null;
4523
+ try {
4524
+ el = document.querySelector(selector);
4525
+ }
4526
+ catch {
4527
+ return null;
4528
+ }
4529
+ if (!el)
4530
+ return null;
4531
+ const r = el.getBoundingClientRect();
4532
+ const x = Math.max(0, r.left);
4533
+ const right = Math.min(vw || window.innerWidth, r.right);
4534
+ if (right - x <= 0)
4535
+ return null;
4536
+ return { x, y: 0, width: right - x, height: 0 };
4537
+ }
4538
+ /** The control a form's implicit submit (Enter in a field) presses: its first submit button. */
4539
+ function defaultSubmitControl(form) {
4540
+ for (const el of Array.from(form.elements)) {
4541
+ if (el instanceof HTMLButtonElement && (el.type === "submit" || !el.getAttribute("type")))
4542
+ return el;
4543
+ if (el instanceof HTMLInputElement && (el.type === "submit" || el.type === "image"))
4544
+ return el;
4545
+ }
4546
+ return null;
4547
+ }
4548
+ /** A field where Enter submits its form (not a textarea, a button or a select). */
4549
+ function submitsOnEnter(el) {
4550
+ if (!(el instanceof HTMLInputElement))
4551
+ return false;
4552
+ return !["button", "submit", "reset", "image", "file", "hidden"].includes(el.type);
4553
+ }
4342
4554
  /** A step's own target takes presses: not on a "view" step, unless the step allows it. */
4343
4555
  function targetPressable(step, bridge) {
4344
4556
  if (!step || bridge)
@@ -4379,7 +4591,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4379
4591
  busyRef.current = busy;
4380
4592
  // No menu: never a menu step (the machine sees it as always open).
4381
4593
  const sidebarOpen = menu ? !!menu.open : true;
4382
- const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
4594
+ const { primary, takeover } = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
4595
+ const primaryRef = useRef(primary);
4596
+ primaryRef.current = primary;
4383
4597
  // Progress: read once this tab runs the tour and the app is ready (for
4384
4598
  // the missions, or for the page guides alone: config.pagesReady).
4385
4599
  const [answer, setAnswer] = useState(null);
@@ -4388,7 +4602,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4388
4602
  const hasPages = useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0 || validFlows(suite).length > 0, [suite]);
4389
4603
  const hasFlows = useMemo(() => validFlows(suite).length > 0, [suite]);
4390
4604
  const hasSections = useMemo(() => validSections(suite).length > 0, [suite]);
4391
- const enabled = primary && (missionsReady || (hasPages && pagesReady));
4605
+ // Every tab reads progress (0.1.78): the "?" menu is shown in all of them.
4606
+ const enabled = missionsReady || (hasPages && pagesReady);
4392
4607
  const loaded = useRef(false);
4393
4608
  useEffect(() => {
4394
4609
  if (!enabled || loaded.current)
@@ -4412,8 +4627,12 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4412
4627
  }, [enabled, suite]);
4413
4628
  // The missions run for their audience; the page guides for theirs (every
4414
4629
  // member while pages are on), each with its own ready.
4415
- const missionsLive = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && missionsReady && !hidden;
4416
- const pagesLive = !!answer && hasPages && answer.pageAudience !== "none" && primary && pagesReady && !hidden;
4630
+ // On: this person has them here (every tab, for the "?" menu). Live: this
4631
+ // tab runs them (the primary tab only): steps, offers, the welcome, saves.
4632
+ const missionsOn = !!answer && answer.audience !== "none" && !answer.newerOnServer && missionsReady && !hidden;
4633
+ const pagesOn = !!answer && hasPages && answer.pageAudience !== "none" && pagesReady && !hidden;
4634
+ const missionsLive = missionsOn && primary;
4635
+ const pagesLive = pagesOn && primary;
4417
4636
  const live = missionsLive || pagesLive;
4418
4637
  /* ---------------- machine ---------------- */
4419
4638
  const [state, setState] = useState(null);
@@ -4506,6 +4725,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4506
4725
  const row = await storeGuides(adapterRef.current, suite, cur.progress);
4507
4726
  if (row)
4508
4727
  savedKey.current = payloadKey(row, suite.app);
4728
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
4729
+ if (row)
4730
+ announceSaved();
4509
4731
  if (row && stateRef.current) {
4510
4732
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
4511
4733
  dispatchRef.current({ type: "serverRow", progress: row });
@@ -4562,6 +4784,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4562
4784
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
4563
4785
  if (row)
4564
4786
  dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
4787
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
4788
+ if (row)
4789
+ announceSaved();
4565
4790
  });
4566
4791
  }
4567
4792
  }
@@ -4608,6 +4833,95 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4608
4833
  clearTimeout(toastTimer.current);
4609
4834
  };
4610
4835
  }, [flushSave]);
4836
+ /* ---------------- other tabs (0.1.78) ---------------- */
4837
+ // Progress saved in one tab reaches the others: a message on a channel
4838
+ // per organisation and app, and a fresh read when a tab comes back into
4839
+ // view. Only the server's rows are read, never another tab's state.
4840
+ const channelRef = useRef(null);
4841
+ const refreshNow = useCallback(async () => {
4842
+ const a = await loadGuides(adapterRef.current, suite, undefined, true);
4843
+ const cur = stateRef.current;
4844
+ if (!a || !cur)
4845
+ return;
4846
+ // The runner's own state wins while it is running something.
4847
+ if (primaryRef.current && (cur.screen.name !== "idle" || saving.current))
4848
+ return;
4849
+ if (a.progress.guide_version === cur.progress.guide_version && a.progress.rev >= cur.progress.rev) {
4850
+ savedKey.current = a.progress.rev > 0 ? payloadKey(a.progress, suite.app) : null;
4851
+ }
4852
+ dispatchRef.current({ type: "refresh", progress: a.progress, pages: a.pages, sections: a.sections, flows: a.flows });
4853
+ }, [suite]);
4854
+ const refreshRef = useRef(refreshNow);
4855
+ refreshRef.current = refreshNow;
4856
+ useEffect(() => {
4857
+ if (typeof BroadcastChannel === "undefined")
4858
+ return;
4859
+ let ch;
4860
+ try {
4861
+ ch = new BroadcastChannel(`erp-guide:${organizationId}:${suite.app}`);
4862
+ }
4863
+ catch {
4864
+ return;
4865
+ }
4866
+ channelRef.current = ch;
4867
+ ch.onmessage = (e) => {
4868
+ if (e.data?.type === "saved")
4869
+ void refreshRef.current();
4870
+ };
4871
+ return () => {
4872
+ channelRef.current = null;
4873
+ ch.close();
4874
+ };
4875
+ }, [organizationId, suite.app]);
4876
+ useEffect(() => {
4877
+ const onVisible = () => {
4878
+ if (document.visibilityState === "visible" && stateRef.current)
4879
+ void refreshRef.current();
4880
+ };
4881
+ document.addEventListener("visibilitychange", onVisible);
4882
+ return () => document.removeEventListener("visibilitychange", onVisible);
4883
+ }, []);
4884
+ function announceSaved() {
4885
+ try {
4886
+ channelRef.current?.postMessage({ type: "saved" });
4887
+ }
4888
+ catch {
4889
+ /* a closed channel: the next visibility change reads again */
4890
+ }
4891
+ }
4892
+ // The tab that lost the lock stops cleanly: whatever it was running ends
4893
+ // silently, without writing.
4894
+ useEffect(() => {
4895
+ if (primary || !stateRef.current)
4896
+ return;
4897
+ const cur = stateRef.current;
4898
+ if (saveTimer.current) {
4899
+ clearTimeout(saveTimer.current);
4900
+ saveTimer.current = null;
4901
+ }
4902
+ if (cur.screen.name !== "idle" || cur.panelOpen)
4903
+ dispatchRef.current({ type: "yield" });
4904
+ }, [primary]);
4905
+ /**
4906
+ * A guide item pressed in this tab: run it here. In a tab that is not the
4907
+ * runner, take the lock first (the other tab stops), read progress fresh,
4908
+ * then do it. Nothing resumes by itself in a tab taken over this way.
4909
+ */
4910
+ const viaRunner = useCallback((fn) => {
4911
+ if (primaryRef.current) {
4912
+ fn();
4913
+ return;
4914
+ }
4915
+ resumed.current = true;
4916
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
4917
+ flowResumed.current = true;
4918
+ void takeover()
4919
+ .then(() => refreshRef.current().catch(() => undefined))
4920
+ .then(() => {
4921
+ if (primaryRef.current)
4922
+ fn();
4923
+ });
4924
+ }, [takeover]);
4611
4925
  /* ---------------- boot: resume, welcome ---------------- */
4612
4926
  const screen = state?.screen ?? { name: "idle" };
4613
4927
  const resumed = useRef(false);
@@ -4717,6 +5031,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4717
5031
  const stepId = screen.name === "step" ? `${screen.mission}:${screen.index}:${screen.bridge ? "b" : ""}${screen.nothingToSave ? "n" : ""}` : "";
4718
5032
  /* Measure every frame while a step is shown: the spotlight follows
4719
5033
  scrolling, dialogs animating in, the drawer sliding, the keyboard. */
5034
+ const contentRoot = config.contentRoot;
4720
5035
  const [layout, setLayout] = useState({
4721
5036
  spot: null,
4722
5037
  covered: false,
@@ -4725,6 +5040,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4725
5040
  vw: 0,
4726
5041
  vh: 0,
4727
5042
  oy: 0,
5043
+ col: null,
4728
5044
  });
4729
5045
  useEffect(() => {
4730
5046
  if (!inStep) {
@@ -4733,7 +5049,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4733
5049
  }
4734
5050
  let raf = 0;
4735
5051
  let lostSince = null;
4736
- let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
5052
+ let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0, col: null };
4737
5053
  targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
4738
5054
  const allow = stepInfo?.step.allow ?? [];
4739
5055
  // The spotlight is the part of the target a person can see: cut by the
@@ -4793,13 +5109,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4793
5109
  holes.push(padRect(rectOf(ael), TARGET_PAD));
4794
5110
  }
4795
5111
  const covered = !!spot && coveredByDialog(targetElRef.current);
4796
- const next = { spot, covered, holes, popovers, vw, vh, oy };
5112
+ const col = contentColumn(contentRoot, vw);
5113
+ const next = { spot, covered, holes, popovers, vw, vh, oy, col };
4797
5114
  const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
4798
5115
  if (!sameRect(next.spot, last.spot) ||
4799
5116
  next.covered !== last.covered ||
4800
5117
  next.vw !== last.vw ||
4801
5118
  next.vh !== last.vh ||
4802
5119
  next.oy !== last.oy ||
5120
+ !sameRect(next.col, last.col) ||
4803
5121
  !sameList(next.holes, last.holes) ||
4804
5122
  !sameList(next.popovers, last.popovers)) {
4805
5123
  last = next;
@@ -4954,6 +5272,43 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4954
5272
  }
4955
5273
  return false;
4956
5274
  };
5275
+ /**
5276
+ * Whether a form may be submitted during this step (shell ≥ 0.1.77): only
5277
+ * when its submit control — the button pressed, or the one Enter in a
5278
+ * field would press — is the step's pressable target or in its `allow`.
5279
+ * Otherwise Enter in an allowed field would save a draft (and take a
5280
+ * document number) behind the guide's back.
5281
+ */
5282
+ const submitAllowed = (form, submitter) => {
5283
+ const btn = submitter ?? defaultSubmitControl(form);
5284
+ if (!btn)
5285
+ return false;
5286
+ const el = targetElRef.current;
5287
+ const sc = stateRef.current?.screen;
5288
+ const pressable = targetPressable(stepInfoRef.current?.step, sc?.name === "step" && sc.bridge);
5289
+ if (el && pressable && el.contains(btn))
5290
+ return true;
5291
+ for (const a of stepInfoRef.current?.step.allow ?? []) {
5292
+ const ael = findAnchor(a);
5293
+ if (ael && ael.contains(btn))
5294
+ return true;
5295
+ }
5296
+ return false;
5297
+ };
5298
+ const onSubmit = (e) => {
5299
+ const sc = stateRef.current?.screen;
5300
+ if (!guardingRef.current || sc?.name !== "step")
5301
+ return;
5302
+ const form = e.target;
5303
+ if (!(form instanceof HTMLFormElement) || inGuideLayer(form))
5304
+ return;
5305
+ const submitter = e.submitter ?? null;
5306
+ if (submitAllowed(form, submitter))
5307
+ return;
5308
+ e.preventDefault();
5309
+ e.stopPropagation();
5310
+ dispatchRef.current({ type: "blocked" });
5311
+ };
4957
5312
  const onPointer = (e) => {
4958
5313
  const t = e.target;
4959
5314
  if (inGuideLayer(t)) {
@@ -5073,8 +5428,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5073
5428
  return;
5074
5429
  }
5075
5430
  }
5076
- if (modalRef.current || !allowedTarget(active))
5431
+ if (modalRef.current || !allowedTarget(active)) {
5077
5432
  stop();
5433
+ return;
5434
+ }
5435
+ // Enter in a field the step lets the person type in: no implicit
5436
+ // submit unless the form's submit control is allowed now.
5437
+ if (e.key === "Enter" && sc.name === "step" && submitsOnEnter(active) && active.form && !submitAllowed(active.form, null)) {
5438
+ e.preventDefault();
5439
+ }
5078
5440
  }
5079
5441
  };
5080
5442
  window.addEventListener("pointerdown", onPointer, true);
@@ -5084,7 +5446,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5084
5446
  window.addEventListener("focusin", onFocusIn, true);
5085
5447
  window.addEventListener("focusout", onFocusOut, true);
5086
5448
  window.addEventListener("keydown", onKey, true);
5449
+ window.addEventListener("submit", onSubmit, true);
5087
5450
  return () => {
5451
+ window.removeEventListener("submit", onSubmit, true);
5088
5452
  window.removeEventListener("pointerdown", onPointer, true);
5089
5453
  window.removeEventListener("mousedown", onPointer, true);
5090
5454
  window.removeEventListener("touchstart", onPointer, true);
@@ -5206,12 +5570,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5206
5570
  !due;
5207
5571
  useAfterIdle(passiveDue, PASSIVE_IDLE_MS, busy, () => dispatchRef.current({ type: "idleCelebrate" }), [state?.pendingCelebrate?.join(",")]);
5208
5572
  useEffect(() => {
5209
- if (!missionsLive || !state) {
5573
+ // Every tab shows "ไกด์เริ่มต้น n/N"; pressing it runs it in this tab.
5574
+ if (!missionsOn || !state) {
5210
5575
  publishGuideEntry(null, null);
5211
5576
  return;
5212
5577
  }
5213
- publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
5214
- }, [missionsLive, state, done, total]);
5578
+ publishGuideEntry({ done, total }, () => viaRunner(() => dispatchRef.current({ type: "openPanel" })));
5579
+ }, [missionsOn, state, done, total, viaRunner]);
5215
5580
  useEffect(() => () => publishGuideEntry(null, null), []);
5216
5581
  /* ---------------- page guides: this page's, the offer, the list ---------------- */
5217
5582
  // `when` anchors (LIFF's sub-screens) come and go without a route change:
@@ -5219,7 +5584,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5219
5584
  const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
5220
5585
  const [domTick, setDomTick] = useState(0);
5221
5586
  useEffect(() => {
5222
- if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
5587
+ if (!pagesOn || !hasWhen || typeof MutationObserver === "undefined")
5223
5588
  return;
5224
5589
  let t = null;
5225
5590
  const obs = new MutationObserver(records => {
@@ -5243,18 +5608,18 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5243
5608
  if (t)
5244
5609
  clearTimeout(t);
5245
5610
  };
5246
- }, [pagesLive, hasWhen]);
5611
+ }, [pagesOn, hasWhen]);
5247
5612
  const hasState = !!state;
5248
- const pageNow = useMemo(() => (pagesLive && hasState ? pageHere(suite, env()) : null),
5613
+ const pageNow = useMemo(() => (pagesOn && hasState ? pageHere(suite, env()) : null),
5249
5614
  // pathname/search/factsKey/domTick make it current.
5250
5615
  // eslint-disable-next-line react-hooks/exhaustive-deps
5251
- [pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
5252
- const listed = useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
5253
- const sectionsNow = useMemo(() => pagesLive && hasState && hasSections
5616
+ [pagesOn, hasState, suite, env, pathname, search, factsKey, domTick]);
5617
+ const listed = useMemo(() => (pagesOn && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesOn, suite, ctxIdle]);
5618
+ const sectionsNow = useMemo(() => pagesOn && hasState && hasSections
5254
5619
  ? sectionsAvailable(suite, env()).map(k => ({ key: k, title: findSection(suite, k)?.title ?? "" }))
5255
5620
  : [],
5256
5621
  // eslint-disable-next-line react-hooks/exhaustive-deps
5257
- [pagesLive, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
5622
+ [pagesOn, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
5258
5623
  const sectionsSig = sectionsNow.map(x => x.key).join(",");
5259
5624
  const openSection = useCallback((key) => {
5260
5625
  openingSection.current = key;
@@ -5265,14 +5630,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5265
5630
  openingSection.current = null;
5266
5631
  }
5267
5632
  }, []);
5268
- const flowsNow = useMemo(() => pagesLive && hasState && hasFlows
5633
+ const flowsNow = useMemo(() => pagesOn && hasState && hasFlows
5269
5634
  ? flowsAvailable(suite, env()).map(k => ({ key: k, title: findFlow(suite, k)?.title ?? "" }))
5270
5635
  : [],
5271
5636
  // eslint-disable-next-line react-hooks/exhaustive-deps
5272
- [pagesLive, hasState, hasFlows, suite, env, pathname, search, factsKey]);
5637
+ [pagesOn, hasState, hasFlows, suite, env, pathname, search, factsKey]);
5273
5638
  const flowsSig = flowsNow.map(x => x.key).join(",");
5274
5639
  /** The flows the panel lists: this person's, startable here or with somewhere to go. */
5275
- const flowsListed = 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]);
5640
+ const flowsListed = 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]);
5276
5641
  const openFlowHere = useCallback((key) => dispatchRef.current({ type: "startFlow", flow: key }), []);
5277
5642
  const openPageHere = useCallback(() => {
5278
5643
  const p = pageHere(suite, env());
@@ -5280,7 +5645,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5280
5645
  dispatchRef.current({ type: "startPage", page: p.key });
5281
5646
  }, [suite, env]);
5282
5647
  useEffect(() => {
5283
- if (!pagesLive || !hasState) {
5648
+ if (!pagesOn || !hasState) {
5284
5649
  publishPageGuides(null, null, null);
5285
5650
  return;
5286
5651
  }
@@ -5290,10 +5655,10 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5290
5655
  sections: sectionsNow,
5291
5656
  flows: flowsNow,
5292
5657
  flowsListed: flowsListed.length,
5293
- }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection, openFlowHere, () => dispatchRef.current({ type: "openPanel", section: "flows" }));
5658
+ }, () => viaRunner(openPageHere), () => viaRunner(() => dispatchRef.current({ type: "openPanel", section: "pages" })), (key) => viaRunner(() => openSection(key)), (key) => viaRunner(() => openFlowHere(key)), () => viaRunner(() => dispatchRef.current({ type: "openPanel", section: "flows" })));
5294
5659
  // sectionsSig / flowsSig stand for sectionsNow / flowsNow.
5295
5660
  // eslint-disable-next-line react-hooks/exhaustive-deps
5296
- }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere]);
5661
+ }, [pagesOn, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere, viaRunner]);
5297
5662
  useEffect(() => () => publishPageGuides(null, null, null), []);
5298
5663
  // The first-visit offer: in "offer" mode, nothing else up, the welcome
5299
5664
  // not due here, and a moment of nothing loading on this page.
@@ -5512,7 +5877,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5512
5877
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
5513
5878
  const kind = stepKind(step);
5514
5879
  const bridge = screen.bridge;
5515
- const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
5880
+ // What is ahead now decides เสร็จ; the number shown is the run's counter,
5881
+ // which never goes down (0.1.77).
5882
+ const raw = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
5883
+ const last = raw.no >= raw.total;
5884
+ const pos = screen.pos ?? raw;
5516
5885
  const page = screen.page ? findPage(suite, screen.page) : undefined;
5517
5886
  const section = screen.section ? findSection(suite, screen.section) : undefined;
5518
5887
  const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
@@ -5521,16 +5890,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5521
5890
  ? {
5522
5891
  ...read,
5523
5892
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
5524
- nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
5893
+ nextLabel: last ? "เสร็จ" : undefined,
5525
5894
  }
5526
5895
  : flow
5527
5896
  ? {
5528
5897
  heading: `พาทำ · ${flow.title}`,
5529
5898
  exitLabel: "ออกจากการพาทำ",
5530
- nextLabel: kind === "view" && pos.no >= pos.total ? "เสร็จ" : undefined,
5899
+ nextLabel: kind === "view" && last ? "เสร็จ" : undefined,
5531
5900
  }
5532
5901
  : {};
5533
- 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: {
5902
+ 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 }, bounds: layout.col, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
5534
5903
  missionNo,
5535
5904
  missionTitle: mission.title,
5536
5905
  replay: screen.replay,
@@ -5713,4 +6082,4 @@ function thaiMonthYear(m, opts = {}) {
5713
6082
  }
5714
6083
 
5715
6084
  export { API_EVENT as A, useGuideEntry as B, usePageGuideEntry as C, usePageGuides as D, useSectionGuideValues as E, GUIDE_EVENT_PREFIX as G, SectionGuideButton as S, THAI_MONTHS_SHORT as T, GUIDE_LAYER_ATTRIBUTE as a, GuideErrorBoundary as b, GuideProgressConflict as c, GuideRuntime as d, apiCallDetail as e, emitGuideEvent as f, fillRoute as g, formatBaht as h, guideAnchor as i, guideMenuItems as j, guideMenuShown as k, loadGuides as l, matchApiPath as m, matchRoute as n, openFlow as o, openFlowsPanel as p, openGuidePanel as q, openPageGuide as r, openPagesPanel as s, openSectionGuide as t, reportApiCall as u, reportApiEvent as v, setSectionGuideValues as w, storeGuides as x, thaiMonth as y, thaiMonthYear as z };
5716
- //# sourceMappingURL=format-BWUKNKxg.esm.js.map
6085
+ //# sourceMappingURL=format-Wgj2sKpK.esm.js.map