@eintrek/erp-shell 0.1.76 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -176,6 +176,33 @@ progress API: `API.md` next to it.
176
176
  shell's adapters), `guide-ui.tsx`, `guide-styles.ts`. Tests:
177
177
  `src/guide/*.test.ts`.
178
178
 
179
+ ### 0.1.77
180
+
181
+ Fixes from the apps' first week on 0.1.76.
182
+
183
+ - **No implicit submit during a step.** While a guide step is up (a mission,
184
+ a page or section guide, a flow), Enter in a field and a form's `submit`
185
+ are blocked unless the form's submit control — the button pressed, or
186
+ the one Enter would press — is the step's pressable target or in its
187
+ `allow`. Peoplx: Enter in an allowed field saved a draft and took a
188
+ document number.
189
+ - **Skipped → completed, once.** A page guide, section guide or flow left
190
+ once (skipped) records the person's later finish (completed). Completed
191
+ is final: a replay writes nothing; skipped again changes nothing.
192
+ - **A counter that never goes down.** "ขั้นที่ x/y": `x` counts the steps
193
+ actually shown in this run; `y` is worked out when a step is entered on a
194
+ page it was not worked out on, held while the run stays on that page,
195
+ and never lowered. Steps on other pages whose showing depends on that
196
+ page (`skipIf`, `onMissing: "skip"`) are counted once it is reached. So
197
+ the total can grow on a new page, never shrink (no more 1/5 → 2/6 → 2/5).
198
+ Going back a step (a checkpoint, ย้อนกลับ) moves `x` back.
199
+ - **Flows carry on between their pages.** When the person goes to the next
200
+ step's page by themselves (within the chain), the flow carries on there,
201
+ with no "ทำต่อ". ทำต่อ stays for going back to an earlier page and after
202
+ a reload.
203
+ - **`config.contentRoot`**: a selector for the app's content column (LIFF's
204
+ 460 px column on a desktop); bubbles stay inside it.
205
+
179
206
  ### 0.1.76
180
207
 
181
208
  Workflow guides ("พาทำงาน", phase 2; authors: `_claude-work/guides-r4/GUIDE-PAGES.md` §11).
@@ -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": {
@@ -2743,7 +2837,7 @@ function reduceGuide(state, action, suite, env) {
2743
2837
  if (!sectionsAvailable(suite, env).includes(action.section))
2744
2838
  break;
2745
2839
  const section = findSection(suite, action.section);
2746
- startSection(d, suite, env, section.key, !sectionRecorded(state, section));
2840
+ startSection(d, suite, env, section.key, rowStillDue(state.sections?.[section.key], section.version));
2747
2841
  break;
2748
2842
  }
2749
2843
  case "idleCelebrate": {
@@ -3189,7 +3283,10 @@ async function storePageRow(adapter, app, key, version, status, rev, kind = "pag
3189
3283
  }
3190
3284
  if (current.version > version)
3191
3285
  return null;
3192
- if (current.version === version && current.status !== "in_progress")
3286
+ // This version already recorded: kept, except a skipped row this run
3287
+ // finished (skipped → completed, once, shell ≥ 0.1.77).
3288
+ const finishing = status === "completed" && current.status === "skipped";
3289
+ if (current.version === version && current.status !== "in_progress" && !finishing)
3193
3290
  return current;
3194
3291
  at = current.rev;
3195
3292
  }
@@ -3856,6 +3953,9 @@ function useNudge(nudge, ref) {
3856
3953
  }
