passenger-mode 0.0.0-stage → 0.3.0

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.
Files changed (40) hide show
  1. package/AGENTS.md +82 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -2
  4. package/dist/demo/index.html +112 -0
  5. package/dist/lib/cli.d.ts +2 -0
  6. package/dist/lib/cli.js +158 -0
  7. package/dist/lib/crawl.d.ts +15 -0
  8. package/dist/lib/crawl.js +189 -0
  9. package/dist/lib/gates-text.d.ts +10 -0
  10. package/dist/lib/gates-text.js +48 -0
  11. package/dist/lib/playwright.d.ts +5 -0
  12. package/dist/lib/playwright.js +35 -0
  13. package/dist/lib/react.d.ts +8 -0
  14. package/dist/lib/react.js +23 -0
  15. package/dist/lib/runtime/driver.d.ts +101 -0
  16. package/dist/lib/runtime/driver.js +422 -0
  17. package/dist/lib/runtime/gates.d.ts +1 -0
  18. package/dist/lib/runtime/gates.js +56 -0
  19. package/dist/lib/runtime/index.d.ts +43 -0
  20. package/dist/lib/runtime/index.js +99 -0
  21. package/dist/lib/runtime/planner.d.ts +61 -0
  22. package/dist/lib/runtime/planner.js +107 -0
  23. package/dist/lib/runtime/remote-planner.d.ts +18 -0
  24. package/dist/lib/runtime/remote-planner.js +37 -0
  25. package/dist/lib/runtime/resolve.d.ts +6 -0
  26. package/dist/lib/runtime/resolve.js +72 -0
  27. package/dist/lib/runtime/routes.d.ts +13 -0
  28. package/dist/lib/runtime/routes.js +65 -0
  29. package/dist/lib/runtime/ui.d.ts +58 -0
  30. package/dist/lib/runtime/ui.js +267 -0
  31. package/dist/lib/scan.d.ts +17 -0
  32. package/dist/lib/scan.js +431 -0
  33. package/dist/lib/server/claude-planner.d.ts +43 -0
  34. package/dist/lib/server/claude-planner.js +159 -0
  35. package/dist/lib/testdrive.d.ts +12 -0
  36. package/dist/lib/testdrive.js +92 -0
  37. package/dist/lib/types.d.ts +70 -0
  38. package/dist/lib/types.js +3 -0
  39. package/dist/passenger-mode.js +52 -0
  40. package/package.json +95 -4
