@eintrek/erp-shell 0.1.75 → 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.
@@ -357,26 +357,34 @@ function publishGuideEntry(next, openPanel) {
357
357
  emit();
358
358
  }
359
359
  }
360
- const NO_PAGES = { page: null, listed: 0, sections: [] };
360
+ const NO_PAGES = { page: null, listed: 0, sections: [], flows: [], flowsListed: 0 };
361
361
  let pages = NO_PAGES;
362
362
  let openPageImpl = null;
363
363
  let openPagesImpl = null;
364
364
  let openSectionImpl = null;
365
+ let openFlowImpl = null;
366
+ let openFlowsImpl = null;
365
367
  const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
366
368
  /** Called by the host: this page's guide, the listed count, the sections on screen, and how to open them. */
367
- function publishPageGuides(next, openPage, openPages, openSection = null) {
369
+ function publishPageGuides(next, openPage, openPages, openSection = null, openFlow = null, openFlows = null) {
368
370
  const n = next ?? NO_PAGES;
369
371
  openPageImpl = openPage;
370
372
  openPagesImpl = openPages;
371
373
  openSectionImpl = openSection;
374
+ openFlowImpl = openFlow;
375
+ openFlowsImpl = openFlows;
372
376
  const same = n.listed === pages.listed &&
377
+ (n.flowsListed ?? 0) === (pages.flowsListed ?? 0) &&
373
378
  (n.page?.title ?? null) === (pages.page?.title ?? null) &&
374
- sectionsKey(n.sections) === sectionsKey(pages.sections);
379
+ sectionsKey(n.sections) === sectionsKey(pages.sections) &&
380
+ sectionsKey(n.flows) === sectionsKey(pages.flows);
375
381
  if (!same) {
376
382
  const sections = (n.sections ?? []).map(x => ({ key: x.key, title: x.title }));
383
+ const flows = (n.flows ?? []).map(x => ({ key: x.key, title: x.title }));
384
+ const flowsListed = n.flowsListed ?? 0;
377
385
  pages =
378
- n.page || n.listed || sections.length
379
- ? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections }
386
+ n.page || n.listed || sections.length || flows.length || flowsListed
387
+ ? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections, flows, flowsListed }
380
388
  : NO_PAGES;
381
389
  emit();
382
390
  }