3857
3954
  function StepLayer(p) {
3858
3955
  const { spot, phone, viewport } = p;
3956
+ // Where the bubble may sit: the content column when the app has one.
3957
+ const col = p.bounds && p.bounds.width > 0 ? p.bounds : { x: 0, width: viewport.width || (typeof window !== "undefined" ? window.innerWidth : 0)};
3958
+ const colKey = `${col.x}|${col.width}`;
3859
3959
  const bubbleRef = React.useRef(null);
3860
3960
  const arrowRef = React.useRef(null);
3861
3961
  const ringRef = React.useRef(null);
@@ -3896,8 +3996,8 @@ function StepLayer(p) {
3896
3996
  const bw = el.offsetWidth;
3897
3997
  const bh = el.offsetHeight;
3898
3998
  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" });
3999
+ ? { left: col.x + m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
4000
+ : { left: col.x + (col.width - bw) / 2, top: viewport.offsetTop + (viewport.height - bh) / 2, side: "dock" });
3901
4001
  return;
3902
4002
  }
3903
4003
  if (phone) {
@@ -3918,7 +4018,7 @@ function StepLayer(p) {
3918
4018
  : overlapBot <= overlapTop
3919
4019
  ? botT
3920
4020
  : topT;
3921
- setPos({ left: m, top, side: "dock" });
4021
+ setPos({ left: col.x + m, top, side: "dock" });
3922
4022
  return;
3923
4023
  }
3924
4024
  let cancelled = false;
@@ -3940,7 +4040,7 @@ function StepLayer(p) {
3940
4040
  placement,
3941
4041
  middleware: [
3942
4042
  dom.offset(placement.startsWith("bottom") ? gap : 14),
3943
- dom.shift({ padding: m }),
4043
+ dom.shift({ padding: m, rootBoundary: { x: col.x, y: 0, width: col.width, height: window.innerHeight } }),
3944
4044
  ...(arrowRef.current ? [dom.arrow({ element: arrowRef.current, padding: 14 })] : []),
3945
4045
  ],
3946
4046
  });
@@ -3950,11 +4050,11 @@ function StepLayer(p) {
3950
4050
  return;
3951
4051
  const bw = el.offsetWidth;
3952
4052
  const bh = el.offsetHeight;
3953
- const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
4053
+ const bounds = { x: col.x + m, y: viewport.offsetTop + m, width: col.width - 2 * m, height: viewport.height - 2 * m };
3954
4054
  const i = pickPlacement(results, bw, bh, a, clear, bounds);
3955
4055
  if (i < 0) {
3956
4056
  // Nowhere beside it: the bottom-right corner.
3957
- setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
4057
+ setPos({ left: col.x + col.width - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
3958
4058
  return;
3959
4059
  }
3960
4060
  const { x, y, placement, middlewareData } = results[i];
@@ -3970,7 +4070,7 @@ function StepLayer(p) {
3970
4070
  };
3971
4071
  // The keys stand for spot, the lists, the holes and the bubble's size.
3972
4072
  // eslint-disable-next-line react-hooks/exhaustive-deps
3973
- }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
4073
+ }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop, colKey]);
3974
4074
  const popovers = p.popovers ?? [];
3975
4075
  const covered = !!p.covered;
3976
4076
  const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
@@ -3991,7 +4091,7 @@ function StepLayer(p) {
3991
4091
  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
4092
  left: pos?.left ?? 0,
3993
4093
  top: pos?.top ?? 0,
3994
- width: phone ? viewport.width - 32 : undefined,
4094
+ width: phone ? col.width - 32 : col.width < 352 ? col.width - 32 : undefined,
3995
4095
  visibility: pos ? "visible" : "hidden",
3996
4096
  }, 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
4097
  }
@@ -4359,6 +4459,42 @@ function runAbort(actions) {
4359
4459
  }, ABORT_DELAY_MS + i * ABORT_GAP_MS);
4360
4460
  });
4361
4461
  }
4462
+ /** The app's content column (GuideConfig.contentRoot), cut to the viewport; null when none is declared or found. */
4463
+ function contentColumn(selector, vw) {
4464
+ if (!selector || typeof document === "undefined")
4465
+ return null;
4466
+ let el = null;
4467
+ try {
4468
+ el = document.querySelector(selector);
4469
+ }
4470
+ catch {
4471
+ return null;
4472
+ }
4473
+ if (!el)
4474
+ return null;
4475
+ const r = el.getBoundingClientRect();
4476
+ const x = Math.max(0, r.left);
4477
+ const right = Math.min(vw || window.innerWidth, r.right);
4478
+ if (right - x <= 0)
4479
+ return null;
4480
+ return { x, y: 0, width: right - x, height: 0 };
4481
+ }
4482
+ /** The control a form's implicit submit (Enter in a field) presses: its first submit button. */
4483
+ function defaultSubmitControl(form) {
4484
+ for (const el of Array.from(form.elements)) {
4485
+ if (el instanceof HTMLButtonElement && (el.type === "submit" || !el.getAttribute("type")))
4486
+ return el;
4487
+ if (el instanceof HTMLInputElement && (el.type === "submit" || el.type === "image"))
4488
+ return el;
4489
+ }
4490
+ return null;
4491
+ }
4492
+ /** A field where Enter submits its form (not a textarea, a button or a select). */
4493
+ function submitsOnEnter(el) {
4494
+ if (!(el instanceof HTMLInputElement))
4495
+ return false;
4496
+ return !["button", "submit", "reset", "image", "file", "hidden"].includes(el.type);
4497
+ }
4362
4498
  /** A step's own target takes presses: not on a "view" step, unless the step allows it. */