@@ -0,0 +1,107 @@
1
+ import { hrefRoute } from "./routes.js";
2
+ // ---------- offline route planner ----------
3
+ const STOP = new Set("a an the my me i to of for on in and or please want would like can you how do go get help with this that it our".split(" "));
4
+ const words = (s) => s.toLowerCase().replace(/[{}]/g, " ").split(/[^a-z0-9]+/).filter((w) => w.length > 1 && !STOP.has(w))
5
+ .map((w) => w.replace(/(ing|es|s)$/, ""));
6
+ function overlap(a, b) {
7
+ const bw = new Set(words(b));
8
+ return words(a).filter((w) => bw.has(w)).length;
9
+ }
10
+ /**
11
+ * Plans without a model: matches the destination to a manifest action, then walks the link graph
12
+ * from the current page to that action's page. Good for tests, demos and as a fallback when the
13
+ * model is unavailable. Production plans with Claude (see ../server/claude-planner.ts).
14
+ */
15
+ export class RoutePlanner {
16
+ target = new Map(); // destination → action id
17
+ pickTarget(dest, m) {
18
+ const cached = this.target.get(dest);
19
+ if (cached)
20
+ return cached;
21
+ let best = null;
22
+ const consider = (id, score) => { if (score > 0 && (!best || score > best.score))
23
+ best = { id, score }; };
24
+ for (const d of m.destinations) {
25
+ const s = overlap(dest, d.goal);
26
+ if (s === 0)
27
+ continue;
28
+ for (const id of d.actionIds) {
29
+ const a = m.actions.find((x) => x.id === id);
30
+ if (a)
31
+ consider(id, s * 2 + overlap(dest, a.label) + (a.kind !== "navigate" ? 0.5 : 0));
32
+ }
33
+ }
34
+ for (const a of m.actions) {
35
+ const s = overlap(dest, a.label);
36
+ if (s > 0)
37
+ consider(a.id, s + (a.kind !== "navigate" ? 0.25 : 0)); // the bonus only breaks ties between real matches
38
+ }
39
+ const found = best;
40
+ if (found)
41
+ this.target.set(dest, found.id);
42
+ return found ? found.id : null;
43
+ }
44
+ /** Whether an action can be on a page: its own page, the pages it was seen on, or anywhere. */
45
+ static on(a, route) {
46
+ if (a.route !== null)
47
+ return a.route === route;
48
+ return !a.seenOn || (route !== null && a.seenOn.includes(route));
49
+ }
50
+ /** Breadth-first over navigate actions: which links to take from `from` to a page where `target` is. */
51
+ path(from, target, m) {
52
+ if (RoutePlanner.on(target, from))
53
+ return [];
54
+ const queue = [{ route: from, via: [] }];
55
+ const seen = new Set([from]);
56
+ while (queue.length) {
57
+ const { route, via } = queue.shift();
58
+ for (const a of m.actions) {
59
+ if (a.kind !== "navigate" || !a.href || !RoutePlanner.on(a, route))
60
+ continue;
61
+ const dest = hrefRoute(a.href, m.routes);
62
+ if (!dest || seen.has(dest))
63
+ continue;
64
+ const next = [...via, a.id];
65
+ if (RoutePlanner.on(target, dest))
66
+ return next;
67
+ seen.add(dest);
68
+ queue.push({ route: dest, via: next });
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+ async plan(view, m) {
74
+ const id = this.pickTarget(view.destination, m);
75
+ if (!id)
76
+ return null;
77
+ const target = m.actions.find((a) => a.id === id);
78
+ const route = this.path(view.route, target, m);
79
+ if (route === null)
80
+ return null;
81
+ const steps = route.map((aid) => {
82
+ const a = m.actions.find((x) => x.id === aid);
83
+ return `Open ${a.label}`;
84
+ });
85
+ steps.push(target.irreversible ? `Stop at ${target.label}. You press it.` : `${target.kind === "submit" ? "Submit" : "Press"} ${target.label}`);
86
+ const n = steps.length;
87
+ return { summary: `${target.label}: ${n} step${n === 1 ? "" : "s"}`, steps };
88
+ }
89
+ async next(view, m) {
90
+ const id = this.pickTarget(view.destination, m);
91
+ if (!id)
92
+ return { kind: "stuck", reason: "I don't know this road." };
93
+ const target = m.actions.find((a) => a.id === id);
94
+ if (view.history.some((h) => h.actionId === id)) {
95
+ return { kind: "arrived", summary: `Done: ${target.label}.` };
96
+ }
97
+ if (view.visible.some((v) => v.id === id)) {
98
+ return { kind: "act", actionId: id, caption: target.irreversible ? `Pull over: ${target.label} is yours to press` : `${target.kind === "submit" ? "Submitting" : "Pressing"} ${target.label}` };
99
+ }
100
+ const route = this.path(view.route, target, m);
101
+ const first = route?.[0];
102
+ if (!first || !view.visible.some((v) => v.id === first))
103
+ return { kind: "stuck", reason: "I don't know this road." };
104
+ const a = m.actions.find((x) => x.id === first);
105
+ return { kind: "act", actionId: first, caption: `Opening ${a.label}` };
106
+ }
107
+ }
@@ -0,0 +1,18 @@
1
+ import type { Manifest } from "../types.ts";
2
+ import type { PageView, Planner, Step, TripPlan } from "./planner.ts";
3
+ /**
4
+ * Plans on the site's own server (which holds the model key). Only the PageView is sent:
5
+ * the destination, the route, and the manifest actions visible now. Never field values or page text.
6
+ */
7
+ export declare class RemotePlanner implements Planner {
8
+ private endpoint;
9
+ private fetchImpl;
10
+ /** Extra headers, e.g. the site's public key for the hosted planner. */
11
+ private headers;
12
+ constructor(endpoint: string, fetchImpl?: typeof fetch,
13
+ /** Extra headers, e.g. the site's public key for the hosted planner. */
14
+ headers?: Record<string, string>);
15
+ private call;
16
+ plan(view: PageView, _m: Manifest): Promise<TripPlan | null>;
17
+ next(view: PageView, _m: Manifest): Promise<Step>;
18
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Plans on the site's own server (which holds the model key). Only the PageView is sent:
3
+ * the destination, the route, and the manifest actions visible now. Never field values or page text.
4
+ */
5
+ export class RemotePlanner {
6
+ endpoint;
7
+ fetchImpl;
8
+ headers;
9
+ constructor(endpoint, fetchImpl = (...a) => fetch(...a),
10
+ /** Extra headers, e.g. the site's public key for the hosted planner. */
11
+ headers = {}) {
12
+ this.endpoint = endpoint;
13
+ this.fetchImpl = fetchImpl;
14
+ this.headers = headers;
15
+ }
16
+ async call(op, view) {
17
+ const res = await this.fetchImpl(this.endpoint, {
18
+ method: "POST",
19
+ headers: { "content-type": "application/json", ...this.headers },
20
+ body: JSON.stringify({ op, view }),
21
+ // Cookies go only to the site's own planner (which may sit behind the site's own auth or preview
22
+ // protection), never to a planner on another origin such as the hosted one.
23
+ credentials: "same-origin",
24
+ });
25
+ if (!res.ok)
26
+ throw new Error(`planner ${res.status}`);
27
+ return (await res.json());
28
+ }
29
+ async plan(view, _m) {
30
+ const r = await this.call("plan", view);
31
+ return r.plan;
32
+ }
33
+ async next(view, _m) {
34
+ const r = await this.call("next", view);
35
+ return r.step;
36
+ }
37
+ }
@@ -0,0 +1,6 @@
1
+ import type { Action } from "../types.ts";
2
+ export declare function isVisible(el: Element): boolean;
3
+ /** Finds the live element for a manifest action: most stable selector first, visible matches only. */
4
+ export declare function resolveElement(action: Action, doc: Document, exclude?: Element | null): HTMLElement | null;
5
+ /** The runtime words on a control, for actions whose label is filled in at runtime ("{link.label}"). */
6
+ export declare function liveLabel(el: Element): string;
@@ -0,0 +1,72 @@
1
+ const q = (v) => '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
2
+ const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim().toLowerCase();
3
+ export function isVisible(el) {
4
+ if (!el.isConnected)
5
+ return false;
6
+ if (el.closest("[hidden],[inert],[aria-hidden='true']"))
7
+ return false;
8
+ const win = el.ownerDocument?.defaultView;
9
+ for (let n = el; n; n = n.parentElement) {
10
+ const cs = win?.getComputedStyle(n);
11
+ if (cs && (cs.display === "none" || cs.visibility === "hidden"))
12
+ return false;
13
+ }
14
+ if (el.disabled)
15
+ return false;
16
+ return true;
17
+ }
18
+ const TAGS = {
19
+ navigate: "a[href]",
20
+ click: "button, [role='button'], input[type='submit'], input[type='button']",
21
+ submit: "form",
22
+ };
23
+ /** Finds the live element for a manifest action: most stable selector first, visible matches only. */
24
+ export function resolveElement(action, doc, exclude) {
25
+ const ok = (el) => !!el && isVisible(el) && !(exclude && exclude.contains(el));
26
+ const pick = (sel) => {
27
+ for (const el of Array.from(doc.querySelectorAll(sel))) {
28
+ const target = action.kind === "submit" && el.tagName !== "FORM" ? el.closest("form") ?? el : el;
29
+ if (ok(target))
30
+ return target;
31
+ }
32
+ return null;
33
+ };
34
+ for (const s of action.selectors) {
35
+ let el = null;
36
+ switch (s.by) {
37
+ case "testid":
38
+ el = pick(`[data-testid=${q(s.value)}],[data-test=${q(s.value)}],[data-cy=${q(s.value)}]`);
39
+ break;
40
+ case "id":
41
+ el = pick(`[id=${q(s.value)}]`);
42
+ break;
43
+ case "name":
44
+ el = pick(`[name=${q(s.value)}]`);
45
+ break;
46
+ case "aria-label":
47
+ el = pick(`[aria-label=${q(s.value)}]`);
48
+ break;
49
+ case "href":
50
+ el = pick(`a[href=${q(s.value)}]`);
51
+ break;
52
+ case "text": {
53
+ if (s.value.startsWith("{"))
54
+ break; // runtime label: no fixed text to look for
55
+ const want = norm(s.value);
56
+ const candidates = action.kind === "submit"
57
+ ? Array.from(doc.querySelectorAll("form button, form input[type='submit']")).map((b) => ({ el: b.closest("form"), text: norm(b.textContent || b.getAttribute("value")) }))
58
+ : Array.from(doc.querySelectorAll(TAGS[action.kind])).map((el) => ({ el, text: norm(el.getAttribute("aria-label") || el.textContent || el.getAttribute("value")) }));
59
+ const hit = candidates.find((c) => c.text === want && ok(c.el)) ?? candidates.find((c) => c.text.startsWith(want) && ok(c.el));
60
+ el = hit?.el ?? null;
61
+ break;
62
+ }
63
+ }
64
+ if (el)
65
+ return el;
66
+ }
67
+ return null;
68
+ }
69
+ /** The runtime words on a control, for actions whose label is filled in at runtime ("{link.label}"). */
70
+ export function liveLabel(el) {
71
+ return (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
72
+ }
@@ -0,0 +1,13 @@
1
+ import type { Route } from "../types.ts";
2
+ /** "/products/[id]", "/join/:code", "/[[...slug]]" → a RegExp over a pathname. */
3
+ export declare function patternToRegex(pattern: string): RegExp;
4
+ /** "/?b=2&utm_source=x&a=1" → "/?a=1&b=2": the form routes are stored in. */
5
+ export declare function canonicalPath(pathWithSearch: string): string;
6
+ /**
7
+ * The manifest route a location belongs to, or null ("I don't know this road").
8
+ * Routes may carry query pairs ("/?lobby=1") for apps that switch screens with parameters;
9
+ * a location matches when its path matches and it has every pair. More pairs = more specific.
10
+ */
11
+ export declare function matchRoute(pathWithSearch: string, routes: Route[]): string | null;
12
+ /** Where a link goes, as a manifest route (hrefs carry ":param" for dynamic parts). */
13
+ export declare function hrefRoute(href: string, routes: Route[]): string | null;
@@ -0,0 +1,65 @@
1
+ /** "/products/[id]", "/join/:code", "/[[...slug]]" → a RegExp over a pathname. */
2
+ export function patternToRegex(pattern) {
3
+ const parts = pattern.split("/").filter(Boolean).map((seg) => {
4
+ if (/^\[\[\.\.\..+\]\]$/.test(seg))
5
+ return "(?:/.*)?";
6
+ if (/^\[\.\.\..+\]$/.test(seg))
7
+ return "/.+";
8
+ if (/^\[.+\]$/.test(seg) || seg.startsWith(":") || seg.includes(":param"))
9
+ return "/[^/]+";
10
+ if (seg === "*")
11
+ return "(?:/.*)?";
12
+ return "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
13
+ });
14
+ return new RegExp("^" + (parts.join("") || "/?") + "/?$");
15
+ }
16
+ /** How specific a pattern is: static segments win over params, params over catch-alls. */
17
+ function specificity(pattern) {
18
+ return pattern.split("/").filter(Boolean).reduce((n, seg) => {
19
+ if (seg.startsWith("[[...") || seg === "*")
20
+ return n;
21
+ if (seg.startsWith("[") || seg.startsWith(":"))
22
+ return n + 1;
23
+ return n + 3;
24
+ }, 0);
25
+ }
26
+ /** Tracking parameters never define a screen. */
27
+ const TRACKING = /^(utm_\w+|fbclid|gclid|msclkid|ref|ttclid|mc_\w+)$/i;
28
+ /** "/?b=2&utm_source=x&a=1" → "/?a=1&b=2": the form routes are stored in. */
29
+ export function canonicalPath(pathWithSearch) {
30
+ const [path, query = ""] = pathWithSearch.split("#")[0].split("?");
31
+ const params = [...new URLSearchParams(query)].filter(([k]) => !TRACKING.test(k)).sort(([a], [b]) => a.localeCompare(b));
32
+ return (path || "/") + (params.length ? "?" + new URLSearchParams(params).toString() : "");
33
+ }
34
+ /**
35
+ * The manifest route a location belongs to, or null ("I don't know this road").
36
+ * Routes may carry query pairs ("/?lobby=1") for apps that switch screens with parameters;
37
+ * a location matches when its path matches and it has every pair. More pairs = more specific.
38
+ */
39
+ export function matchRoute(pathWithSearch, routes) {
40
+ const [clean, query = ""] = pathWithSearch.split("#")[0].split("?");
41
+ const params = new URLSearchParams(query);
42
+ let best = null;
43
+ let bestScore = -1;
44
+ for (const r of routes) {
45
+ const [p, q] = r.path.split("?");
46
+ if (!patternToRegex(p).test(clean || "/"))
47
+ continue;
48
+ const pairs = q ? [...new URLSearchParams(q)] : [];
49
+ if (!pairs.every(([k, v]) => params.get(k) === v))
50
+ continue;
51
+ const sc = specificity(p) + pairs.length * 2;
52
+ if (sc > bestScore) {
53
+ best = r.path;
54
+ bestScore = sc;
55
+ }
56
+ }
57
+ return best;
58
+ }
59
+ /** Where a link goes, as a manifest route (hrefs carry ":param" for dynamic parts). */
60
+ export function hrefRoute(href, routes) {
61
+ if (!href.startsWith("/") && !href.startsWith("#/"))
62
+ return null;
63
+ const path = href.startsWith("#") ? href.slice(1) : href;
64
+ return matchRoute(path.replace(/:param/g, "x"), routes);
65
+ }
@@ -0,0 +1,58 @@
1
+ import type { DriverState, DriverUI } from "./driver.ts";
2
+ import type { TripPlan } from "./planner.ts";
3
+ export interface OverlayHandlers {
4
+ open(e: Event): void;
5
+ close(): void;
6
+ plan(destination: string, e: Event): void;
7
+ go(e: Event): void;
8
+ notThis(): void;
9
+ takeWheel(): void;
10
+ resume(e: Event): void;
11
+ end(): void;
12
+ rate(up: boolean): void;
13
+ }
14
+ export interface OverlayOptions {
15
+ /** Goals shown as chips under "Where to?" (the manifest's destinations). */
16
+ suggestions?: string[];
17
+ /** Free tier shows the badge. */
18
+ badge?: boolean;
19
+ /** Distance of the switch from the bottom-right corner, to clear a site's own corner buttons. */
20
+ offset?: {
21
+ right?: number;
22
+ bottom?: number;
23
+ };
24
+ }
25
+ /** The overlay: switch, destination box, ghost cursor, trip bar, rings. Lives in a shadow root. */
26
+ export declare class OverlayUI implements DriverUI {
27
+ private on;
28
+ private opts;
29
+ readonly host: HTMLElement;
30
+ private root;
31
+ private layer;
32
+ private switchBtn;
33
+ private panel;
34
+ private bar;
35
+ private cursor;
36
+ private cap;
37
+ private ringEl;
38
+ private ringTarget;
39
+ private raf;
40
+ private doc;
41
+ private win;
42
+ private destination;
43
+ constructor(win: Window, on: OverlayHandlers, opts?: OverlayOptions);
44
+ owns(node: Node | null): boolean;
45
+ destroy(): void;
46
+ render(state: DriverState, info: {
47
+ plan?: TripPlan | null;
48
+ caption?: string;
49
+ message?: string;
50
+ destination?: string;
51
+ step?: number;
52
+ }): void;
53
+ private renderOpen;
54
+ private renderPlan;
55
+ private renderArrived;
56
+ moveCursor(el: Element, caption: string): Promise<void>;
57
+ ring(el: Element | null, tone?: "drive" | "gate" | "handoff"): void;
58
+ }
@@ -0,0 +1,267 @@
1
+ const CSS = `
2
+ :host{all:initial}
3
+ *{box-sizing:border-box;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
4
+ .layer{position:fixed;inset:0;pointer-events:none;z-index:2147483646}
5
+ .auto{pointer-events:auto}
6
+ button{font:inherit;cursor:pointer}
7
+ .switch{position:fixed;right:var(--pm-right,20px);bottom:var(--pm-bottom,20px);max-width:calc(100vw - var(--pm-right,20px) - 12px);display:inline-flex;align-items:center;gap:12px;min-height:48px;padding:6px 8px 6px 16px;border-radius:999px;border:1px solid #DDDAD0;background:#fff;color:#111418;box-shadow:0 8px 24px rgba(17,20,24,.12);text-align:left}
8
+ .switch[aria-checked="true"]{background:#E6ECFF;border-color:#A9BCFF}
9
+ .switch .t{display:flex;flex-direction:column;line-height:1.15}
10
+ .switch .t b{font-size:14px;font-weight:600}
11
+ .switch[aria-checked="true"] .t b{color:#1A3FCC}
12
+ .switch .t span{font-size:12px;color:#565C66}
13
+ .track{width:40px;height:24px;border-radius:999px;background:#C9C6BE;position:relative;flex:none;transition:background .16s}
14
+ .knob{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:left .16s}
15
+ .switch[aria-checked="true"] .track{background:#2D5BFF}
16
+ .switch[aria-checked="true"] .knob{left:19px}
17
+ .badge{position:fixed;right:calc(var(--pm-right,20px) + 8px);bottom:calc(var(--pm-bottom,20px) - 16px);font-size:10px;color:#565C66}
18
+ .panel{position:fixed;right:var(--pm-right,20px);bottom:calc(var(--pm-bottom,20px) + 60px);width:min(420px,calc(100vw - var(--pm-right,20px) - 20px));background:#fff;color:#111418;border:1px solid #DDDAD0;border-radius:18px;box-shadow:0 24px 60px rgba(17,20,24,.16);padding:20px;display:flex;flex-direction:column;gap:14px}
19
+ .panel h2{margin:0;font-size:20px;font-weight:650;letter-spacing:-.01em}
20
+ .panel p{margin:0;font-size:14px;line-height:1.5;color:#3A4049}
21
+ .small{font-size:12px!important;color:#565C66!important}
22
+ .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
23
+ input{flex:1;min-width:0;min-height:46px;border:1.5px solid #2D5BFF;border-radius:12px;padding:0 14px;font:inherit;font-size:15px;color:#111418;outline:none}
24
+ .primary{min-height:46px;padding:0 16px;border-radius:12px;border:none;background:#111418;color:#F5F4EF;font-weight:600}
25
+ .go{flex:1;background:#2D5BFF;color:#fff}
26
+ .ghost{min-height:46px;padding:0 14px;border-radius:12px;border:1px solid #DDDAD0;background:#fff;color:#111418}
27
+ .chip{min-height:36px;padding:0 12px;border-radius:999px;border:1px solid #DDDAD0;background:#FAF9F6;color:#111418;font-size:13px}
28
+ ol{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:8px;font-size:14px}
29
+ ol li{display:flex;gap:10px;align-items:center}
30
+ .n{width:22px;height:22px;border-radius:50%;background:#E6ECFF;color:#1A3FCC;font-size:12px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;flex:none}
31
+ .n.gate{background:#FDF1DC;color:#6B4A0E}
32
+ .bar{position:fixed;left:50%;bottom:calc(var(--pm-bottom,20px) + 64px);transform:translateX(-50%);max-width:calc(100vw - 24px);display:flex;flex-wrap:wrap;align-items:center;gap:12px;background:#111418;color:#F5F4EF;border-radius:16px;padding:10px 10px 10px 16px;box-shadow:0 16px 40px rgba(17,20,24,.25);font-size:14px}
33
+ .bar .dot{width:8px;height:8px;border-radius:50%;background:#5C84FF;flex:none}
34
+ .bar[data-tone="gate"] .dot,.bar[data-tone="handoff"] .dot{background:#F2A93B}
35
+ .bar[data-tone="paused"] .dot{background:#C9C6BE}
36
+ .bar .msg{color:#C9C6BE;max-width:44ch}
37
+ .bar button{min-height:40px;padding:0 14px;border-radius:10px;border:1px solid #3A4049;background:transparent;color:#F5F4EF}
38
+ .bar .resume{background:#2D5BFF;border-color:#2D5BFF;color:#fff;font-weight:600}
39
+ .cursor{position:fixed;left:0;top:0;display:flex;flex-direction:column;align-items:flex-start;gap:6px;transition:transform .7s cubic-bezier(.4,0,.2,1);will-change:transform}
40
+ .cap{background:#111418;color:#F5F4EF;font-size:13px;font-weight:500;border-radius:8px;padding:6px 10px;white-space:nowrap}
41
+ .ring{position:fixed;border-radius:12px;transition:all .25s ease}
42
+ .ring[data-tone="drive"]{box-shadow:0 0 0 3px #2D5BFF,0 0 0 9px rgba(45,91,255,.18)}
43
+ .ring[data-tone="gate"]{box-shadow:0 0 0 3px #F2A93B,0 0 0 10px rgba(242,169,59,.28);animation:pulse 1.6s infinite}
44
+ .ring[data-tone="handoff"]{box-shadow:0 0 0 3px #F2A93B,0 0 0 9px rgba(242,169,59,.18)}
45
+ @keyframes pulse{0%,100%{box-shadow:0 0 0 3px #F2A93B,0 0 0 6px rgba(242,169,59,.35)}50%{box-shadow:0 0 0 3px #F2A93B,0 0 0 16px rgba(242,169,59,0)}}
46
+ .done{display:flex;align-items:center;gap:10px}
47
+ .thumb{width:44px;height:44px;border-radius:12px;border:1px solid #DDDAD0;background:#fff;display:inline-flex;align-items:center;justify-content:center}
48
+ .thumb[aria-pressed="true"]{border-color:#2D5BFF;background:#E6ECFF}
49
+ [hidden]{display:none!important}
50
+ @media (prefers-reduced-motion:reduce){.cursor,.ring,.knob,.track{transition:none}.ring[data-tone="gate"]{animation:none}}
51
+ @media (max-width:600px){.cursor svg{display:none}.panel{left:12px;right:12px;width:auto}.bar{left:12px;right:12px;transform:none;max-width:none}}
52
+ `;
53
+ const CURSOR_SVG = `<svg width="30" height="34" viewBox="0 0 30 34" aria-hidden="true"><path d="M3 2 L26 19 L15 21 L10 32 Z" fill="#2D5BFF" fill-opacity=".6" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg>`;
54
+ const THUMB = `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="#111418" stroke-width="1.6" stroke-linejoin="round" aria-hidden="true"><path d="M6 9v8H3V9zM6 9l3.5-6.5c1.2 0 2 .9 1.8 2.1L10.8 8H16a1.5 1.5 0 0 1 1.5 1.8l-1.2 6A1.5 1.5 0 0 1 14.8 17H6"/></svg>`;
55
+ function h(doc, tag, attrs = {}, text) {
56
+ const el = doc.createElement(tag);
57
+ for (const [k, v] of Object.entries(attrs))
58
+ el.setAttribute(k, v);
59
+ if (text !== undefined)
60
+ el.textContent = text;
61
+ return el;
62
+ }
63
+ /** The overlay: switch, destination box, ghost cursor, trip bar, rings. Lives in a shadow root. */
64
+ export class OverlayUI {
65
+ on;
66
+ opts;
67
+ host;
68
+ root;
69
+ layer;
70
+ switchBtn;
71
+ panel;
72
+ bar;
73
+ cursor;
74
+ cap;
75
+ ringEl;
76
+ ringTarget = null;
77
+ raf = 0;
78
+ doc;
79
+ win;
80
+ destination = "";
81
+ constructor(win, on, opts = {}) {
82
+ this.on = on;
83
+ this.opts = opts;
84
+ this.win = win;
85
+ this.doc = win.document;
86
+ const d = this.doc;
87
+ this.host = h(d, "div", { id: "passenger-mode", "data-passenger-mode": "" });
88
+ this.root = this.host.attachShadow({ mode: "open" });
89
+ const style = h(d, "style");
90
+ style.textContent = CSS;
91
+ this.layer = h(d, "div", { class: "layer" });
92
+ if (opts.offset?.right != null)
93
+ this.layer.style.setProperty("--pm-right", `${opts.offset.right}px`);
94
+ if (opts.offset?.bottom != null)
95
+ this.layer.style.setProperty("--pm-bottom", `${opts.offset.bottom}px`);
96
+ this.ringEl = h(d, "div", { class: "ring", hidden: "" });
97
+ this.cursor = h(d, "div", { class: "cursor", hidden: "", "aria-hidden": "true" });
98
+ this.cursor.innerHTML = CURSOR_SVG;
99
+ this.cap = h(d, "span", { class: "cap" });
100
+ this.cursor.appendChild(this.cap);
101
+ this.switchBtn = h(d, "button", { class: "switch auto", type: "button", role: "switch", "aria-checked": "false" });
102
+ this.switchBtn.innerHTML = `<span class="t"><b>Passenger mode</b><span>Say where you're going. We'll do the clicking.</span></span><span class="track"><span class="knob"></span></span>`;
103
+ this.switchBtn.addEventListener("click", (e) => (this.switchBtn.getAttribute("aria-checked") === "true" ? this.on.close() : this.on.open(e)));
104
+ this.panel = h(d, "div", { class: "panel auto", role: "dialog", "aria-label": "Passenger mode", hidden: "" });
105
+ this.bar = h(d, "div", { class: "bar auto", role: "status", "aria-live": "polite", hidden: "" });
106
+ this.layer.append(this.ringEl, this.cursor, this.panel, this.bar, this.switchBtn);
107
+ if (opts.badge)
108
+ this.layer.appendChild(h(d, "span", { class: "badge" }, "by Passenger Mode"));
109
+ this.root.append(style, this.layer);
110
+ d.body.appendChild(this.host);
111
+ }
112
+ owns(node) {
113
+ if (!node)
114
+ return false;
115
+ if (node === this.host || this.host.contains(node))
116
+ return true;
117
+ const r = node.getRootNode?.();
118
+ return r === this.root;
119
+ }
120
+ destroy() {
121
+ this.win.cancelAnimationFrame?.(this.raf);
122
+ this.host.remove();
123
+ }
124
+ // ---------- rendering ----------
125
+ render(state, info) {
126
+ const d = this.doc;
127
+ if (info.destination)
128
+ this.destination = info.destination;
129
+ this.switchBtn.setAttribute("aria-checked", state === "off" ? "false" : "true");
130
+ const inTrip = state === "driving" || state === "paused" || state === "gate" || state === "handoff";
131
+ // On a handoff the passenger is typing: no ghost cursor saying what the driver is pressing.
132
+ this.cursor.hidden = !inTrip || state === "handoff";
133
+ if (!inTrip)
134
+ this.ring(null);
135
+ // Panel
136
+ this.panel.replaceChildren();
137
+ this.panel.hidden = !(state === "open" || state === "planning" || state === "ready" || state === "stuck" || state === "arrived");
138
+ if (state === "open")
139
+ this.renderOpen();
140
+ if (state === "planning")
141
+ this.panel.append(h(d, "h2", {}, "Planning the route…"), h(d, "p", { class: "small" }, this.destination));
142
+ if (state === "ready" && info.plan)
143
+ this.renderPlan(info.plan);
144
+ if (state === "stuck") {
145
+ const again = h(d, "button", { class: "primary", type: "button" }, "Try another destination");
146
+ again.addEventListener("click", () => this.on.notThis());
147
+ this.panel.append(h(d, "h2", {}, "Pulled over"), h(d, "p", {}, info.message ?? "I don't know this road."), again);
148
+ }
149
+ if (state === "arrived")
150
+ this.renderArrived(info.message ?? "");
151
+ // Trip bar
152
+ this.bar.replaceChildren();
153
+ this.bar.hidden = !inTrip;
154
+ if (inTrip) {
155
+ this.bar.dataset.tone = state === "driving" ? "drive" : state;
156
+ const title = state === "driving" ? "Passenger mode is driving" : state === "paused" ? "You have the wheel" : state === "gate" ? "Pulled over" : "Your turn";
157
+ this.bar.append(h(d, "span", { class: "dot" }), h(d, "b", {}, title));
158
+ const msg = state === "driving" ? info.caption : info.message;
159
+ if (msg)
160
+ this.bar.append(h(d, "span", { class: "msg" }, msg));
161
+ if (state === "driving") {
162
+ const take = h(d, "button", { type: "button" }, "Take the wheel");
163
+ take.addEventListener("click", () => this.on.takeWheel());
164
+ this.bar.append(take);
165
+ }
166
+ if (state === "paused" || state === "handoff") {
167
+ const res = h(d, "button", { type: "button", class: "resume" }, "Resume");
168
+ res.addEventListener("click", (e) => this.on.resume(e));
169
+ this.bar.append(res);
170
+ }
171
+ const end = h(d, "button", { type: "button" }, "End trip");
172
+ end.addEventListener("click", () => this.on.end());
173
+ this.bar.append(end);
174
+ }
175
+ if (state === "driving" && info.caption)
176
+ this.cap.textContent = info.caption;
177
+ }
178
+ renderOpen() {
179
+ const d = this.doc;
180
+ const form = h(d, "form", { class: "row" });
181
+ const label = h(d, "label", { for: "pm-dest" }, "Where to?");
182
+ const title = h(d, "h2");
183
+ title.appendChild(label);
184
+ const input = h(d, "input", { id: "pm-dest", name: "destination", autocomplete: "off", maxlength: "300", placeholder: "Say it your way: cancel my plan, invite Jo…" });
185
+ const btn = h(d, "button", { class: "primary", type: "submit" }, "Plan trip");
186
+ form.append(input, btn);
187
+ form.addEventListener("submit", (e) => { e.preventDefault(); if (input.value.trim())
188
+ this.on.plan(input.value, e); });
189
+ this.panel.append(title, form);
190
+ const sugg = (this.opts.suggestions ?? []).slice(0, 4);
191
+ if (sugg.length) {
192
+ const row = h(d, "div", { class: "row" });
193
+ for (const s of sugg) {
194
+ const chip = h(d, "button", { class: "chip", type: "button" }, s);
195
+ chip.addEventListener("click", (e) => this.on.plan(s, e));
196
+ row.appendChild(chip);
197
+ }
198
+ this.panel.append(h(d, "p", { class: "small" }, "Popular here"), row);
199
+ }
200
+ this.panel.append(h(d, "p", { class: "small" }, "Nothing moves until you press Go. Any click takes the wheel."));
201
+ setTimeout(() => input.focus(), 0);
202
+ }
203
+ renderPlan(plan) {
204
+ const d = this.doc;
205
+ this.panel.append(h(d, "p", { class: "small" }, `Where to: ${this.destination}`), h(d, "h2", {}, plan.summary));
206
+ const ol = h(d, "ol");
207
+ plan.steps.forEach((s, i) => {
208
+ const li = h(d, "li");
209
+ const gated = /you press it/i.test(s);
210
+ li.append(h(d, "span", { class: gated ? "n gate" : "n" }, String(i + 1)), d.createTextNode(s));
211
+ ol.appendChild(li);
212
+ });
213
+ const go = h(d, "button", { class: "primary go", type: "button" }, "Go");
214
+ go.addEventListener("click", (e) => this.on.go(e));
215
+ const not = h(d, "button", { class: "ghost", type: "button" }, "Not this");
216
+ not.addEventListener("click", () => this.on.notThis());
217
+ const row = h(d, "div", { class: "row" });
218
+ row.append(go, not);
219
+ this.panel.append(ol, row);
220
+ setTimeout(() => go.focus(), 0);
221
+ }
222
+ renderArrived(message) {
223
+ const d = this.doc;
224
+ const head = h(d, "div", { class: "done" });
225
+ head.innerHTML = `<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true"><circle cx="14" cy="14" r="13" fill="#2D5BFF"/><path d="M8.5 14.5l3.5 3.5 7.5-8" stroke="#fff" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
226
+ head.appendChild(h(d, "h2", {}, "You're here"));
227
+ const row = h(d, "div", { class: "row" });
228
+ const mk = (up) => {
229
+ const b = h(d, "button", { class: "thumb", type: "button", "aria-label": up ? "Good trip" : "Bad trip", "aria-pressed": "false" });
230
+ b.innerHTML = up ? THUMB : THUMB.replace("<svg ", '<svg style="transform:rotate(180deg)" ');
231
+ b.addEventListener("click", () => { this.on.rate(up); row.querySelectorAll(".thumb").forEach((t) => t.setAttribute("aria-pressed", "false")); b.setAttribute("aria-pressed", "true"); });
232
+ return b;
233
+ };
234
+ const done = h(d, "button", { class: "ghost", type: "button" }, "Done");
235
+ done.addEventListener("click", () => this.on.end());
236
+ row.append(h(d, "span", { class: "small" }, "How was the trip?"), mk(true), mk(false), done);
237
+ this.panel.append(head, h(d, "p", {}, message), row);
238
+ }
239
+ // ---------- cursor and ring ----------
240
+ moveCursor(el, caption) {
241
+ this.cap.textContent = caption;
242
+ this.cursor.hidden = false;
243
+ const r = el.getBoundingClientRect();
244
+ const x = Math.max(4, Math.min(r.left + Math.min(r.width * 0.6, 60), (this.win.innerWidth || 1024) - 40));
245
+ const y = Math.max(4, r.top + Math.min(r.height * 0.6, 24));
246
+ this.cursor.style.transform = `translate(${x}px, ${y}px)`;
247
+ const reduce = this.win.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
248
+ return new Promise((res) => setTimeout(res, reduce ? 300 : 900));
249
+ }
250
+ ring(el, tone = "drive") {
251
+ this.ringTarget = el;
252
+ this.ringEl.hidden = !el;
253
+ if (!el) {
254
+ this.win.cancelAnimationFrame?.(this.raf);
255
+ return;
256
+ }
257
+ this.ringEl.dataset.tone = tone;
258
+ const tick = () => {
259
+ if (!this.ringTarget)
260
+ return;
261
+ const r = this.ringTarget.getBoundingClientRect();
262
+ Object.assign(this.ringEl.style, { left: `${r.left - 4}px`, top: `${r.top - 4}px`, width: `${r.width + 8}px`, height: `${r.height + 8}px` });
263
+ this.raf = this.win.requestAnimationFrame?.(tick) ?? 0;
264
+ };
265
+ tick();
266
+ }
267
+ }