@eintrek/erp-shell 0.1.62 → 0.1.63

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
@@ -144,6 +144,36 @@ Phase 3a ✅ — Pure / session-only hooks + jwt-utils
144
144
  Consumer apps' `@/shared/lib/jwt-utils`, `@/shared/hooks/use-session-refresh`,
145
145
  `@/shared/hooks/use-keycloak-roles` shims now re-export from this package.
146
146
 
147
+ ## Guided tour (ไกด์เริ่มต้น), from 0.1.63
148
+
149
+ A forced, game-style spotlight tour, written by each app as data and run by
150
+ the shell. Authors' guide: `_claude-work/peoplx-guide/ENGINE-API.md`; the
151
+ progress API: `API.md` next to it.
152
+
153
+ - `AppLayoutShell guides={{ suite, facts, can, ready }}` mounts `GuideHost`
154
+ once the URL's organisation is active (never on the not-member refusal).
155
+ Who sees it (forced / optional / none) comes from platform-api
156
+ `GET /organizations/:id/guides/me?app=`; any failure shows nothing.
157
+ - Steps advance on outcomes: a route, an anchor appearing or going away, an
158
+ API answer (the shell's axios reports `erp:api`; an app's own instance calls
159
+ `reportApiEvent`), an app event (`emitGuideEvent` → `erp:guide:<name>`), a
160
+ press, a valid value.
161
+ - Anchors: `guideAnchor("peoplx.x.y")` → `data-guide`. The sidebar anchors
162
+ every row as `nav.<item id>`; the menu button is `shell.menu-toggle`, the
163
+ "?" button `shell.help`.
164
+ - The "?" button becomes a menu ("ไกด์เริ่มต้น n/N", "ติดต่อทีมช่วยเหลือ")
165
+ for a user with a tour.
166
+ - The layer is a portal marked `data-erp-guide-layer` with its own
167
+ stylesheet (`eg-` classes, theme tokens). Radix overlays must ignore it:
168
+ `@eintrek/erp-theme` ≥ 1.4.21 does; the engine also stops its own presses
169
+ and focus from reaching an open dialog.
170
+ - New dependency: `@floating-ui/dom` (external in the bundle; Radix already
171
+ installs it in every app).
172
+ - Code: `src/guide/` — `types.ts` (definitions), `machine.ts` (pure state
173
+ machine), `patterns.ts`, `progress.ts` / `progress-client.ts` (API),
174
+ `dom.ts`, `guide-host.tsx`, `guide-ui.tsx`, `guide-styles.ts`. Tests:
175
+ `src/guide/*.test.ts`.
176
+
147
177
  ## Roadmap
148
178
 
149
179
  | Phase | Scope | Files to move from each app |
@@ -170,6 +200,8 @@ Requires Node 22.13+ and pnpm 11 (same as other ERP packages).
170
200
  ```bash
171
201
  pnpm install
172
202
  pnpm build # rollup → dist/{index.js,index.esm.js,index.d.ts}
203
+ pnpm test # node --test (pnpm 11 may refuse to run scripts over
204
+ # ignored builds; then: node --test "src/**/*.test.ts")
173
205
  pnpm dev # watch
174
206
  pnpm pack # produce .tgz for local consumer testing
175
207
  ```