4363
4499
  function targetPressable(step, bridge) {
4364
4500
  if (!step || bridge)
@@ -4737,6 +4873,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4737
4873
  const stepId = screen.name === "step" ? `${screen.mission}:${screen.index}:${screen.bridge ? "b" : ""}${screen.nothingToSave ? "n" : ""}` : "";
4738
4874
  /* Measure every frame while a step is shown: the spotlight follows
4739
4875
  scrolling, dialogs animating in, the drawer sliding, the keyboard. */
4876
+ const contentRoot = config.contentRoot;
4740
4877
  const [layout, setLayout] = React.useState({
4741
4878
  spot: null,
4742
4879
  covered: false,
@@ -4745,6 +4882,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4745
4882
  vw: 0,
4746
4883
  vh: 0,
4747
4884
  oy: 0,
4885
+ col: null,
4748
4886
  });
4749
4887
  React.useEffect(() => {
4750
4888
  if (!inStep) {
@@ -4753,7 +4891,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4753
4891
  }
4754
4892
  let raf = 0;
4755
4893
  let lostSince = null;
4756
- let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
4894
+ let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0, col: null };
4757
4895
  targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
4758
4896
  const allow = stepInfo?.step.allow ?? [];
4759
4897
  // The spotlight is the part of the target a person can see: cut by the
@@ -4813,13 +4951,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4813
4951
  holes.push(padRect(rectOf(ael), TARGET_PAD));
4814
4952
  }
4815
4953
  const covered = !!spot && coveredByDialog(targetElRef.current);
4816
- const next = { spot, covered, holes, popovers, vw, vh, oy };
4954
+ const col = contentColumn(contentRoot, vw);
4955
+ const next = { spot, covered, holes, popovers, vw, vh, oy, col };
4817
4956
  const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
4818
4957
  if (!sameRect(next.spot, last.spot) ||
4819
4958
  next.covered !== last.covered ||
4820
4959
  next.vw !== last.vw ||
4821
4960
  next.vh !== last.vh ||
4822
4961
  next.oy !== last.oy ||
4962
+ !sameRect(next.col, last.col) ||
4823
4963
  !sameList(next.holes, last.holes) ||
4824
4964
  !sameList(next.popovers, last.popovers)) {
4825
4965
  last = next;
@@ -4974,6 +5114,43 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4974
5114
  }
4975
5115
  return false;
4976
5116
  };