@@ -387,10 +395,14 @@ function guideMenuItems(e, p = NO_PAGES) {
387
395
  items.push("page");
388
396
  if ((p.sections ?? []).length > 0)
389
397
  items.push("sections");
398
+ if ((p.flows ?? []).length > 0)
399
+ items.push("flowsHere");
390
400
  if (e && e.total > 0)
391
401
  items.push("missions");
392
402
  if (p.listed > 0)
393
403
  items.push("pages");
404
+ if ((p.flowsListed ?? 0) > 0)
405
+ items.push("flows");
394
406
  if (items.length)
395
407
  items.push("help");
396
408
  return items;
@@ -444,6 +456,14 @@ function usePageGuideEntry() {
444
456
  function openSectionGuide(key) {
445
457
  openSectionImpl?.(key);
446
458
  }
459
+ /** Starts a flow that starts on this page ("พาทำ · <title>"). */
460
+ function openFlow(key) {
461
+ openFlowImpl?.(key);
462
+ }
463
+ /** Opens the panel on the flows alone ("พาทำงาน"). */
464
+ function openFlowsPanel() {
465
+ openFlowsImpl?.();
466
+ }
447
467
  const sectionValues = new Map();
448
468
  const valueListeners = new Set();
449
469
  let valuesVersion = 0;
@@ -821,6 +841,144 @@ function sectionShown(section, ctx) {
821
841
  function sectionsHere(suite, where, ctx) {
822
842
  return validSections(suite).filter(s => sectionApplies(s, where) && sectionShown(s, ctx));
823
843
  }
844
+ /* ------------------------------------------------------------------ */
845
+ /* Workflow guides ("พาทำงาน", shell 0.1.76) */
846
+ /* ------------------------------------------------------------------ */
847
+ /** The synthetic mission key of a flow: `flow:<key>`. */
848
+ const FLOW_PREFIX = "flow:";
849
+ function isFlowMissionKey(key) {
850
+ return !!key && key.startsWith(FLOW_PREFIX);
851
+ }
852
+ function flowKeyOf(missionKey) {
853
+ return isFlowMissionKey(missionKey) ? missionKey.slice(FLOW_PREFIX.length) : undefined;
854
+ }
855
+ const asList = (r) => (r === undefined ? [] : Array.isArray(r) ? r : [r]);
856
+ /** Why a flow cannot run, or null when it can. */
857
+ function flowProblem(app, flow) {
858
+ if (!flow || typeof flow.key !== "string" || !PAGE_KEY_RE.test(flow.key))
859
+ return "its key is not [a-z0-9-]";
860
+ if (`${app}.flow.${flow.key}`.length > ROW_KEY_MAX)
861
+ return `its row key is over ${ROW_KEY_MAX} characters`;
862
+ if (!Number.isInteger(flow.version) || flow.version < 1)
863
+ return "its version is not a whole number ≥ 1";
864
+ const starts = asList(flow.startRoute);
865
+ if (!starts.length || starts.some(r => typeof r !== "string" || !r.startsWith("/")))
866
+ return "it has no start route";
867
+ if (!Array.isArray(flow.steps) || flow.steps.length === 0)
868
+ return "it has no steps";
869
+ for (const st of flow.steps) {
870
+ const kind = st.kind ?? "click";
871
+ if (kind === "view" || kind === "click")
872
+ continue;
873
+ if (!flow.allowCommit)
874
+ return `step "${st.key}" is a ${kind} step and the flow does not set allowCommit`;
875
+ if (kind === "commit" && !st.abort)
876
+ return `commit step "${st.key}" has no abort`;
877
+ }
878
+ return null;
879
+ }
880
+ const validFlowCache = new WeakMap();
881
+ /** The suite's flows that can run (as validPages: the rest dropped, with a warning in development). */
882
+ function validFlows(suite) {
883
+ const hit = validFlowCache.get(suite);
884
+ if (hit)
885
+ return hit;
886
+ const out = [];
887
+ const seen = new Set();
888
+ for (const flow of suite.flows ?? []) {
889
+ let problem = flowProblem(suite.app, flow);
890
+ if (!problem && seen.has(flow.key))
891
+ problem = "another flow has the same key";
892
+ if (problem) {
893
+ const id = `${suite.key}:flow:${flow?.key}:${problem}`;
894
+ if (!warned.has(id)) {
895
+ warned.add(id);
896
+ devWarn(`flow "${flow?.key}" dropped: ${problem}`);
897
+ }
898
+ continue;
899
+ }
900
+ seen.add(flow.key);
901
+ out.push(flow);
902
+ }
903
+ validFlowCache.set(suite, out);
904
+ return out;
905
+ }
906
+ function findFlow(suite, key) {
907
+ return key ? validFlows(suite).find(f => f.key === key) : undefined;
908
+ }
909
+ const flowMissionCache = new WeakMap();
910
+ /** The flow as a mission the machine plays (in replay mode): `flow:<key>`, its steps as authored. */
911
+ function flowMission(flow) {
912
+ const hit = flowMissionCache.get(flow);
913
+ if (hit)
914
+ return hit;
915
+ const m = { key: FLOW_PREFIX + flow.key, title: flow.title, minutes: flow.minutes ?? 1, steps: flow.steps };
916
+ flowMissionCache.set(flow, m);
917
+ return m;
918
+ }
919
+ /**
920
+ * The flow's chain: its start routes, every step's `route` and route
921
+ * trigger, and its `transit` routes. Leaving all of them ends the flow.
922
+ */
923
+ function flowChain(flow) {
924
+ const out = new Set(asList(flow.startRoute));
925
+ for (const st of flow.steps) {
926
+ if (st.route)
927
+ out.add(st.route);
928
+ const triggers = st.advanceOn ? (Array.isArray(st.advanceOn) ? st.advanceOn : [st.advanceOn]) : [];
929
+ for (const t of triggers)
930
+ if ("route" in t && t.route)
931
+ out.add(t.route);
932
+ }
933
+ for (const t of flow.transit ?? [])
934
+ out.add(t);
935
+ return Array.from(out);
936
+ }
937
+ /** Path patterns only (`?k=v` dropped): the chain is about pages, not their tabs. */
938
+ const pathOf = (pattern) => (pattern.includes("?") ? pattern.slice(0, pattern.indexOf("?")) : pattern);
939
+ /** Whether the person is on one of the flow's pages. */
940
+ function inFlowChain(flow, where) {
941
+ return flowChain(flow).some(r => !!matchRoute(pathOf(r), where.pathname, null, where.code));
942
+ }
943
+ /** Whether the person is on the flow's start page. */
944
+ function onFlowStart(flow, where) {
945
+ return asList(flow.startRoute).some(r => !!matchRoute(r, where.pathname, where.search, where.code));
946
+ }
947
+ /** Whether this person has the flow: `visible` (only true) and `requires`. */
948
+ function flowShown(flow, ctx) {
949
+ try {
950
+ if (flow.visible && flow.visible(ctx) !== true)
951
+ return false;
952
+ if (flow.requires && !ctx.can(flow.requires))
953
+ return false;
954
+ return true;
955
+ }
956
+ catch {
957
+ return false;
958
+ }
959
+ }
960
+ /** The flows this person has (the panel's "พาทำงาน" list), in the suite's order. */
961
+ function shownFlows(suite, ctx) {
962
+ return validFlows(suite).filter(f => flowShown(f, ctx));
963
+ }
964
+ /** Where the panel sends the person to start a flow, or null when it needs an id it does not have. */
965
+ function flowHref(flow, ctx) {
966
+ let raw;
967
+ try {
968
+ raw = flow.href === undefined ? undefined : typeof flow.href === "function" ? flow.href(ctx) : flow.href;
969
+ }
970
+ catch {
971
+ raw = undefined;
972
+ }
973
+ if (raw)
974
+ return /^https?:\/\//i.test(raw) ? raw : (fillRoute(raw, { code: ctx.code }) ?? null);
975
+ for (const r of asList(flow.startRoute)) {
976
+ const filled = fillRoute(r, { code: ctx.code });
977
+ if (filled)
978
+ return filled;
979
+ }
980
+ return null;
981
+ }
824
982
 
825
983
  /* ------------------------------------------------------------------ */
826
984
  /* Copy */
@@ -836,6 +994,7 @@ const COPY = {
836
994
  aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
837
995
  pageDone: "จบคู่มือหน้านี้",
838
996
  sectionDone: "จบคำอธิบายส่วนนี้",
997
+ flowDone: "พาทำงานเสร็จแล้ว",
839
998
  bridgeTitle: "กดปุ่มเมนู",
840
999
  bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
841
1000
  };
@@ -849,9 +1008,11 @@ function initialGuideState(audience, progress, pages) {
849
1008
  pagesMode: pages?.pagesMode ?? "off",
850
1009
  pages: pages?.pages ?? {},
851
1010
  sections: pages?.sections ?? {},
1011
+ flows: pages?.flows ?? {},
852
1012
  pageRun: null,
853
1013
  panelSection: "all",
854
1014
  autoPassed: null,
1015
+ counter: null,
855
1016
  audience,
856
1017
  progress,
857
1018
  screen: { name: "idle" },
@@ -875,6 +1036,10 @@ function findMission(suite, key) {
875
1036
  const section = findSection(suite, sectionKeyOf(key));
876
1037
  return section ? sectionMission(section) : undefined;
877
1038
  }
1039
+ if (isFlowMissionKey(key)) {
1040
+ const flow = findFlow(suite, flowKeyOf(key));
1041
+ return flow ? flowMission(flow) : undefined;
1042
+ }
878
1043
  return suite.missions.find(m => m.key === key);
879
1044
  }
880
1045
  /**
@@ -1226,7 +1391,7 @@ function announceStep(d, suite, env) {
1226
1391
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1227
1392
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
1228
1393
  const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
1229
- 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));
1230
1395
  d.effects.push({
1231
1396
  type: "announce",
1232
1397
  text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
@@ -1260,6 +1425,54 @@ function stepPosition(mission, index, env, context, autoPassed = []) {
1260
1425
  });
1261
1426
  return { no: Math.max(no, 1), total: Math.max(total, 1) };
1262
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
+ }
1263
1476
  /** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
1264
1477
  function wouldAutoPass(step, env) {
1265
1478
  const kind = stepKind(step);
@@ -1323,11 +1536,13 @@ function enterStep(d, suite, env, mission, index, opts) {
1323
1536
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1324
1537
  const page = pageKeyOf(mission.key);
1325
1538
  const section = sectionKeyOf(mission.key);
1539
+ const flow = flowKeyOf(mission.key);
1326
1540
  const where = placeRoute(steps, i);
1327
1541
  // A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
1328
1542
  const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
1329
1543
  const bridge = !away && needsBridge(step, env, ctx);
1330
1544
  const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
1545
+ const pos = countStep(d, env, mission, i);
1331
1546
  setScreen(d, {
1332
1547
  name: "step",
1333
1548
  mission: mission.key,
@@ -1344,6 +1559,8 @@ function enterStep(d, suite, env, mission, index, opts) {
1344
1559
  replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
1345
1560
  ...(page ? { page } : {}),
1346
1561
  ...(section ? { section } : {}),
1562
+ ...(flow ? { flow } : {}),
1563
+ pos,
1347
1564
  });
1348
1565
  if (!opts.replay) {
1349
1566
  const cp = checkpointAtOrBefore(steps, i);
@@ -1508,9 +1725,12 @@ function advance(d, suite, env, capture) {
1508
1725
  enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
1509
1726
  }
1510
1727
  function finishMission(d, suite, env, mission, replay) {
1511
- if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
1728
+ if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key) || isFlowMissionKey(mission.key)) {
1512
1729
  endPage(d, suite, "completed");
1513
- d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
1730
+ d.effects.push({
1731
+ type: "toast",
1732
+ text: isSectionMissionKey(mission.key) ? COPY.sectionDone : isFlowMissionKey(mission.key) ? COPY.flowDone : COPY.pageDone,
1733
+ });
1514
1734
  return;
1515
1735
  }
1516
1736
  if (replay) {
@@ -1615,7 +1835,7 @@ function startMission(d, suite, env, key, replay) {
1615
1835
  const isReplay = replay || entry?.status === "done";
1616
1836
  const before = d.state.progress;
1617
1837
  // A new run: what passed by itself is counted afresh.
1618
- d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
1838
+ d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null, counter: null };
1619
1839
  if (d.state.progress.welcome === "not_seen")
1620
1840
  withProgress(d, { welcome: "started" });
1621
1841
  if (!isReplay && d.state.progress.mission_key !== key)
@@ -1765,24 +1985,33 @@ function pageStillHere(suite, key, env) {
1765
1985
  const page = findPage(suite, key);
1766
1986
  return !!page && pageMatchScore(page, whereOf(env)) !== null;
1767
1987
  }
1768
- /** 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. */
1769
2003
  function recordPage(d, suite, key, status) {
1770
2004
  const page = findPage(suite, key);
1771
- if (!page || pageRecorded(d.state, page))
2005
+ if (!page || !rowWritable(d.state.pages[key], page.version, status))
1772
2006
  return;
1773
2007
  const prev = d.state.pages[key];
1774
2008
  d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
1775
2009
  d.effects.push({ type: "savePage", key, version: page.version, status });
1776
2010
  }
1777
- /** The section guide's row says this person has ended this version of it. */
1778
- function sectionRecorded(state, section) {
1779
- const row = state.sections?.[section.key];
1780
- return !!row && row.version >= section.version;
1781
- }
1782
2011
  /** Write a section guide's row (once per section and version). */
1783
2012
  function recordSection(d, suite, key, status) {
1784
2013
  const section = findSection(suite, key);
1785
- if (!section || sectionRecorded(d.state, section))
2014
+ if (!section || !rowWritable(d.state.sections?.[key], section.version, status))
1786
2015
  return;
1787
2016
  const prev = d.state.sections?.[key];
1788
2017
  d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
@@ -1805,18 +2034,115 @@ function readStillHere(suite, r, env) {
1805
2034
  const section = findSection(suite, r.key);
1806
2035
  return !!section && sectionApplies(section, whereOf(env));
1807
2036
  }
1808
- /** The page or section guide ends: its row written when it is due one; back to idle. */
2037
+ /** The flow's row says this person has ended this version of it. */
2038
+ function flowRecorded(state, flow) {
2039
+ const row = state.flows?.[flow.key];
2040
+ return !!row && row.version >= flow.version;
2041
+ }
2042
+ /** Write a flow's row (once per flow and version). */
2043
+ function recordFlow(d, suite, key, status) {
2044
+ const flow = findFlow(suite, key);
2045
+ if (!flow || !rowWritable(d.state.flows?.[key], flow.version, status))
2046
+ return;
2047
+ const prev = d.state.flows?.[key];
2048
+ d.state = { ...d.state, flows: { ...d.state.flows, [key]: { version: flow.version, status, rev: prev?.rev ?? 0 } } };
2049
+ d.effects.push({ type: "savePage", key, version: flow.version, status, kind: "flow" });
2050
+ }
2051
+ /** The page guide, section guide or flow ends: its row written when it is due one; back to idle. */
1809
2052
  function endPage(d, suite, how) {
1810
2053
  const run = d.state.pageRun;
1811
2054
  if (run?.write) {
1812
2055
  if (run.kind === "section")
1813
2056
  recordSection(d, suite, run.key, how);
2057
+ else if (run.kind === "flow")
2058
+ recordFlow(d, suite, run.key, how);
1814
2059
  else
1815
2060
  recordPage(d, suite, run.key, how);
1816
2061
  }
2062
+ // A flow's captured ids were its own: the tour's come back.
2063
+ if (run?.kind === "flow" && run.context)
2064
+ withProgress(d, { context: run.context });
1817
2065
  d.state = { ...d.state, pageRun: null, replayPage: null };
1818
2066
  setScreen(d, { name: "idle" });
1819
2067
  }
2068
+ /** The flow on screen, or null. */
2069
+ function flowOf(s) {
2070
+ return s.name === "step" && s.flow ? s.flow : null;
2071
+ }
2072
+ /** The flows this person has that start on this page ("พาทำ · <title>"). */
2073
+ function flowsAvailable(suite, env) {
2074
+ if (!suite.flows?.length)
2075
+ return [];
2076
+ const ctx = guideContext(env, {});
2077
+ const where = whereOf(env);
2078
+ return (suite.flows ?? [])
2079
+ .map(f => findFlow(suite, f?.key))
2080
+ .filter((f) => !!f && onFlowStart(f, where) && flowShown(f, ctx))
2081
+ .map(f => f.key);
2082
+ }
2083
+ /**
2084
+ * The flow to offer now, or null: only in "offer" mode, only a flow that
2085
+ * says `offer: true`, on its start page, with no row of this version, when
2086
+ * nothing else is up and this page's own guide is not due (it comes first:
2087
+ * no guide here, offer: false, or already recorded), its first step's
2088
+ * target on screen, no app dialog open. The host adds the welcome and the
2089
+ * idle wait, as for the page guide's offer.
2090
+ */
2091
+ function flowOfferDue(state, suite, env) {
2092
+ if (state.pagesMode !== "offer" || state.pageAudience === "none")
2093
+ return null;
2094
+ if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
2095
+ return null;
2096
+ const page = pageHere(suite, env);
2097
+ if (page && page.offer !== false && !pageRecorded(state, page))
2098
+ return null;
2099
+ if (safe(() => env.dialogOpen?.() === true, false))
2100
+ return null;
2101
+ for (const key of flowsAvailable(suite, env)) {
2102
+ const flow = findFlow(suite, key);
2103
+ if (flow.offer !== true || flowRecorded(state, flow))
2104
+ continue;
2105
+ const mission = flowMission(flow);
2106
+ const first = mission.steps.find(st => {
2107
+ const ctx = guideContext(env, {});
2108
+ if (st.requires && !safe(() => env.can(st.requires), true))
2109
+ return false;
2110
+ return !(st.skipIf && safe(() => st.skipIf(ctx), false));
2111
+ });
2112
+ if (first && !stepTargetVisible(first, env, guideContext(env, {}), false))
2113
+ continue;
2114
+ return key;
2115
+ }
2116
+ return null;
2117
+ }
2118
+ function startFlow(d, suite, env, key, write, at) {
2119
+ const flow = findFlow(suite, key);
2120
+ if (!flow)
2121
+ return;
2122
+ const mission = flowMission(flow);
2123
+ const saved = d.state.progress.context;
2124
+ d.state = {
2125
+ ...d.state,
2126
+ panelOpen: false,
2127
+ justDone: null,
2128
+ pageRun: { key, write, kind: "flow", context: saved },
2129
+ autoPassed: null,
2130
+ counter: null,
2131
+ replayPage: {
2132
+ mission: mission.key,
2133
+ anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
2134
+ dialog: safe(() => env.dialogOpen?.() === true, false),
2135
+ },
2136
+ };
2137
+ withProgress(d, { context: at?.context ?? {} });
2138
+ let index = 0;
2139
+ if (at) {
2140
+ const stepAt = mission.steps.findIndex(st => st.key === at.step);
2141
+ if (stepAt > 0)
2142
+ index = checkpointAtOrBefore(mission.steps, stepAt);
2143
+ }
2144
+ enterStep(d, suite, env, mission, index, { replay: true });
2145
+ }
1820
2146
  /** The section guides on screen here that this person has (the "?" entries, the buttons). */
1821
2147
  function sectionsAvailable(suite, env) {
1822
2148
  return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
@@ -1832,6 +2158,7 @@ function startSection(d, suite, env, key, write) {
1832
2158
  justDone: null,
1833
2159
  pageRun: { key, write, kind: "section" },
1834
2160
  autoPassed: null,
2161
+ counter: null,
1835
2162
  replayPage: {
1836
2163
  mission: mission.key,
1837
2164
  anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
@@ -1851,6 +2178,7 @@ function startPage(d, suite, env, key, write) {
1851
2178
  justDone: null,
1852
2179
  pageRun: { key, write, kind: "page" },
1853
2180
  autoPassed: null,
2181
+ counter: null,
1854
2182
  // What is open now is the person's: ending the guide leaves it alone.
1855
2183
  replayPage: {
1856
2184
  mission: mission.key,
@@ -1894,6 +2222,28 @@ function backTarget(mission, index, env, context) {
1894
2222
  }
1895
2223
  return prev >= 0 ? { index: prev, close: false } : null;
1896
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
+ }
1897
2247
  function reduceGuide(state, action, suite, env) {
1898
2248
  const d = { state, effects: [] };
1899
2249
  const s = state.screen;
@@ -1950,7 +2300,7 @@ function reduceGuide(state, action, suite, env) {
1950
2300
  break;
1951
2301
  // A mission always wins over a page guide: a running one ends (its row
1952
2302
  // written if due), an offer goes unrecorded.
1953
- if (readOf(s))
2303
+ if (readOf(s) || flowOf(s))
1954
2304
  endPage(d, suite, "skipped");
1955
2305
  else if (s.name === "pageOffer")
1956
2306
  setScreen(d, { name: "idle" });
@@ -1971,13 +2321,21 @@ function reduceGuide(state, action, suite, env) {
1971
2321
  const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
1972
2322
  const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
1973
2323
  const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
1974
- d.state = { ...d.state, autoPassed: null };
2324
+ d.state = { ...d.state, autoPassed: null, counter: null };
1975
2325
  enterStep(d, suite, env, mission, index, { replay: false });
1976
2326
  break;
1977
2327
  }
1978
2328
  case "sync": {
1979
2329
  if (s.name === "pageOffer") {
1980
2330
  // Ignored: the person left the page (or its sub-screen). Recorded.
2331
+ if (s.flow) {
2332
+ const flow = findFlow(suite, s.page);
2333
+ if (!flow || !onFlowStart(flow, whereOf(env))) {
2334
+ recordFlow(d, suite, s.page, "skipped");
2335
+ setScreen(d, { name: "idle" });
2336
+ }
2337
+ break;
2338
+ }
1981
2339
  if (!pageStillHere(suite, s.page, env)) {
1982
2340
  recordPage(d, suite, s.page, "skipped");
1983
2341
  setScreen(d, { name: "idle" });
@@ -1986,6 +2344,15 @@ function reduceGuide(state, action, suite, env) {
1986
2344
  }
1987
2345
  if (s.name !== "step")
1988
2346
  break;
2347
+ if (s.flow) {
2348
+ // Off the flow's pages: it ends, silently. Between them it goes on
2349
+ // (a step on another of its pages shows "ทำต่อ").
2350
+ const flow = findFlow(suite, s.flow);
2351
+ if (!flow || !inFlowChain(flow, env)) {
2352
+ endPage(d, suite, "skipped");
2353
+ break;
2354
+ }
2355
+ }
1989
2356
  const read = readOf(s);
1990
2357
  if (read && !readStillHere(suite, read, env)) {
1991
2358
  // Leaving the page (or the section's root going) ends its guide, silently.
@@ -1997,6 +2364,15 @@ function reduceGuide(state, action, suite, env) {
1997
2364
  break;
1998
2365
  const step = mission.steps[s.index];
1999
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
+ }
2000
2376
  if (s.phase === "away") {
2001
2377
  const where = placeRoute(mission.steps, s.index);
2002
2378
  if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
@@ -2175,7 +2551,7 @@ function reduceGuide(state, action, suite, env) {
2175
2551
  advance(d, suite, env);
2176
2552
  }
2177
2553
  else if (policy === "abort") {
2178
- if (readOf(s))
2554
+ if (readOf(s) || flowOf(s))
2179
2555
  endPage(d, suite, "skipped");
2180
2556
  else
2181
2557
  exitMission(d);
@@ -2217,7 +2593,7 @@ function reduceGuide(state, action, suite, env) {
2217
2593
  case "exit":
2218
2594
  if (s.name !== "step")
2219
2595
  break;
2220
- if (readOf(s)) {
2596
+ if (readOf(s) || flowOf(s)) {
2221
2597
  // ✕, Escape or ปิดคู่มือ: ends silently.
2222
2598
  pushAbort(d, suite, s);
2223
2599
  endPage(d, suite, "skipped");
@@ -2245,6 +2621,11 @@ function reduceGuide(state, action, suite, env) {
2245
2621
  case "endWithoutSave":
2246
2622
  if (s.name !== "step" || !s.replay)
2247
2623
  break;
2624
+ if (flowOf(s)) {
2625
+ pushAbort(d, suite, s);
2626
+ endPage(d, suite, "skipped");
2627
+ break;
2628
+ }
2248
2629
  pushAbort(d, suite, s);
2249
2630
  setScreen(d, { name: "idle" });
2250
2631
  d.effects.push({ type: "toast", text: COPY.replayEnded });
@@ -2293,7 +2674,7 @@ function reduceGuide(state, action, suite, env) {
2293
2674
  break;
2294
2675
  case "openPanel": {
2295
2676
  const section = action.section ?? "all";
2296
- const allowed = section === "pages" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
2677
+ const allowed = section !== "all" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
2297
2678
  if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
2298
2679
  break;
2299
2680
  // The offer gives way to the panel, unrecorded.
@@ -2346,6 +2727,13 @@ function reduceGuide(state, action, suite, env) {
2346
2727
  break;
2347
2728
  }
2348
2729
  case "pageOffer": {
2730
+ if (action.flow) {
2731
+ if (flowOfferDue(state, suite, env) !== action.page)
2732
+ break;
2733
+ setScreen(d, { name: "pageOffer", page: action.page, flow: true });
2734
+ d.effects.push({ type: "announce", text: `พาทำงาน ${findFlow(suite, action.page)?.title ?? ""}` });
2735
+ break;
2736
+ }
2349
2737
  if (pageOfferDue(state, suite, env) !== action.page)
2350
2738
  break;
2351
2739
  setScreen(d, { name: "pageOffer", page: action.page });
@@ -2355,14 +2743,42 @@ function reduceGuide(state, action, suite, env) {
2355
2743
  case "pageOfferStart":
2356
2744
  if (s.name !== "pageOffer" || state.pageAudience === "none")
2357
2745
  break;
2358
- startPage(d, suite, env, s.page, true);
2746
+ if (s.flow)
2747
+ startFlow(d, suite, env, s.page, true);
2748
+ else
2749
+ startPage(d, suite, env, s.page, true);
2359
2750
  break;
2360
2751
  case "pageOfferLater":
2361
2752
  if (s.name !== "pageOffer")
2362
2753
  break;
2363
- recordPage(d, suite, s.page, "skipped");
2754
+ if (s.flow)
2755
+ recordFlow(d, suite, s.page, "skipped");
2756
+ else
2757
+ recordPage(d, suite, s.page, "skipped");
2364
2758
  setScreen(d, { name: "idle" });
2365
2759
  break;
2760
+ case "startFlow": {
2761
+ // Only on its start page; never over a mission step, a welcome, a gate
2762
+ // or a celebration.
2763
+ if (state.pageAudience === "none" || state.pagesMode === "off")
2764
+ break;
2765
+ if (s.name !== "idle" && s.name !== "pageOffer")
2766
+ break;
2767
+ if (!flowsAvailable(suite, env).includes(action.flow))
2768
+ break;
2769
+ const flow = findFlow(suite, action.flow);
2770
+ startFlow(d, suite, env, flow.key, rowStillDue(state.flows?.[flow.key], flow.version));
2771
+ break;
2772
+ }
2773
+ case "resumeFlow": {
2774
+ if (state.pageAudience === "none" || state.pagesMode === "off" || s.name !== "idle")
2775
+ break;
2776
+ const flow = findFlow(suite, action.flow);
2777
+ if (!flow || !flowShown(flow, guideContext(env, {})) || !inFlowChain(flow, whereOf(env)))
2778
+ break;
2779
+ startFlow(d, suite, env, flow.key, action.write && rowStillDue(state.flows?.[flow.key], flow.version), { step: action.step, context: action.context });
2780
+ break;
2781
+ }
2366
2782
  case "startPage": {
2367
2783
  // Never over a mission step, a welcome, a gate or a celebration.
2368
2784
  if (state.pageAudience === "none" || state.pagesMode === "off")
@@ -2372,7 +2788,7 @@ function reduceGuide(state, action, suite, env) {
2372
2788
  const page = pageHere(suite, env);
2373
2789
  if (!page || page.key !== action.page)
2374
2790
  break;
2375
- startPage(d, suite, env, page.key, !pageRecorded(state, page));
2791
+ startPage(d, suite, env, page.key, rowStillDue(state.pages[page.key], page.version));
2376
2792
  break;
2377
2793
  }
2378
2794
  case "back": {
@@ -2391,6 +2807,13 @@ function reduceGuide(state, action, suite, env) {
2391
2807
  break;
2392
2808
  }
2393
2809
  case "pageRow": {
2810
+ if (action.kind === "flow") {
2811
+ const cur = d.state.flows?.[action.key];
2812
+ if (cur && cur.version > action.row.version)
2813
+ break;
2814
+ d.state = { ...d.state, flows: { ...d.state.flows, [action.key]: action.row } };
2815
+ break;
2816
+ }
2394
2817
  if (action.kind === "section") {
2395
2818
  const cur = d.state.sections?.[action.key];
2396
2819
  if (cur && cur.version > action.row.version)
@@ -2414,7 +2837,7 @@ function reduceGuide(state, action, suite, env) {
2414
2837
  if (!sectionsAvailable(suite, env).includes(action.section))
2415
2838
  break;
2416
2839
  const section = findSection(suite, action.section);
2417
- startSection(d, suite, env, section.key, !sectionRecorded(state, section));
2840
+ startSection(d, suite, env, section.key, rowStillDue(state.sections?.[section.key], section.version));
2418
2841
  break;
2419
2842
  }
2420
2843
  case "idleCelebrate": {
@@ -2546,6 +2969,7 @@ function parseGuidesAnswer(payload, suite) {
2546
2969
  : "none";
2547
2970
  const pages = parsePageRows(rows, suite.app);
2548
2971
  const sections = parsePageRows(rows, suite.app, "section");
2972
+ const flows = parsePageRows(rows, suite.app, "flow");
2549
2973
  let row = null;
2550
2974
  for (const r of rows) {
2551
2975
  if (isRecord(r) && r.guide_key === suite.key) {
@@ -2566,9 +2990,9 @@ function parseGuidesAnswer(payload, suite) {
2566
2990
  progress = row;
2567
2991
  newerOnServer = row.guide_version > suite.version;
2568
2992
  }
2569
- return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
2993
+ return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections, flows };
2570
2994
  }
2571
- /** The row key of a page guide, `<app>.page.<key>`, or a section guide, `<app>.section.<key>`. */
2995
+ /** The row key of a page guide `<app>.page.<key>`, a section guide `<app>.section.<key>`, or a flow `<app>.flow.<key>`. */
2572
2996
  function pageRowKey(app, key, kind = "page") {
2573
2997
  return `${app}.${kind}.${key}`;
2574
2998
  }
@@ -2859,7 +3283,10 @@ async function storePageRow(adapter, app, key, version, status, rev, kind = "pag
2859
3283
  }
2860
3284
  if (current.version > version)
2861
3285
  return null;
2862
- 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)
2863
3290
  return current;
2864
3291
  at = current.rev;
2865
3292
  }
@@ -3526,6 +3953,9 @@ function useNudge(nudge, ref) {
3526
3953
  }
3527
3954
  function StepLayer(p) {
3528
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}`;
3529
3959
  const bubbleRef = React.useRef(null);
3530
3960
  const arrowRef = React.useRef(null);
3531
3961
  const ringRef = React.useRef(null);
@@ -3566,8 +3996,8 @@ function StepLayer(p) {
3566
3996
  const bw = el.offsetWidth;
3567
3997
  const bh = el.offsetHeight;
3568
3998
  setPos(phone
3569
- ? { left: m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
3570
- : { 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" });
3571
4001
  return;
3572
4002
  }
3573
4003
  if (phone) {
@@ -3588,7 +4018,7 @@ function StepLayer(p) {
3588
4018
  : overlapBot <= overlapTop
3589
4019
  ? botT
3590
4020
  : topT;
3591
- setPos({ left: m, top, side: "dock" });
4021
+ setPos({ left: col.x + m, top, side: "dock" });
3592
4022
  return;
3593
4023
  }
3594
4024
  let cancelled = false;
@@ -3610,7 +4040,7 @@ function StepLayer(p) {
3610
4040
  placement,
3611
4041
  middleware: [
3612
4042
  dom.offset(placement.startsWith("bottom") ? gap : 14),
3613
- dom.shift({ padding: m }),
4043
+ dom.shift({ padding: m, rootBoundary: { x: col.x, y: 0, width: col.width, height: window.innerHeight } }),
3614
4044
  ...(arrowRef.current ? [dom.arrow({ element: arrowRef.current, padding: 14 })] : []),
3615
4045
  ],
3616
4046
  });
@@ -3620,11 +4050,11 @@ function StepLayer(p) {
3620
4050
  return;
3621
4051
  const bw = el.offsetWidth;
3622
4052
  const bh = el.offsetHeight;
3623
- 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 };
3624
4054
  const i = pickPlacement(results, bw, bh, a, clear, bounds);
3625
4055
  if (i < 0) {
3626
4056
  // Nowhere beside it: the bottom-right corner.
3627
- 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" });
3628
4058
  return;
3629
4059
  }
3630
4060
  const { x, y, placement, middlewareData } = results[i];
@@ -3640,7 +4070,7 @@ function StepLayer(p) {
3640
4070
  };
3641
4071
  // The keys stand for spot, the lists, the holes and the bubble's size.
3642
4072
  // eslint-disable-next-line react-hooks/exhaustive-deps
3643
- }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
4073
+ }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop, colKey]);
3644
4074
  const popovers = p.popovers ?? [];
3645
4075
  const covered = !!p.covered;
3646
4076
  const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
@@ -3661,7 +4091,7 @@ function StepLayer(p) {
3661
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: {
3662
4092
  left: pos?.left ?? 0,
3663
4093
  top: pos?.top ?? 0,
3664
- width: phone ? viewport.width - 32 : undefined,
4094
+ width: phone ? col.width - 32 : col.width < 352 ? col.width - 32 : undefined,
3665
4095
  visibility: pos ? "visible" : "hidden",
3666
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] }));
3667
4097
  }
@@ -3750,7 +4180,10 @@ function CompleteModal(p) {
3750
4180
  * corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
3751
4181
  */
3752
4182
  function PageOfferCard(p) {
3753
- return (jsxRuntime.jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsxRuntime.jsx("p", { className: "eg-po-e", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxRuntime.jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
4183
+ return (jsxRuntime.jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsxRuntime.jsx("p", { className: "eg-po-e", children: p.eyebrow ?? "คู่มือหน้านี้" }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxRuntime.jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
4184
+ }
4185
+ function FlowList({ items, onOpen }) {
4186
+ return (jsxRuntime.jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxRuntime.jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: it.title }), jsxRuntime.jsx("small", { children: it.done ? "ทำแล้ว" : it.minutes ? `ประมาณ ${it.minutes} นาที` : "ใหม่" })] }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: it.done ? "ทำอีกครั้ง" : "เริ่ม" })] }, it.key))) }));
3754
4187
  }
3755
4188
  function PageList({ items, onOpen }) {
3756
4189
  return (jsxRuntime.jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxRuntime.jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: it.title }), jsxRuntime.jsx("small", { className: it.seen ? undefined : "eg-new", children: it.seen ? "ดูแล้ว" : "ใหม่" })] }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: "\u0E40\u0E1B\u0E34\u0E14" })] }, it.key))) }));
@@ -3777,12 +4210,16 @@ function QuestChip({ done, total, pulse, onOpen }) {
3777
4210
  }
3778
4211
  function QuestPanel(p) {
3779
4212
  const ref = useAutoFocus();
3780
- const pages = p.pages ?? [];
4213
+ const pages = p.only === "flows" ? [] : (p.pages ?? []);
4214
+ const flows = p.only === "pages" ? [] : (p.flows ?? []);
3781
4215
  const openPage = p.onOpenPage ?? (() => { });
4216
+ const openFlow = p.onOpenFlow ?? (() => { });
4217
+ const flowSection = flows.length ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h3", { className: "eg-q-sub", id: "eg-q-flows", children: "\u0E1E\u0E32\u0E17\u0E33\u0E07\u0E32\u0E19" }), jsxRuntime.jsx(FlowList, { items: flows, onOpen: openFlow })] })) : null;
3782
4218
  if (p.showMissions === false) {
3783
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })] }));
4219
+ const heading = pages.length ? "คู่มือแต่ละหน้า" : "พาทำงาน";
4220
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: heading }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), pages.length ? jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage }) : null, pages.length ? flowSection : flows.length ? jsxRuntime.jsx(FlowList, { items: flows, onOpen: openFlow }) : null] })] }));
3784
4221
  }
3785
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx("div", { className: "eg-prog", children: jsxRuntime.jsx("div", { className: "eg-bar", children: jsxRuntime.jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsxRuntime.jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsxRuntime.jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })) : null, p.canHideChip ? (jsxRuntime.jsx("div", { className: "eg-q-f", children: jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
4222
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx("div", { className: "eg-prog", children: jsxRuntime.jsx("div", { className: "eg-bar", children: jsxRuntime.jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsxRuntime.jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsxRuntime.jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })) : null, flowSection, p.canHideChip ? (jsxRuntime.jsx("div", { className: "eg-q-f", children: jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
3786
4223
  }
3787
4224
  function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
3788
4225
  const v = item.view;
@@ -3920,8 +4357,7 @@ function writeMirror(orgId, suite, p) {
3920
4357
  /* private mode: the server copy is enough */
3921
4358
  }
3922
4359
  }
3923
- /** Page rows written in this tab, kept for a remount within the GET's cache time. */
3924
- const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
4360
+ const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind}s:${orgId}:${suite.app}`;
3925
4361
  function readPagesMirror(orgId, suite, kind = "page") {
3926
4362
  try {
3927
4363
  const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
@@ -3955,6 +4391,8 @@ function mergePageRows(server, local) {
3955
4391
  }
3956
4392
  return out;
3957
4393
  }
4394
+ /** A flow in progress in this tab (resumed after a refresh, never across sessions). */
4395
+ const flowMirrorKey = (orgId, suite) => `erp-guide-flow-run:${orgId}:${suite.app}`;
3958
4396
  /* ------------------------------------------------------------------ */
3959
4397
  /* One tab at a time */
3960
4398
  /* ------------------------------------------------------------------ */
@@ -4021,6 +4459,42 @@ function runAbort(actions) {
4021
4459
  }, ABORT_DELAY_MS + i * ABORT_GAP_MS);
4022
4460
  });
4023
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
+ }
4024
4498
  /** A step's own target takes presses: not on a "view" step, unless the step allows it. */
4025
4499
  function targetPressable(step, bridge) {
4026
4500
  if (!step || bridge)
@@ -4067,7 +4541,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4067
4541
  const [answer, setAnswer] = React.useState(null);
4068
4542
  const missionsReady = config.ready !== false;
4069
4543
  const pagesReady = config.pagesReady ?? missionsReady;
4070
- const hasPages = React.useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
4544
+ const hasPages = React.useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0 || validFlows(suite).length > 0, [suite]);
4545
+ const hasFlows = React.useMemo(() => validFlows(suite).length > 0, [suite]);
4071
4546
  const hasSections = React.useMemo(() => validSections(suite).length > 0, [suite]);
4072
4547
  const enabled = primary && (missionsReady || (hasPages && pagesReady));
4073
4548
  const loaded = React.useRef(false);
@@ -4125,6 +4600,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4125
4600
  pagesMode: answer.pagesMode,
4126
4601
  pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
4127
4602
  sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
4603
+ flows: mergePageRows(answer.flows ?? {}, readPagesMirror(organizationId, suite, "flow")),
4128
4604
  });
4129
4605
  stateRef.current = s;
4130
4606
  setState(s);
@@ -4233,7 +4709,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4233
4709
  else if (e.type === "savePage") {
4234
4710
  // Once per page and version; the machine already holds it as written.
4235
4711
  const kind = e.kind ?? "page";
4236
- const rows = stateRef.current ? (kind === "section" ? stateRef.current.sections : stateRef.current.pages) : undefined;
4712
+ const cur = stateRef.current;
4713
+ const rows = cur ? (kind === "section" ? cur.sections : kind === "flow" ? cur.flows : cur.pages) : undefined;
4237
4714
  if (rows)
4238
4715
  writePagesMirror(organizationId, suite, rows, kind);
4239
4716
  const rev = rows?.[e.key]?.rev ?? 0;
@@ -4330,7 +4807,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4330
4807
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
4331
4808
  // A page guide's step runs while page guides are live; a mission's while
4332
4809
  // the missions are. The offer card is watched the same way (left: ignored).
4333
- const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
4810
+ const inStep = screen.name === "step" && (screen.page || screen.section || screen.flow ? pagesLive : missionsLive);
4334
4811
  const offerUp = pagesLive && screen.name === "pageOffer";
4335
4812
  const watching = inStep || offerUp;
4336
4813
  React.useEffect(() => {
@@ -4396,6 +4873,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4396
4873
  const stepId = screen.name === "step" ? `${screen.mission}:${screen.index}:${screen.bridge ? "b" : ""}${screen.nothingToSave ? "n" : ""}` : "";
4397
4874
  /* Measure every frame while a step is shown: the spotlight follows
4398
4875
  scrolling, dialogs animating in, the drawer sliding, the keyboard. */
4876
+ const contentRoot = config.contentRoot;
4399
4877
  const [layout, setLayout] = React.useState({
4400
4878
  spot: null,
4401
4879
  covered: false,
@@ -4404,6 +4882,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4404
4882
  vw: 0,
4405
4883
  vh: 0,
4406
4884
  oy: 0,
4885
+ col: null,
4407
4886
  });
4408
4887
  React.useEffect(() => {
4409
4888
  if (!inStep) {
@@ -4412,7 +4891,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4412
4891
  }
4413
4892
  let raf = 0;
4414
4893
  let lostSince = null;
4415
- 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 };
4416
4895
  targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
4417
4896
  const allow = stepInfo?.step.allow ?? [];
4418
4897
  // The spotlight is the part of the target a person can see: cut by the
@@ -4472,13 +4951,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4472
4951
  holes.push(padRect(rectOf(ael), TARGET_PAD));
4473
4952
  }
4474
4953
  const covered = !!spot && coveredByDialog(targetElRef.current);
4475
- 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 };
4476
4956
  const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
4477
4957
  if (!sameRect(next.spot, last.spot) ||
4478
4958
  next.covered !== last.covered ||
4479
4959
  next.vw !== last.vw ||
4480
4960
  next.vh !== last.vh ||
4481
4961
  next.oy !== last.oy ||
4962
+ !sameRect(next.col, last.col) ||
4482
4963
  !sameList(next.holes, last.holes) ||
4483
4964
  !sameList(next.popovers, last.popovers)) {
4484
4965
  last = next;
@@ -4633,6 +5114,43 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4633
5114
  }
4634
5115
  return false;
4635
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
+ };
4636
5154
  const onPointer = (e) => {
4637
5155
  const t = e.target;
4638
5156
  if (inGuideLayer(t)) {
@@ -4752,8 +5270,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4752
5270
  return;
4753
5271
  }
4754
5272
  }
4755
- if (modalRef.current || !allowedTarget(active))
5273
+ if (modalRef.current || !allowedTarget(active)) {
4756
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
+ }
4757
5282
  }
4758
5283
  };
4759
5284
  window.addEventListener("pointerdown", onPointer, true);
@@ -4763,7 +5288,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4763
5288
  window.addEventListener("focusin", onFocusIn, true);
4764
5289
  window.addEventListener("focusout", onFocusOut, true);
4765
5290
  window.addEventListener("keydown", onKey, true);
5291
+ window.addEventListener("submit", onSubmit, true);
4766
5292
  return () => {
5293
+ window.removeEventListener("submit", onSubmit, true);
4767
5294
  window.removeEventListener("pointerdown", onPointer, true);
4768
5295
  window.removeEventListener("mousedown", onPointer, true);
4769
5296
  window.removeEventListener("touchstart", onPointer, true);
@@ -4944,6 +5471,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4944
5471
  openingSection.current = null;
4945
5472
  }
4946
5473
  }, []);
5474
+ const flowsNow = React.useMemo(() => pagesLive && hasState && hasFlows
5475
+ ? flowsAvailable(suite, env()).map(k => ({ key: k, title: findFlow(suite, k)?.title ?? "" }))
5476
+ : [],
5477
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5478
+ [pagesLive, hasState, hasFlows, suite, env, pathname, search, factsKey]);
5479
+ const flowsSig = flowsNow.map(x => x.key).join(",");
5480
+ /** The flows the panel lists: this person's, startable here or with somewhere to go. */
5481
+ 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]);
5482
+ const openFlowHere = React.useCallback((key) => dispatchRef.current({ type: "startFlow", flow: key }), []);
4947
5483
  const openPageHere = React.useCallback(() => {
4948
5484
  const p = pageHere(suite, env());
4949
5485
  if (p)
@@ -4954,10 +5490,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4954
5490
  publishPageGuides(null, null, null);
4955
5491
  return;
4956
5492
  }
4957
- publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length, sections: sectionsNow }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection);
4958
- // sectionsSig stands for sectionsNow.
5493
+ publishPageGuides({
5494
+ page: pageNow ? { title: pageNow.title } : null,
5495
+ listed: listed.length,
5496
+ sections: sectionsNow,
5497
+ flows: flowsNow,
5498
+ flowsListed: flowsListed.length,
5499
+ }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection, openFlowHere, () => dispatchRef.current({ type: "openPanel", section: "flows" }));
5500
+ // sectionsSig / flowsSig stand for sectionsNow / flowsNow.
4959
5501
  // eslint-disable-next-line react-hooks/exhaustive-deps
4960
- }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection]);
5502
+ }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere]);
4961
5503
  React.useEffect(() => () => publishPageGuides(null, null, null), []);
4962
5504
  // The first-visit offer: in "offer" mode, nothing else up, the welcome
4963
5505
  // not due here, and a moment of nothing loading on this page.
@@ -4985,14 +5527,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4985
5527
  if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
4986
5528
  return;
4987
5529
  let key = null;
5530
+ let flow = null;
4988
5531
  try {
4989
5532
  key = pageOfferDue(cur, suite, env());
5533
+ // A flow's offer comes after the page guide's (and only for `offer: true` flows).
5534
+ if (!key)
5535
+ flow = flowOfferDue(cur, suite, env());
4990
5536
  }
4991
5537
  catch {
4992
5538
  key = null;
5539
+ flow = null;
4993
5540
  }
4994
5541
  if (key)
4995
5542
  dispatchRef.current({ type: "pageOffer", page: key });
5543
+ else if (flow)
5544
+ dispatchRef.current({ type: "pageOffer", page: flow, flow: true });
4996
5545
  }, PAGE_POLL_MS);
4997
5546
  return () => clearInterval(iv);
4998
5547
  }, [offerWatch, suite, env, pathname, search]);
@@ -5044,6 +5593,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5044
5593
  if (b)
5045
5594
  return;
5046
5595
  const e = env();
5596
+ if (pending.flow) {
5597
+ const flow = findFlow(suite, pending.key);
5598
+ if (!flow || !onFlowStart(flow, e))
5599
+ return;
5600
+ setPending(null);
5601
+ dispatchRef.current({ type: "startFlow", flow: pending.key });
5602
+ return;
5603
+ }
5047
5604
  const page = findPage(suite, pending.key);
5048
5605
  if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
5049
5606
  return;
@@ -5052,6 +5609,93 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5052
5609
  }, PAGE_POLL_MS);
5053
5610
  return () => clearInterval(iv);
5054
5611
  }, [pending, pagesLive, suite, env, code]);
5612
+ // A flow in the panel's list: start it here, or go to its start page and start it there.
5613
+ const openListedFlow = React.useCallback((key) => {
5614
+ const flow = findFlow(suite, key);
5615
+ if (!flow)
5616
+ return;
5617
+ const e = env();
5618
+ if (onFlowStart(flow, e)) {
5619
+ dispatchRef.current({ type: "startFlow", flow: key });
5620
+ return;
5621
+ }
5622
+ dispatchRef.current({ type: "closePanel" });
5623
+ const href = flowHref(flow, guideContext(e, {}));
5624
+ if (!href)
5625
+ return;
5626
+ setPending({ key, until: Date.now() + PAGE_ARRIVE_MS, flow: true });
5627
+ try {
5628
+ if (/^https?:\/\//i.test(href))
5629
+ window.location.assign(href);
5630
+ else
5631
+ navigateRef.current(href);
5632
+ }
5633
+ catch {
5634
+ /* never breaks the app */
5635
+ }
5636
+ }, [suite, env]);
5637
+ // A flow in progress is kept in this tab's session (sessionStorage), so a
5638
+ // refresh or a full page load on one of its pages picks it up again at
5639
+ // its checkpoint ("ทำต่อ" when its step is on another page). Never across
5640
+ // sessions; a flow that ends is forgotten.
5641
+ const flowRun = state?.pageRun?.kind === "flow" ? state.pageRun : null;
5642
+ const flowStepKey = screen.name === "step" && screen.flow ? (findMission(suite, screen.mission)?.steps[screen.index]?.key ?? null) : null;
5643
+ React.useEffect(() => {
5644
+ if (!hasFlows)
5645
+ return;
5646
+ const k = flowMirrorKey(organizationId, suite);
5647
+ try {
5648
+ if (!flowRun) {
5649
+ if (state)
5650
+ sessionStorage.removeItem(k);
5651
+ return;
5652
+ }
5653
+ if (!flowStepKey)
5654
+ return;
5655
+ const flow = findFlow(suite, flowRun.key);
5656
+ sessionStorage.setItem(k, JSON.stringify({ flow: flowRun.key, version: flow?.version, step: flowStepKey, write: flowRun.write, context: state?.progress.context ?? {} }));
5657
+ }
5658
+ catch {
5659
+ /* private mode: no resume after a refresh */
5660
+ }
5661
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5662
+ }, [hasFlows, flowRun?.key, flowRun?.write, flowStepKey, organizationId, suite]);
5663
+ const flowResumed = React.useRef(false);
5664
+ React.useEffect(() => {
5665
+ if (flowResumed.current || !pagesLive || !state || !hasFlows)
5666
+ return;
5667
+ // The missions resume first.
5668
+ if (missionsLive && !resumed.current)
5669
+ return;
5670
+ flowResumed.current = true;
5671
+ const k = flowMirrorKey(organizationId, suite);
5672
+ let saved = null;
5673
+ try {
5674
+ saved = JSON.parse(sessionStorage.getItem(k) ?? "null");
5675
+ }
5676
+ catch {
5677
+ saved = null;
5678
+ }
5679
+ if (!saved?.flow || !saved.step)
5680
+ return;
5681
+ const flow = findFlow(suite, saved.flow);
5682
+ const ok = !!flow && flow.version === saved.version && stateRef.current?.screen.name === "idle" && inFlowChain(flow, { code, pathname });
5683
+ if (!ok) {
5684
+ try {
5685
+ sessionStorage.removeItem(k);
5686
+ }
5687
+ catch {
5688
+ /* ignore */
5689
+ }
5690
+ return;
5691
+ }
5692
+ const context = {};
5693
+ for (const [ck, cv] of Object.entries(saved.context ?? {}))
5694
+ if (typeof cv === "string")
5695
+ context[ck] = cv;
5696
+ dispatchRef.current({ type: "resumeFlow", flow: saved.flow, step: saved.step, context, write: saved.write === true });
5697
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5698
+ }, [pagesLive, !!state, hasFlows, missionsLive]);
5055
5699
  if (!live || !state || !layer || !ctxIdle)
5056
5700
  return null;
5057
5701
  /* ---------------- render ---------------- */
@@ -5074,18 +5718,29 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5074
5718
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
5075
5719
  const kind = stepKind(step);
5076
5720
  const bridge = screen.bridge;
5077
- 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;
5078
5726
  const page = screen.page ? findPage(suite, screen.page) : undefined;
5079
5727
  const section = screen.section ? findSection(suite, screen.section) : undefined;
5728
+ const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
5080
5729
  const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
5081
5730
  const pageBits = read
5082
5731
  ? {
5083
5732
  ...read,
5084
5733
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
5085
- nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
5734
+ nextLabel: last ? "เสร็จ" : undefined,
5086
5735
  }
5087
- : {};
5088
- 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: {
5736
+ : flow
5737
+ ? {
5738
+ heading: `พาทำ · ${flow.title}`,
5739
+ exitLabel: "ออกจากการพาทำ",
5740
+ nextLabel: kind === "view" && last ? "เสร็จ" : undefined,
5741
+ }
5742
+ : {};
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: {
5089
5744
  missionNo,
5090
5745
  missionTitle: mission.title,
5091
5746
  replay: screen.replay,
@@ -5101,6 +5756,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5101
5756
  ...pageBits,
5102
5757
  }, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), phase: screen.phase, confirmExit: screen.confirmExit, reopenHint: suite.reopenHint, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onBack: () => dispatch({ type: "back" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
5103
5758
  }
5759
+ else if (screen.name === "pageOffer" && pagesLive && screen.flow) {
5760
+ const flow = findFlow(suite, screen.page);
5761
+ const mission = flow ? findMission(suite, FLOW_PREFIX + flow.key) : undefined;
5762
+ if (flow && mission) {
5763
+ content = (jsxRuntime.jsx(PageOfferCard, { eyebrow: "\u0E1E\u0E32\u0E17\u0E33\u0E07\u0E32\u0E19", title: flow.title, steps: stepPosition(mission, 0, env(), {}).total, onStart: () => dispatch({ type: "pageOfferStart" }), onLater: () => dispatch({ type: "pageOfferLater" }) }));
5764
+ }
5765
+ }
5104
5766
  else if (screen.name === "pageOffer" && pagesLive) {
5105
5767
  const page = findPage(suite, screen.page);
5106
5768
  if (page) {
@@ -5144,12 +5806,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5144
5806
  const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
5145
5807
  // The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
5146
5808
  // then the page guides they have.
5147
- const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
5148
- const pageItems = pagesLive
5149
- ? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) }))
5809
+ const panelMissions = missionsLive && total > 0 && state.panelSection === "all";
5810
+ const pageItems = pagesLive && state.panelSection !== "flows" ? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) })) : [];
5811
+ const flowItems = pagesLive && state.panelSection !== "pages"
5812
+ ? flowsListed.map(f => ({ key: f.key, title: f.title, minutes: f.minutes, done: flowRecorded(state, f) && state.flows[f.key]?.status === "completed" }))
5150
5813
  : [];