@@ -0,0 +1,37 @@
1
+ export interface Rect {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export declare function anchorSelector(anchor: string, id?: string): string;
8
+ /**
9
+ * On screen: connected, with a size, not hidden by style, not inside
10
+ * aria-hidden or inert (other than the guide's own), not a closed Radix part.
11
+ *
12
+ * aria-hidden that Radix put there (data-aria-hidden: everything outside an
13
+ * open dialog, select or menu is hidden from screen readers) does not count:
14
+ * a dialog's field is still on screen while its select's list is open.
15
+ */
16
+ export declare function isPresent(el: Element | null | undefined): el is HTMLElement;
17
+ /** The first matching anchor that is on screen. */
18
+ export declare function findAnchor(anchor: string, id?: string): HTMLElement | null;
19
+ export declare function anchorVisible(anchor: string, id?: string): boolean;
20
+ /** The field to type into: the anchor itself, or the first field inside it. */
21
+ export declare function fieldOf(el: Element | null): HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLElement | null;
22
+ export declare function fieldValue(el: Element | null): string;
23
+ export declare const FOCUSABLE = "a[href],button:not([disabled]),input:not([disabled]):not([type=hidden]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1']),[contenteditable=''],[contenteditable=true]";
24
+ export declare function focusablesIn(root: Element | null): HTMLElement[];
25
+ export declare function rectOf(el: Element): Rect;
26
+ export declare function padRect(r: Rect, pad: number): Rect;
27
+ export declare function sameRect(a: Rect | null, b: Rect | null): boolean;
28
+ /** Open Radix poppers (a select's list, a menu): usable while a step runs. */
29
+ export declare const POPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
30
+ /** Toasts stay usable above the tour. */
31
+ export declare const TOASTER_SELECTOR = "[data-sonner-toaster]";
32
+ export declare function openPoppers(): HTMLElement[];
33
+ export declare function inGuideLayer(node: EventTarget | null): boolean;
34
+ export declare function prefersReducedMotion(): boolean;
35
+ export declare function isPhoneWidth(): boolean;
36
+ /** Scrolls the target to the middle when it is out of view (instant under reduced motion). */
37
+ export declare function revealTarget(el: Element): void;
@@ -0,0 +1,24 @@
1
+ export declare const GUIDE_ATTRIBUTE = "data-guide";
2
+ export declare const GUIDE_ID_ATTRIBUTE = "data-guide-id";
3
+ export declare const GUIDE_LAYER_ATTRIBUTE = "data-erp-guide-layer";
4
+ export declare const API_EVENT = "erp:api";
5
+ export declare const GUIDE_EVENT_PREFIX = "erp:guide:";
6
+ export interface GuideAnchorProps {
7
+ "data-guide": string;
8
+ "data-guide-id"?: string;
9
+ }
10
+ /** Spread onto the element a tour step points at. */
11
+ export declare function guideAnchor(key: string, id?: string | number | null): GuideAnchorProps;
12
+ export interface ApiEventDetail {
13
+ method: string;
14
+ path: string;
15
+ status: number;
16
+ id?: string;
17
+ message?: string;
18
+ }
19
+ /** Announces an app event a step can wait for (`advanceOn: { event: name }`). */
20
+ export declare function emitGuideEvent(name: string, detail?: Record<string, string | number>): void;
21
+ /** The bus detail for an axios answer or error; null when it is neither. */
22
+ export declare function apiEventDetail(responseOrError: unknown): ApiEventDetail | null;
23
+ /** Puts an axios answer (or error) on the bus. Safe to call with anything. */
24
+ export declare function reportApiEvent(responseOrError: unknown): void;
@@ -0,0 +1,7 @@
1
+ import type { GuideConfig } from "./types";
2
+ export interface GuideHostProps {
3
+ config: GuideConfig;
4
+ organizationId: string;
5
+ code: string;
6
+ }
7
+ export declare function GuideHost(props: GuideHostProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * What the rest of the shell needs to know about the tour, outside the
3
+ * host's tree: whether this user has one here (the "?" menu's
4
+ * "ไกด์เริ่มต้น n/N" entry) and a way to open its panel. A tiny external
5
+ * store, like the help dialog's.
6
+ */
7
+ export interface GuideEntry {
8
+ done: number;
9
+ total: number;
10
+ }
11
+ /** Called by the host: the entry to show (null: none), and how to open the panel. */
12
+ export declare function publishGuideEntry(next: GuideEntry | null, openPanel: (() => void) | null): void;
13
+ /** Opens the tour's quest panel, when this user has a tour here. */
14
+ export declare function openGuidePanel(): void;
15
+ /** The "?" menu's entry, or null when there is no tour for this user. */
16
+ export declare function useGuideEntry(): GuideEntry | null;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The tour's stylesheet. The shell has no CSS pipeline and the apps only scan
3
+ * its dist for Tailwind classes, which cannot carry keyframes; so the layer
4
+ * brings its own sheet, namespaced `eg-`, drawn from the theme's tokens
5
+ * (--primary carries each app's brand accent; light and dark follow).
6
+ */
7
+ export declare const GUIDE_CSS = "\n[data-erp-guide-layer]{position:fixed;inset:0;z-index:1000;pointer-events:none;font-family:inherit;color:var(--popover-foreground,#1c2634);\n --eg-scrim:rgba(12,20,34,.64);--eg-card:var(--popover,#fff);--eg-fg:var(--popover-foreground,#1c2634);\n --eg-muted:var(--muted-foreground,#6d7886);--eg-soft:var(--muted,#eef3f9);--eg-line:var(--border,#e8ecf1);\n --eg-primary:var(--primary,#3280dd);--eg-on-primary:var(--primary-foreground,#fff);\n --eg-tint:color-mix(in oklab,var(--eg-primary) 13%,var(--eg-card));--eg-strong:color-mix(in oklab,var(--eg-primary) 82%,#000);\n --eg-danger:var(--destructive,#d23b4b);--eg-ok:#1d9a5f;--eg-gold:#e6a417;\n --eg-shadow:0 18px 50px -12px rgba(16,32,64,.35),0 4px 12px rgba(16,32,64,.08);line-height:1.5;font-size:14px}\n[data-erp-guide-layer] *{box-sizing:border-box}\n[data-erp-guide-layer] button{font:inherit;color:inherit;cursor:pointer}\n.eg-i{pointer-events:auto}\n.eg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n.eg-scrim-svg{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}\n.eg-scrim-svg path{fill:var(--eg-scrim);pointer-events:visiblePainted;fill-rule:evenodd}\n.eg-scrim{position:fixed;inset:0;background:var(--eg-scrim);pointer-events:auto}\n.eg-scrim.eg-light{background:rgba(12,20,34,.45)}\n.eg-scrim.eg-faint{background:rgba(12,20,34,.35)}\n.eg-ring{position:fixed;border-radius:12px;pointer-events:none;box-shadow:0 0 0 3px #fff,0 0 0 6px var(--eg-primary)}\n.eg-ring.eg-move{transition:left .26s ease,top .26s ease,width .26s ease,height .26s ease}\n.eg-ring::after{content:\"\";position:absolute;inset:-6px;border-radius:15px;border:3px solid var(--eg-primary);animation:eg-pulse 1.6s ease-out infinite}\n.eg-ring.eg-flash::after{animation:eg-flash .5s ease 1}\n@keyframes eg-pulse{0%{opacity:.9;transform:scale(1)}100%{opacity:0;transform:scale(1.12)}}\n@keyframes eg-flash{0%,100%{opacity:1;transform:none}50%{opacity:.2}}\n.eg-hand{position:fixed;width:46px;height:46px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(0,0,0,.35));animation:eg-tap 1.1s ease-in-out infinite}\n.eg-hand svg{width:46px;height:46px;fill:#fff;stroke:#1c2634;stroke-width:1.4;stroke-linejoin:round;stroke-linecap:round}\n.eg-hand.eg-flip svg{transform:scaleY(-1)}\n@keyframes eg-tap{0%,100%{transform:translate(6px,8px)}50%{transform:translate(0,0)}}\n.eg-bubble{position:fixed;left:0;top:0;width:320px;background:var(--eg-card);color:var(--eg-fg);border-radius:14px;box-shadow:var(--eg-shadow);padding:14px 16px 12px;border-top:4px solid var(--eg-primary);outline:none;pointer-events:auto}\n.eg-bubble.eg-err{border-top-color:var(--eg-danger)}\n.eg-bubble.eg-nudge{animation:eg-nudge .38s ease}\n@keyframes eg-nudge{0%,100%{translate:0}20%{translate:-7px}40%{translate:6px}60%{translate:-4px}80%{translate:2px}}\n.eg-b-top{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;color:var(--eg-muted)}\n.eg-b-top .eg-mis{font-weight:600;color:var(--eg-strong);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.eg-b-top .eg-cnt{white-space:nowrap;font-weight:600}\n.eg-pips{display:flex;gap:4px;margin:8px 0 10px}\n.eg-pips i{flex:1;height:4px;border-radius:2px;background:var(--eg-soft)}\n.eg-pips i.eg-d{background:var(--eg-primary)}\n.eg-pips i.eg-c{background:color-mix(in oklab,var(--eg-primary) 45%,var(--eg-card))}\n.eg-b-t{margin:0;font-size:16px;font-weight:600;line-height:1.45}\n.eg-b-w{margin:2px 0 0;font-size:13.5px;color:var(--eg-muted)}\n.eg-bubble.eg-err .eg-b-t{color:var(--eg-danger)}\n.eg-k{display:inline-block;background:var(--eg-tint);color:var(--eg-strong);border-radius:7px;padding:0 7px;font-weight:600}\n.eg-b-f{display:flex;align-items:center;justify-content:space-between;margin-top:12px;gap:8px}\n.eg-b-f .eg-btn{height:34px;padding:0 14px;font-size:13.5px}\n.eg-exit{border:0;background:none;color:var(--eg-muted);font-size:12.5px;padding:4px 0;text-decoration:underline;text-underline-offset:3px}\n.eg-exit:hover{color:var(--eg-fg)}\n.eg-arrow{position:absolute;width:14px;height:14px;background:var(--eg-card);transform:rotate(45deg);border-radius:2px}\n.eg-arrow.eg-top{background:var(--eg-primary)}\n.eg-bubble.eg-err .eg-arrow.eg-top{background:var(--eg-danger)}\n.eg-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 16px;border-radius:10px;border:1px solid transparent;font-size:14px;font-weight:600;white-space:nowrap}\n.eg-btn:focus-visible,.eg-exit:focus-visible,.eg-chip:focus-visible,.eg-x:focus-visible{outline:2px solid var(--eg-primary);outline-offset:2px}\n.eg-primary{background:var(--eg-primary);color:var(--eg-on-primary)!important}\n.eg-primary:hover{background:var(--eg-strong)}\n.eg-primary[disabled]{opacity:.55;cursor:default}\n.eg-ghost{background:var(--eg-card);border-color:var(--eg-line)}\n.eg-ghost:hover{background:var(--eg-soft)}\n.eg-ghost[disabled]{opacity:.55;cursor:default}\n.eg-link{background:none;border:0;color:var(--eg-muted);font-weight:500;padding:0 6px;height:32px}\n.eg-link:hover{color:var(--eg-fg);text-decoration:underline}\n.eg-modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:min(440px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;background:var(--eg-card);color:var(--eg-fg);border-radius:20px;box-shadow:var(--eg-shadow);padding:28px 28px 22px;text-align:center;outline:none;pointer-events:auto}\n.eg-modal.eg-pop{animation:eg-pop .32s cubic-bezier(.2,.9,.3,1.3)}\n@keyframes eg-pop{from{opacity:0;transform:translate(-50%,-46%) scale(.94)}}\n.eg-modal h2{margin:6px 0 4px;font-size:22px;font-weight:700;letter-spacing:-.2px;line-height:1.3}\n.eg-modal .eg-sub{margin:0;color:var(--eg-muted);font-size:14.5px}\n.eg-eyebrow{margin:0;font-size:12.5px;font-weight:600;letter-spacing:.3px;color:var(--eg-strong)}\n.eg-actions{display:grid;gap:6px;margin-top:20px}\n.eg-actions .eg-btn{height:44px}\n.eg-badge{width:64px;height:64px;border-radius:18px;margin:0 auto 12px;display:grid;place-items:center;background:var(--eg-tint);color:var(--eg-strong)}\n.eg-badge svg.eg-ic{width:30px;height:30px}\n.eg-quests{list-style:none;padding:0;margin:18px 0 0;text-align:left;display:grid;gap:6px}\n.eg-quests li{display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:11px;background:var(--eg-soft);font-size:14px}\n.eg-quests li .eg-n{width:26px;height:26px;border-radius:50%;background:var(--eg-card);border:1.5px solid var(--eg-line);display:grid;place-items:center;font-size:12px;font-weight:700;color:var(--eg-muted);flex:none}\n.eg-quests li.eg-first{background:var(--eg-tint)}\n.eg-quests li.eg-first .eg-n{border-color:var(--eg-primary);color:var(--eg-strong)}\n.eg-quests li.eg-ok .eg-n{background:var(--eg-ok);border-color:var(--eg-ok);color:#fff}\n.eg-quests li .eg-n svg.eg-ic{width:15px;height:15px;stroke-width:3}\n.eg-quests li .eg-t{flex:1}\n.eg-quests li small{color:var(--eg-muted);font-size:12px;white-space:nowrap}\n.eg-modal.eg-confirm{text-align:left;width:min(380px,calc(100vw - 32px));padding:22px}\n.eg-modal.eg-confirm h2{font-size:18px;margin-top:0}\n.eg-modal.eg-confirm .eg-actions{grid-template-columns:1fr 1fr}\n.eg-medal{width:84px;height:84px;border-radius:50%;margin:4px auto 10px;display:grid;place-items:center;color:#fff;background:radial-gradient(circle at 35% 30%,#ffd666,var(--eg-gold));box-shadow:0 0 0 8px #fff4d6;animation:eg-stamp .5s cubic-bezier(.2,.9,.3,1.4) .1s both}\n.eg-medal svg.eg-ic{width:42px;height:42px;stroke-width:2.6}\n@keyframes eg-stamp{from{transform:scale(.3) rotate(-25deg);opacity:0}}\n.eg-prog{display:flex;align-items:center;gap:10px;margin-top:16px;font-size:13px;color:var(--eg-muted);font-weight:600}\n.eg-prog .eg-bar{flex:1;height:8px;border-radius:4px;background:var(--eg-soft);overflow:hidden}\n.eg-prog .eg-bar i{display:block;height:100%;background:var(--eg-primary);border-radius:4px;transition:width .9s cubic-bezier(.2,.8,.2,1) .35s}\n.eg-burst{position:absolute;left:50%;top:70px;width:0;height:0;pointer-events:none}\n.eg-burst span{position:absolute;width:8px;height:12px;border-radius:2px;opacity:0;animation:eg-conf 1.1s cubic-bezier(.15,.7,.3,1) forwards}\n@keyframes eg-conf{0%{opacity:1;transform:translate(0,0) rotate(0)}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}\n.eg-card{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);width:min(360px,calc(100vw - 32px));background:var(--eg-card);color:var(--eg-fg);border-radius:14px;box-shadow:var(--eg-shadow);padding:14px 16px;border-top:4px solid var(--eg-primary);pointer-events:auto;outline:none}\n.eg-card .eg-row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}\n.eg-loading{display:flex;align-items:center;gap:10px;font-weight:600}\n.eg-spin{width:18px;height:18px;border-radius:50%;border:2.5px solid var(--eg-soft);border-top-color:var(--eg-primary);animation:eg-spin .8s linear infinite;flex:none}\n@keyframes eg-spin{to{transform:rotate(360deg)}}\n.eg-chip{position:fixed;right:22px;bottom:22px;display:flex;align-items:center;gap:10px;height:48px;padding:0 18px 0 8px;border-radius:24px;border:1px solid var(--eg-line);background:var(--eg-card);color:var(--eg-fg);box-shadow:0 8px 24px -8px rgba(16,32,64,.3);font-weight:600;font-size:14px;pointer-events:auto}\n.eg-chip svg{width:34px;height:34px}\n.eg-chip.eg-hi{animation:eg-hi 1.2s ease 2}\n@keyframes eg-hi{50%{box-shadow:0 0 0 6px color-mix(in oklab,var(--eg-primary) 35%,transparent),0 8px 24px -8px rgba(16,32,64,.3)}}\n.eg-panel{position:fixed;right:22px;bottom:82px;width:360px;max-height:calc(100vh - 110px);overflow:auto;background:var(--eg-card);color:var(--eg-fg);border:1px solid var(--eg-line);border-radius:16px;box-shadow:var(--eg-shadow);outline:none;pointer-events:auto}\n.eg-panel-bd{display:none}\n.eg-q-h{display:flex;align-items:center;gap:10px;padding:16px 12px 4px 18px}\n.eg-q-h h2{margin:0;font-size:16px;font-weight:700;flex:1}\n.eg-q-h .eg-n{font-size:13px;color:var(--eg-muted);font-weight:600}\n.eg-x{width:34px;height:34px;border-radius:10px;border:0;background:none;display:grid;place-items:center;color:var(--eg-muted)}\n.eg-x:hover{background:var(--eg-soft)}\n.eg-x svg.eg-ic{width:18px;height:18px}\n.eg-panel .eg-prog{margin:6px 18px 8px}\n.eg-q-list{list-style:none;margin:0;padding:4px 10px 6px;display:grid;gap:2px}\n.eg-q-item{display:flex;align-items:center;gap:12px;padding:10px 8px;border-radius:11px}\n.eg-q-item.eg-next{background:var(--eg-tint)}\n.eg-q-item .eg-n{width:30px;height:30px;border-radius:50%;border:1.5px solid var(--eg-line);display:grid;place-items:center;font-size:12.5px;font-weight:700;color:var(--eg-muted);flex:none;background:var(--eg-card)}\n.eg-q-item.eg-next .eg-n{border-color:var(--eg-primary);color:var(--eg-strong)}\n.eg-q-item.eg-done .eg-n{background:var(--eg-ok);border-color:var(--eg-ok);color:#fff}\n.eg-q-item.eg-done .eg-n svg.eg-ic{width:16px;height:16px;stroke-width:3}\n.eg-q-item.eg-dim .eg-n{opacity:.55}\n.eg-q-item.eg-just .eg-n{animation:eg-stamp .55s cubic-bezier(.2,.9,.3,1.4) .2s both}\n.eg-q-item .eg-t{flex:1;min-width:0}\n.eg-q-item .eg-t b{display:block;font-size:14px;font-weight:600}\n.eg-q-item.eg-done .eg-t b{color:var(--eg-muted)}\n.eg-q-item .eg-t small{display:block;font-size:12px;color:var(--eg-muted)}\n.eg-q-item .eg-t small.eg-sk{color:#b06a00}\n.eg-q-item .eg-btn{height:32px;padding:0 12px;font-size:13px}\n.eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}\n.eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}\n.eg-q-f{border-top:1px solid var(--eg-line);padding:8px 12px;display:flex;justify-content:flex-end}\n.eg-toast{position:fixed;left:50%;bottom:84px;transform:translateX(-50%);background:#1c2634;color:#fff;padding:10px 16px;border-radius:10px;font-size:13.5px;box-shadow:var(--eg-shadow);max-width:calc(100vw - 32px);pointer-events:none}\nsvg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n@media (max-width:767px){\n .eg-bubble{width:auto}\n .eg-arrow{display:none}\n .eg-modal{top:auto;bottom:12px;transform:translateX(-50%);width:calc(100vw - 24px);padding:24px 20px 18px}\n .eg-modal.eg-pop{animation:eg-up .3s cubic-bezier(.2,.9,.3,1.1)}\n .eg-modal .eg-actions .eg-btn{height:46px}\n .eg-chip{right:16px;bottom:calc(16px + env(safe-area-inset-bottom))}\n .eg-panel-bd{display:block;position:fixed;inset:0;background:rgba(15,23,42,.38);pointer-events:auto}\n .eg-panel{left:0;right:0;bottom:0;width:100%;max-height:85vh;border-radius:18px 18px 0 0;padding-bottom:env(safe-area-inset-bottom)}\n .eg-q-item{padding:12px 8px}\n .eg-card{bottom:calc(12px + env(safe-area-inset-bottom))}\n .eg-toast{bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n@keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}\n@media (prefers-reduced-motion:reduce){\n [data-erp-guide-layer] *,[data-erp-guide-layer] *::before,[data-erp-guide-layer] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}\n .eg-ring::after{animation:none!important;opacity:1;transform:none}\n .eg-ring.eg-flash::after{animation:eg-flash .5s ease 1!important;animation-duration:.5s!important}\n .eg-hand{animation:none!important;transform:translate(4px,6px)}\n .eg-burst{display:none}\n .eg-bubble.eg-nudge{animation:none!important;border-top-color:var(--eg-danger)}\n}\n";
@@ -0,0 +1,158 @@
1
+ import type { Rect } from "./dom";
2
+ import type { MissionView, StepError } from "./machine";
3
+ /** "26 ต.ค. 2569 13:04" — day, month, Buddhist year, 24 h. */
4
+ export declare function formatThaiDateTime(iso: string | null | undefined): string;
5
+ /** "กด [บันทึก]": the bracketed word is a chip in the brand colour. */
6
+ export declare function GuideTitle({ text }: {
7
+ text: string;
8
+ }): import("react/jsx-runtime").JSX.Element;
9
+ export declare const IconCheck: () => import("react/jsx-runtime").JSX.Element;
10
+ export declare const IconX: () => import("react/jsx-runtime").JSX.Element;
11
+ export declare const IconFlag: () => import("react/jsx-runtime").JSX.Element;
12
+ export declare const IconTrophy: () => import("react/jsx-runtime").JSX.Element;
13
+ /**
14
+ * The dim layer that eats every press outside the holes. One SVG path with
15
+ * even-odd holes: a press inside a hole reaches the page underneath, any
16
+ * other lands on the path (marked data-eg-block) and is cancelled.
17
+ */
18
+ export declare function Scrim({ holes, width, height }: {
19
+ holes: Rect[];
20
+ width: number;
21
+ height: number;
22
+ }): import("react/jsx-runtime").JSX.Element;
23
+ export interface BubbleModel {
24
+ missionNo: number;
25
+ missionTitle: string;
26
+ replay: boolean;
27
+ index: number;
28
+ total: number;
29
+ title: string;
30
+ why: string;
31
+ error: StepError | null;
32
+ showNext: boolean;
33
+ showEndWithoutSave: boolean;
34
+ }
35
+ export interface StepLayerProps {
36
+ /** The spotlight (padded target), or null while there is none to show. */
37
+ spot: Rect | null;
38
+ /** Other holes: open popper lists, allowed anchors. */
39
+ holes: Rect[];
40
+ /** False for a "view" step with nothing to point at: the bubble is centred. */
41
+ hasTarget: boolean;
42
+ viewport: {
43
+ width: number;
44
+ height: number;
45
+ offsetTop: number;
46
+ };
47
+ phone: boolean;
48
+ showHand: boolean;
49
+ bubble: BubbleModel;
50
+ phase: "waiting" | "ready" | "missing" | "away";
51
+ confirmExit: boolean;
52
+ nudge: number;
53
+ /** Animate the ring to its new place (a new step). */
54
+ moving: boolean;
55
+ onExit: () => void;
56
+ onNext: () => void;
57
+ onEndWithoutSave: () => void;
58
+ onRetry: () => void;
59
+ onGoResume: () => void;
60
+ onExitConfirm: () => void;
61
+ onExitCancel: () => void;
62
+ }
63
+ export declare const BUBBLE_TITLE_ID = "eg-bubble-title";
64
+ export declare const BUBBLE_WHY_ID = "eg-bubble-why";
65
+ export declare function StepLayer(p: StepLayerProps): import("react/jsx-runtime").JSX.Element;
66
+ /** The fingertip at ≈62% across and down the target; upside down near the bottom. */
67
+ export declare function handPosition(spot: Rect, viewportHeight: number): {
68
+ left: number;
69
+ top: number;
70
+ flip: boolean;
71
+ };
72
+ export declare function ConfirmModal(p: {
73
+ title: string;
74
+ line: string;
75
+ leaveLabel: string;
76
+ stayLabel: string;
77
+ onLeave: () => void;
78
+ onStay: () => void;
79
+ }): import("react/jsx-runtime").JSX.Element;
80
+ export declare function WelcomeModal(p: {
81
+ title: string;
82
+ subtitle: string;
83
+ missions: Array<{
84
+ title: string;
85
+ minutes: number;
86
+ }>;
87
+ confirm: boolean;
88
+ onStart: () => void;
89
+ onSkip: () => void;
90
+ onSkipConfirm: () => void;
91
+ onSkipCancel: () => void;
92
+ }): import("react/jsx-runtime").JSX.Element;
93
+ export declare function GateModal(p: {
94
+ title: string;
95
+ subtitle?: string;
96
+ actionLabel: string;
97
+ href: string;
98
+ onLater: () => void;
99
+ }): import("react/jsx-runtime").JSX.Element;
100
+ export declare function CelebrateModal(p: {
101
+ missionNo: number;
102
+ title: string;
103
+ subtitle: string;
104
+ done: number;
105
+ total: number;
106
+ nextTitle: string | null;
107
+ reduced: boolean;
108
+ onNext: () => void;
109
+ onRest: () => void;
110
+ }): import("react/jsx-runtime").JSX.Element;
111
+ export declare function CompleteModal(p: {
112
+ total: number;
113
+ title: string;
114
+ subtitle: string;
115
+ missions: string[];
116
+ actionLabel: string;
117
+ secondary: {
118
+ label: string;
119
+ href: string;
120
+ } | null;
121
+ reduced: boolean;
122
+ onFinish: () => void;
123
+ }): import("react/jsx-runtime").JSX.Element;
124
+ export declare function QuestChip({ done, total, pulse, onOpen }: {
125
+ done: number;
126
+ total: number;
127
+ pulse: number;
128
+ onOpen: () => void;
129
+ }): import("react/jsx-runtime").JSX.Element;
130
+ export interface QuestItem {
131
+ key: string;
132
+ title: string;
133
+ minutes: number;
134
+ view: MissionView;
135
+ optional: boolean;
136
+ completedAt?: string | null;
137
+ /** For "locked": the missions to do first. */
138
+ lockedBy?: string;
139
+ }
140
+ export declare function QuestPanel(p: {
141
+ items: QuestItem[];
142
+ done: number;
143
+ total: number;
144
+ justDone: string | null;
145
+ gate: {
146
+ label: string;
147
+ pending: string;
148
+ href: string;
149
+ } | null;
150
+ canHideChip: boolean;
151
+ onClose: () => void;
152
+ onStart: (key: string, replay: boolean) => void;
153
+ onDismiss: (key: string) => void;
154
+ onHideChip: () => void;
155
+ }): import("react/jsx-runtime").JSX.Element;
156
+ export declare function GuideToast({ text }: {
157
+ text: string;
158
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The tour's state machine: pure. `reduceGuide(state, action, suite, env)`
3
+ * returns the next state and the effects for the host to carry out (save,
4
+ * announce, toast, navigate). Everything the machine needs to know about the
5
+ * page comes in through `env`, so the tests drive it with plain objects.
6
+ *
7
+ * No React, no DOM: the tests run it in Node (through the test loader).
8
+ */
9
+ import type { GuideContext, GuideMission, GuideStep, GuideSuite, GuideText, GuideTrigger } from "./types";
10
+ import { type RouteParams } from "./patterns";
11
+ import type { GuideAudience, GuideProgress, MissionEntry } from "./progress";
12
+ export type StepPhase =
13
+ /** Looking for the target (dim page, "กำลังโหลด…"). */
14
+ "waiting"
15
+ /** Target shown, spotlight on. */
16
+ | "ready"
17
+ /** Target never came: "ไม่พบส่วนนี้บนหน้า" with ลองอีกครั้ง. */
18
+ | "missing"
19
+ /** The step happens on another page: the "ทำต่อ" card. */
20
+ | "away";
21
+ export interface StepError {
22
+ title: string;
23
+ detail?: string;
24
+ }
25
+ export type GuideScreen = {
26
+ name: "idle";
27
+ } | {
28
+ name: "welcome";
29
+ confirm: boolean;
30
+ } | {
31
+ name: "gate";
32
+ } | {
33
+ name: "step";
34
+ mission: string;
35
+ index: number;
36
+ replay: boolean;
37
+ /** The phone/closed-sidebar "กดปุ่มเมนู" step before a nav.* target. */
38
+ bridge: boolean;
39
+ phase: StepPhase;
40
+ error: StepError | null;
41
+ confirmExit: boolean;
42
+ /** Timeouts so far on this step. */
43
+ misses: number;
44
+ /** A `hidden` trigger's anchor has been seen on this step, so its
45
+ * going away counts. */
46
+ armed: boolean;
47
+ } | {
48
+ name: "celebrate";
49
+ mission: string;
50
+ } | {
51
+ name: "complete";
52
+ };
53
+ export interface GuideState {
54
+ audience: GuideAudience;
55
+ progress: GuideProgress;
56
+ screen: GuideScreen;
57
+ panelOpen: boolean;
58
+ /** A mission whose tick should pop in the panel. */
59
+ justDone: string | null;
60
+ /** Bumped to make the chip pulse twice. */
61
+ chipPulse: number;
62
+ /** Bumped to shake the bubble (a press outside the hole). */
63
+ nudge: number;
64
+ /** The gate card was put away for this visit ("ไว้ทีหลัง"). */
65
+ gateDismissed: boolean;
66
+ }
67
+ /** What the machine may ask about the page. */
68
+ export interface GuideEnv {
69
+ code: string;
70
+ pathname: string;
71
+ search: URLSearchParams;
72
+ facts: Readonly<Record<string, unknown>>;
73
+ can: (policyKey: string) => boolean;
74
+ /** An anchor (optionally one row by data-guide-id) is on screen. */
75
+ isVisible: (anchor: string, id?: string) => boolean;
76
+ /** The sidebar is open (docked or drawer). */
77
+ sidebarOpen: boolean;
78
+ /** The current value of the step target's field. */
79
+ targetValue: () => string;
80
+ }
81
+ export type GuideSignal = {
82
+ kind: "api";
83
+ method: string;
84
+ path: string;
85
+ status: number;
86
+ id?: string;
87
+ message?: string;
88
+ } | {
89
+ kind: "event";
90
+ name: string;
91
+ detail?: Record<string, string>;
92
+ } | {
93
+ kind: "activate";
94
+ } | {
95
+ kind: "value";
96
+ value: string;
97
+ }
98
+ /** The user typed in the step's field (clears an error). */
99
+ | {
100
+ kind: "typing";
101
+ };
102
+ export type GuideAction = {
103
+ type: "welcome";
104
+ } | {
105
+ type: "welcomeSkip";
106
+ } | {
107
+ type: "welcomeSkipCancel";
108
+ } | {
109
+ type: "welcomeSkipConfirm";
110
+ }
111
+ /** "เริ่มภารกิจแรก" (and "เริ่มเลย" on the skip confirm). */
112
+ | {
113
+ type: "welcomeStart";
114
+ } | {
115
+ type: "gateLater";
116
+ } | {
117
+ type: "start";
118
+ mission: string;
119
+ replay?: boolean;
120
+ } | {
121
+ type: "resume";
122
+ }
123
+ /** Route, DOM or sidebar changed: re-evaluate the current step. */
124
+ | {
125
+ type: "sync";
126
+ } | {
127
+ type: "signal";
128
+ signal: GuideSignal;
129
+ }
130
+ /** ถัดไป, or Enter/Tab in an input step. */
131
+ | {
132
+ type: "next";
133
+ } | {
134
+ type: "targetLost";
135
+ } | {
136
+ type: "timeout";
137
+ } | {
138
+ type: "retry";
139
+ } | {
140
+ type: "blocked";
141
+ } | {
142
+ type: "exit";
143
+ } | {
144
+ type: "exitCancel";
145
+ } | {
146
+ type: "exitConfirm";
147
+ } | {
148
+ type: "endWithoutSave";
149
+ } | {
150
+ type: "goResume";
151
+ } | {
152
+ type: "celebrateNext";
153
+ } | {
154
+ type: "rest";
155
+ } | {
156
+ type: "finish";
157
+ } | {
158
+ type: "openPanel";
159
+ } | {
160
+ type: "closePanel";
161
+ } | {
162
+ type: "hideChip";
163
+ } | {
164
+ type: "dismissMission";
165
+ mission: string;
166
+ } | {
167
+ type: "serverRow";
168
+ progress: GuideProgress;
169
+ };
170
+ export type GuideEffect = {
171
+ type: "save";
172
+ immediate?: boolean;
173
+ } | {
174
+ type: "announce";
175
+ text: string;
176
+ } | {
177
+ type: "toast";
178
+ text: string;
179
+ } | {
180
+ type: "navigate";
181
+ href: string;
182
+ }
183
+ /** Close the app dialog the replay was in (Escape to Radix). */
184
+ | {
185
+ type: "closeAppDialog";
186
+ };
187
+ export interface GuideResult {
188
+ state: GuideState;
189
+ effects: GuideEffect[];
190
+ }
191
+ export declare const COPY: {
192
+ readonly fieldFix: "แก้ในช่องที่ไฮไลต์ แล้วกด ถัดไป";
193
+ readonly saveFailed: "บันทึกไม่สำเร็จ";
194
+ readonly blank: "ยังไม่ได้พิมพ์";
195
+ readonly exitToast: "ออกจากไกด์แล้ว";
196
+ readonly replayDone: "ทบทวนภารกิจเสร็จแล้ว";
197
+ readonly replayEnded: "จบการทบทวนแล้ว";
198
+ readonly hiddenToast: "ซ่อนแล้ว";
199
+ readonly aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง";
200
+ readonly bridgeTitle: "กดปุ่มเมนู";
201
+ readonly bridgeWhy: (app: string) => string;
202
+ };
203
+ export declare const BRIDGE_TARGET = "shell.menu-toggle";
204
+ export declare function initialGuideState(audience: GuideAudience, progress: GuideProgress): GuideState;
205
+ export declare function findMission(suite: GuideSuite, key: string | null | undefined): GuideMission | undefined;
206
+ export declare function guideContext(env: GuideEnv, data: Readonly<Record<string, string>>, params?: RouteParams): GuideContext;
207
+ export declare function resolveText(t: GuideText | undefined, ctx: GuideContext): string;
208
+ export declare function triggersOf(step: GuideStep): GuideTrigger[];
209
+ export declare function stepKind(step: GuideStep): import("./types").GuideStepKind;
210
+ /** The last checkpoint at or before `index` (a mission's first step always is one). */
211
+ export declare function checkpointAtOrBefore(steps: GuideStep[], index: number): number;
212
+ /** Whether the step's target (or the bridge's) is on screen. */
213
+ export declare function stepTargetVisible(step: GuideStep, env: GuideEnv, ctx: GuideContext, bridge: boolean): boolean;
214
+ /** The anchor the spotlight is on for the current step. */
215
+ export declare function spotlightTarget(step: GuideStep, ctx: GuideContext, bridge: boolean): {
216
+ anchor: string;
217
+ id?: string;
218
+ } | null;
219
+ /** Status of each mission as the panel shows it. */
220
+ export type MissionView = "todo" | "next" | "active" | "skipped" | "done" | "already" | "locked" | "waiting";
221
+ export declare function missionSettled(m: GuideMission, entry: MissionEntry | undefined, ctx: GuideContext): boolean;
222
+ export declare function settledCount(suite: GuideSuite, progress: GuideProgress, ctx: GuideContext): number;
223
+ export declare function gateState(suite: GuideSuite, ctx: GuideContext): boolean | undefined;
224
+ export declare function missionViews(suite: GuideSuite, progress: GuideProgress, ctx: GuideContext): Array<{
225
+ mission: GuideMission;
226
+ view: MissionView;
227
+ entry?: MissionEntry;
228
+ }>;
229
+ /** The mission the celebration offers next: the first one not settled and not locked. */
230
+ export declare function nextMissionKey(suite: GuideSuite, progress: GuideProgress, ctx: GuideContext): string | null;
231
+ /** Whether the welcome should open by itself. */
232
+ export declare function welcomeDue(state: GuideState, suite: GuideSuite): boolean;
233
+ /** Whether the floating "ภารกิจ n/N" chip shows. */
234
+ export declare function chipVisible(state: GuideState, suite: GuideSuite, ctx: GuideContext): boolean;
235
+ /** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
236
+ export declare function plainTitle(t: string): string;
237
+ export declare function reduceGuide(state: GuideState, action: GuideAction, suite: GuideSuite, env: GuideEnv): GuideResult;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Route and API-path patterns for tour steps.
3
+ *
4
+ * No runtime imports: the tests run this file in Node as it is.
5
+ */
6
+ export type RouteParams = Record<string, string>;
7
+ /**
8
+ * Matches a Next-style page pattern ("/[code]/employee/[id]", optionally
9
+ * "?tab=job") against a path and query. `[code]`, when given, must be the
10
+ * organisation code. Returns the params, or null.
11
+ */
12
+ export declare function matchRoute(pattern: string, pathname: string, search?: URLSearchParams | string | null, code?: string): RouteParams | null;
13
+ /** Fills a page pattern's params ("/[code]/x/[id]" → "/O1/x/42"); null if one is missing. */
14
+ export declare function fillRoute(pattern: string, params: Readonly<Record<string, string | undefined>>): string | null;
15
+ /**
16
+ * The path of a request, without origin, base URL query or hash:
17
+ * ("/shifts/4", "https://hr-api/api/v1") → "/api/v1/shifts/4".
18
+ */
19
+ export declare function requestPath(url: string | undefined, baseURL?: string): string;
20
+ /**
21
+ * Whether a request path matches a step's API path: the pattern's segments
22
+ * must be the END of the path ("/shifts/[id]" matches "/api/v1/shifts/42");
23
+ * `[x]`/`:x` is one segment, `*` any number (including none). A RegExp is
24
+ * tested against the whole path.
25
+ */
26
+ export declare function matchApiPath(pattern: string | RegExp, path: string): boolean;
27
+ /** "2xx" or an exact status. */
28
+ export declare function matchStatus(want: "2xx" | number | undefined, status: number): boolean;
@@ -0,0 +1,11 @@
1
+ import type { GuideSuite } from "./types";
2
+ import { type GuideProgress, type GuidesAnswer } from "./progress";
3
+ /** GET …/guides/me?app=…; null (show nothing) on any failure or odd answer. */
4
+ export declare function fetchGuides(organizationId: string, suite: GuideSuite, signal?: AbortSignal): Promise<GuidesAnswer | null>;
5
+ /**
6
+ * PUT the whole state. On 409 the server's row comes back: merge (keeping
7
+ * whatever is further along) and try once more with its rev. Returns the
8
+ * stored row, or null when it could not be saved (the tour goes on; the
9
+ * session copy keeps the place).
10
+ */
11
+ export declare function saveGuides(organizationId: string, suite: GuideSuite, progress: GuideProgress): Promise<GuideProgress | null>;