5117
+ /**
5118
+ * Whether a form may be submitted during this step (shell ≥ 0.1.77): only
5119
+ * when its submit control — the button pressed, or the one Enter in a
5120
+ * field would press — is the step's pressable target or in its `allow`.
5121
+ * Otherwise Enter in an allowed field would save a draft (and take a
5122
+ * document number) behind the guide's back.
5123
+ */
5124
+ const submitAllowed = (form, submitter) => {
5125
+ const btn = submitter ?? defaultSubmitControl(form);
5126
+ if (!btn)
5127
+ return false;
5128
+ const el = targetElRef.current;
5129
+ const sc = stateRef.current?.screen;
5130
+ const pressable = targetPressable(stepInfoRef.current?.step, sc?.name === "step" && sc.bridge);
5131
+ if (el && pressable && el.contains(btn))
5132
+ return true;
5133
+ for (const a of stepInfoRef.current?.step.allow ?? []) {
5134
+ const ael = findAnchor(a);
5135
+ if (ael && ael.contains(btn))
5136
+ return true;
5137
+ }
5138
+ return false;
5139
+ };
5140
+ const onSubmit = (e) => {
5141
+ const sc = stateRef.current?.screen;
5142
+ if (!guardingRef.current || sc?.name !== "step")
5143
+ return;
5144
+ const form = e.target;
5145
+ if (!(form instanceof HTMLFormElement) || inGuideLayer(form))
5146
+ return;
5147
+ const submitter = e.submitter ?? null;
5148
+ if (submitAllowed(form, submitter))
5149
+ return;
5150
+ e.preventDefault();
5151
+ e.stopPropagation();
5152
+ dispatchRef.current({ type: "blocked" });
5153
+ };
4977
5154
  const onPointer = (e) => {
4978
5155
  const t = e.target;
4979
5156
  if (inGuideLayer(t)) {
@@ -5093,8 +5270,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5093
5270
  return;
5094
5271
  }
5095
5272
  }
5096
- if (modalRef.current || !allowedTarget(active))
5273
+ if (modalRef.current || !allowedTarget(active)) {
5097
5274
  stop();
5275
+ return;
5276
+ }
5277
+ // Enter in a field the step lets the person type in: no implicit
5278
+ // submit unless the form's submit control is allowed now.
5279
+ if (e.key === "Enter" && sc.name === "step" && submitsOnEnter(active) && active.form && !submitAllowed(active.form, null)) {
5280
+ e.preventDefault();
5281
+ }
5098
5282
  }
5099
5283
  };
5100
5284
  window.addEventListener("pointerdown", onPointer, true);
@@ -5104,7 +5288,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5104
5288
  window.addEventListener("focusin", onFocusIn, true);
5105
5289
  window.addEventListener("focusout", onFocusOut, true);
5106
5290
  window.addEventListener("keydown", onKey, true);
5291
+ window.addEventListener("submit", onSubmit, true);
5107
5292
  return () => {
5293
+ window.removeEventListener("submit", onSubmit, true);
5108
5294
  window.removeEventListener("pointerdown", onPointer, true);
5109
5295
  window.removeEventListener("mousedown", onPointer, true);
5110
5296
  window.removeEventListener("touchstart", onPointer, true);
@@ -5532,7 +5718,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5532
5718
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
5533
5719
  const kind = stepKind(step);
5534
5720
  const bridge = screen.bridge;
5535
- const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
5721
+ // What is ahead now decides เสร็จ; the number shown is the run's counter,
5722
+ // which never goes down (0.1.77).
5723
+ const raw = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
5724
+ const last = raw.no >= raw.total;
5725
+ const pos = screen.pos ?? raw;
5536
5726
  const page = screen.page ? findPage(suite, screen.page) : undefined;
5537
5727
  const section = screen.section ? findSection(suite, screen.section) : undefined;
5538
5728
  const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
@@ -5541,16 +5731,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5541
5731
  ? {
5542
5732
  ...read,
5543
5733
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
5544
- nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
5734
+ nextLabel: last ? "เสร็จ" : undefined,
5545
5735
  }
5546
5736
  : flow
5547
5737
  ? {
5548
5738
  heading: `พาทำ · ${flow.title}`,
5549
5739
  exitLabel: "ออกจากการพาทำ",
5550
- nextLabel: kind === "view" && pos.no >= pos.total ? "เสร็จ" : undefined,
5740
+ nextLabel: kind === "view" && last ? "เสร็จ" : undefined,
5551
5741
  }
5552
5742
  : {};
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: {
5743
+ 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
5744
  missionNo,
5555
5745
  missionTitle: mission.title,
5556
5746
  replay: screen.replay,
@@ -5766,4 +5956,4 @@ exports.useGuideEntry = useGuideEntry;
5766
5956
  exports.usePageGuideEntry = usePageGuideEntry;
5767
5957
  exports.usePageGuides = usePageGuides;
5768
5958
  exports.useSectionGuideValues = useSectionGuideValues;
5769
- //# sourceMappingURL=format-BVoVc7Yk.cjs.map
5959
+ //# sourceMappingURL=format-DQLPHq7h.cjs.map