5151
- const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
5152
- return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("style", { children: GUIDE_CSS }), jsxRuntime.jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsxRuntime.jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsxRuntime.jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage })) : null, toast ? jsxRuntime.jsx(GuideToast, { text: toast }) : null] }), layer);
5814
+ const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0 || flowItems.length > 0);
5815
+ return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("style", { children: GUIDE_CSS }), jsxRuntime.jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsxRuntime.jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsxRuntime.jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage, flows: flowItems, onOpenFlow: openListedFlow })) : null, toast ? jsxRuntime.jsx(GuideToast, { text: toast }) : null] }), layer);
5153
5816
  }
5154
5817
  /** A stable key for the facts object, so effects re-run when they change. */
5155
5818
  function useFactsKey(facts) {
@@ -5277,6 +5940,8 @@ exports.guideMenuShown = guideMenuShown;
5277
5940
  exports.loadGuides = loadGuides;
5278
5941
  exports.matchApiPath = matchApiPath;
5279
5942
  exports.matchRoute = matchRoute;
5943
+ exports.openFlow = openFlow;
5944
+ exports.openFlowsPanel = openFlowsPanel;
5280
5945
  exports.openGuidePanel = openGuidePanel;
5281
5946
  exports.openPageGuide = openPageGuide;
5282
5947
  exports.openPagesPanel = openPagesPanel;
@@ -5291,4 +5956,4 @@ exports.useGuideEntry = useGuideEntry;
5291
5956
  exports.usePageGuideEntry = usePageGuideEntry;
5292
5957
  exports.usePageGuides = usePageGuides;
5293
5958
  exports.useSectionGuideValues = useSectionGuideValues;
5294
- //# sourceMappingURL=format-CVazK0hP.cjs.map
5959
+ //# sourceMappingURL=format-DQLPHq7h.cjs.map