diff --git a/README.md b/README.md index 38d83d5..53e3b46 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Run an existing Twilio voice app on Bandwidth's network — without rewriting it.** Point your app at the adapter, change one URL, and its calls now run on Bandwidth. The code never changes. -> **Status:** Inbound/outbound calls, call control, recordings, and the two key webhooks are working and proven on real calls. Number **search** is live-verified; number **ordering/release** are experimental (see [Number lifecycle](#number-lifecycle)). 255 automated tests, typecheck clean. +> **Status:** Inbound/outbound calls, call control, recordings, and the two key webhooks are working and proven on real calls. Number **search** is live-verified; number **ordering/release** are experimental (see [Number lifecycle](#number-lifecycle)). 265 automated tests, typecheck clean. --- @@ -57,6 +57,14 @@ The translation is a fixed rulebook driven by a single [compatibility matrix](sr ## Quickstart +**Migration Preflight playground (for demos — a double-click HTML file):** +```bash +npm install +npm run playground:build # writes dist/playground.html +open dist/playground.html # (macOS) or just double-click it +``` +A single self-contained page — no server, no network, works offline. Paste a customer's TwiML (or pick a curated example) and see the live *works-as-is / heads-up / blocker* verdict, a migration-complexity score, the translated BXML, and a forwardable report. It runs the **real** translation engine in the browser, so the verdict matches what the adapter does in production. Built for sales engineers to drive live on a screen-share; see [`web/README.md`](web/README.md). + **See a migration report (30s, no accounts):** ```bash npm install @@ -69,7 +77,7 @@ Prints a migration-complexity score and a per-feature *works-as-is / heads-up / **Run the adapter** (Node 20+): ```bash npm start # adapter on :3000 -npm test # 255 tests +npm test # 265 tests npm run typecheck ``` @@ -136,7 +144,7 @@ Reproduce the live check (read-only by default) with [`scripts/verify-numbers-li ## How it's built -All translation is driven by one declarative compatibility matrix (`src/matrix/twilio-voice.json`) — the runtime adapter and the pre-flight report read the same data, so they can't disagree. +All translation is driven by one declarative compatibility matrix (`src/matrix/twilio-voice.json`) — the runtime adapter, the pre-flight report, and the Migration Preflight playground all read the same data, so they can't disagree. ``` src/translator TwiML → BXML translation @@ -145,6 +153,8 @@ src/streams Media Streams bridge src/numbers number-lifecycle facade (search/order/release) src/server the proxy (Fastify) wiring it together src/preflight static migration-complexity report +web Migration Preflight playground (double-click HTML demo) +scripts build-playground.ts + benches / live-verify helpers ``` Tests live in `test/` (Vitest); CI gates `npm run typecheck` + `npm test`. diff --git a/package-lock.json b/package-lock.json index a09b12e..ae21881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@types/node": "^25.9.2", "@types/ws": "^8.18.1", + "esbuild": "^0.28.0", "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.8" diff --git a/package.json b/package.json index db1143e..0295943 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "generate": "tsx src/generate/cli.ts", "bench": "tsx scripts/bench-translate.ts", "bench:overhead": "tsx scripts/bench-adapter-overhead.ts", - "latency:report": "tsx scripts/latency-report.ts" + "latency:report": "tsx scripts/latency-report.ts", + "playground:build": "tsx scripts/build-playground.ts" }, "dependencies": { "@fastify/formbody": "^8.0.2", @@ -23,6 +24,7 @@ "devDependencies": { "@types/node": "^25.9.2", "@types/ws": "^8.18.1", + "esbuild": "^0.28.0", "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.8" diff --git a/scripts/build-playground.ts b/scripts/build-playground.ts new file mode 100644 index 0000000..d1ef280 --- /dev/null +++ b/scripts/build-playground.ts @@ -0,0 +1,87 @@ +// Builds the self-contained Migration Preflight artifact: web/ sources + the +// real translation engine + DM Sans fonts, all inlined into a single +// dist/playground.html a salesperson can double-click. No server, no network. + +import { build } from "esbuild"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const webDir = join(root, "web"); +const fontsDir = join(webDir, "fonts"); +const outDir = join(root, "dist"); +const outFile = join(outDir, "playground.html"); + +// DM Sans (Bandwidth brand face), inlined as base64 woff2 so the file is offline-safe. +const FONT_FACES: Array<{ file: string; weight: string; style: string }> = [ + { file: "dm-sans-latin-400-normal.woff2", weight: "400 500", style: "normal" }, + { file: "dm-sans-latin-700-normal.woff2", weight: "600 700", style: "normal" }, + { file: "dm-sans-latin-800-normal.woff2", weight: "800", style: "normal" }, + { file: "dm-sans-latin-400-italic.woff2", weight: "400 700", style: "italic" }, +]; + +async function fontCss(): Promise { + const faces = await Promise.all( + FONT_FACES.map(async ({ file, weight, style }) => { + const b64 = (await readFile(join(fontsDir, file))).toString("base64"); + return `@font-face{font-family:"DM Sans";font-style:${style};font-weight:${weight};font-display:swap;src:url("data:font/woff2;base64,${b64}") format("woff2");}`; + }), + ); + return faces.join("\n"); +} + +async function main(): Promise { + const [html, css, fonts, bundle] = await Promise.all([ + readFile(join(webDir, "index.html"), "utf8"), + readFile(join(webDir, "styles.css"), "utf8"), + fontCss(), + build({ + entryPoints: [join(webDir, "app.ts")], + bundle: true, + format: "iife", + platform: "browser", + target: "es2020", + minify: true, + write: false, + legalComments: "none", + }).then((r) => r.outputFiles[0].text), + ]); + + const styleBlock = ``; + const scriptBlock = ``; + + // split/join, not replace(): the minified bundle contains "$" sequences that + // String.replace would interpret as special replacement patterns. + const out = html + .split("") + .join(styleBlock) + .split("") + .join(scriptBlock); + + if (out.includes("build injects")) { + throw new Error("Injection markers not replaced — index.html markers may have changed."); + } + + // Self-contained check: no external *resource loads* (script/link/img src/href, + // CSS url(http…), @import). Anchor links to docs (https://…) are fine. + const offenders = [ + /]+src\s*=\s*["']https?:/i, + /]+href\s*=\s*["']https?:/i, + /]+src\s*=\s*["']https?:/i, + /url\(\s*["']?https?:/i, + /@import\s+["']?https?:/i, + ].filter((re) => re.test(out)); + if (offenders.length) { + throw new Error(`Output is not self-contained — external resource reference(s): ${offenders}`); + } + + await mkdir(outDir, { recursive: true }); + await writeFile(outFile, out, "utf8"); + console.log(`Wrote ${outFile} (${(Buffer.byteLength(out) / 1024).toFixed(0)} KB)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/matrix/load.ts b/src/matrix/load.ts index 6c90e47..e359a84 100644 --- a/src/matrix/load.ts +++ b/src/matrix/load.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); +import matrixData from "./twilio-voice.json" with { type: "json" }; const Status = z.enum(["supported", "partial", "unsupported"]); @@ -30,5 +28,5 @@ export type VerbMapping = z.infer; export type CompatMatrix = z.infer; export function loadMatrix(): CompatMatrix { - return MatrixSchema.parse(require("./twilio-voice.json")); + return MatrixSchema.parse(matrixData); } diff --git a/test/playground-view.test.ts b/test/playground-view.test.ts new file mode 100644 index 0000000..d2faae6 --- /dev/null +++ b/test/playground-view.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { buildView } from "../web/view-model.js"; +import { EXAMPLES } from "../web/examples.js"; + +const byId = (id: string) => EXAMPLES.find((e) => e.id === id)!.twiml; + +describe("buildView — input guards", () => { + it("rejects empty input", () => { + const v = buildView(" "); + expect(v.ok).toBe(false); + expect(v.error).toMatch(/paste/i); + }); + + it("rejects non-TwiML", () => { + const v = buildView("just some text"); + expect(v.ok).toBe(false); + expect(v.error).toMatch(/Response/); + }); +}); + +describe("buildView — curated examples", () => { + for (const ex of EXAMPLES) { + it(`${ex.label} analyzes cleanly with a bounded score and real BXML`, () => { + const v = buildView(ex.twiml); + expect(v.ok).toBe(true); + expect(v.score).toBeGreaterThanOrEqual(1); + expect(v.score).toBeLessThanOrEqual(10); + expect(v.bxml).toContain(""); + expect(v.bxml).toContain("SpeakSentence"); // every example has a + const total = v.counts.clean + v.counts.headsUp + v.counts.blocker; + expect(total).toBe(v.blockers.length + v.headsUp.length + v.clean.length); + expect(total).toBeGreaterThan(0); + }); + } + + it("Queue example surfaces Enqueue as a blocker", () => { + const v = buildView(byId("queue")); + expect(v.blockers.some((c) => c.verb === "Enqueue")).toBe(true); + expect(v.blockers[0].pill).toBe("No equivalent"); + }); + + it("Conference example flags the hold-music blocker", () => { + const v = buildView(byId("conference")); + expect(v.blockers.some((c) => c.verb === "Conference")).toBe(true); + }); + + it("IVR example maps the Polly voice as a heads-up, not a failure", () => { + const v = buildView(byId("ivr")); + expect(v.headsUp.some((c) => c.verb === "Say")).toBe(true); + expect(v.counts.blocker).toBe(0); + }); + + it("marks a supported 1:1 verb with a target and 'Maps 1:1'", () => { + const v = buildView(byId("ivr")); + const gather = v.clean.find((c) => c.verb === "Gather"); + expect(gather?.pill).toBe("Maps 1:1"); + expect(gather?.target).toBe("Gather"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index dedf197..b6c1223 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,5 @@ "noEmit": true, "types": ["node"] }, - "include": ["src", "test"] + "include": ["src", "test", "web"] } diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..64c57f4 --- /dev/null +++ b/web/README.md @@ -0,0 +1,39 @@ +# Migration Preflight playground + +A single, self-contained HTML file a sales engineer can **double-click** and present to a prospect. Paste a customer's TwiML (or pick a curated example) and it shows — live — how much of that Twilio voice app runs on Bandwidth unchanged: a works-as-is / heads-up / blocker verdict, a migration-complexity score, the translated BXML, and a forwardable report. + +No server, no install, no network. It works offline on a plane. + +## Why it's trustworthy + +The page runs the **real translation engine** (`src/translator`, `src/preflight`) and the real compatibility matrix (`src/matrix/twilio-voice.json`) in the browser — the exact same code the live adapter uses. It does not reimplement any translation or scoring logic. Because the same matrix drives the adapter and this page, the verdict shown here provably matches what happens in production. That invariant is the point; don't break it by hand-coding results into the UI. + +## Build & run + +```bash +npm run playground:build # writes dist/playground.html +open dist/playground.html # macOS — or just double-click the file +``` + +`dist/` is git-ignored (it's a build product); rebuild it whenever the engine, matrix, or UI changes. + +## Layout + +| File | Purpose | +|---|---| +| `view-model.ts` | The only new logic: `buildView(twiml)` calls the engine and shapes the result. Pure, DOM-free, unit-tested (`test/playground-view.test.ts`). | +| `app.ts` | DOM rendering + interactions (chips, BXML toggle, print, copy). Calls `buildView`. | +| `index.html` | Static shell (header, input, caveats, actions) with injection markers for the build. | +| `styles.css` | All styling. Brand tokens live in `:root` — swap them there to re-theme. | +| `examples.ts` | Curated TwiML examples. Verdicts are computed live, never hardcoded. | +| `fonts/` | DM Sans (Bandwidth brand face), woff2. Inlined as base64 at build time. | + +The build (`scripts/build-playground.ts`, esbuild) bundles `app.ts` + the engine, inlines the CSS and fonts into `index.html`, and asserts the output has no external resource references before writing it. + +## Design + +The visual design comes from a Bandwidth design-system mockup (DM Sans, primary `#076ea8`). To re-theme, edit the `:root` custom properties in `styles.css` in one place. + +## Scope + +Intentionally narrow: one screen, TwiML-document input only. It does **not** scan source code (that's the CLI `npm run preflight`), place live calls, or talk to Bandwidth. Those need a server and are out of scope for this demo tool. diff --git a/web/app.ts b/web/app.ts new file mode 100644 index 0000000..f56fa1b --- /dev/null +++ b/web/app.ts @@ -0,0 +1,253 @@ +// DOM wiring for the Migration Preflight page. Keeps no logic of its own beyond +// presentation — it calls buildView() (which runs the real engine) and paints +// the result. See view-model.ts for the honesty invariant. + +import { buildView, type PreflightView, type VerbCard } from "./view-model.js"; +import { EXAMPLES, DEFAULT_EXAMPLE } from "./examples.js"; + +const $ = (id: string) => document.getElementById(id) as T; + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +// The engine emits compact single-line BXML; indent it for readable display. +// Leaf elements with text (hi) stay on one line; +// self-closing tags don't nest. Operates on raw XML — esc() is applied after. +function formatXml(xml: string): string { + const withBreaks = xml.replace(/>\s*\n<"); + let pad = 0; + return withBreaks + .split("\n") + .map((node) => { + let indent = 0; + if (/^<\/\w/.test(node)) { + pad = Math.max(0, pad - 1); // closing tag: dedent before printing + } else if (/^<\w[^>]*[^/]>.*$/.test(node) && !/.+<\/\w[^>]*>\s*$/.test(node)) { + indent = 1; // opening tag with no matching close on this line: indent after + } + const line = " ".repeat(pad) + node; + pad += indent; + return line; + }) + .join("\n"); +} + +// ---- SVG icons (inline so the artifact stays self-contained) ---- +const ICON = { + clean: ``, + warn: ``, + block: ``, + docs: ``, +}; +const smallIcon = (name: keyof typeof ICON) => + ICON[name].replace('width="20" height="20"', 'width="15" height="15"'); + +// ---- Verdict ---- +function gaugeColor(score: number): string { + if (score <= 3) return "var(--color-clean)"; + if (score <= 6) return "var(--color-warn)"; + return "var(--color-block)"; +} + +function renderVerdict(v: PreflightView): string { + const C = 2 * Math.PI * 72; // gauge circumference + const offset = C * (1 - v.score / 10); + const color = gaugeColor(v.score); + const labelColor = + v.score <= 3 ? "var(--color-clean-text)" : v.score <= 6 ? "var(--color-warn-text)" : "var(--color-block-text)"; + + return ` +
+
+
+ + + + +
+
${v.score}/10
+
${esc(v.scoreLabel)}
+
+
+
Migration complexity score
+
+
+

${esc(v.headline)}

+

${verdictSub(v)}

+
+ ${tile("clean", v.counts.clean, "Clean")} + ${tile("warn", v.counts.headsUp, "Heads-up")} + ${tile("block", v.counts.blocker, "Blocker")} +
+
+
`; +} + +function verdictSub(v: PreflightView): string { + const total = v.counts.clean + v.counts.headsUp + v.counts.blocker; + const parts = [`${v.counts.clean} of ${total} verbs translate cleanly`]; + if (v.counts.headsUp) parts.push(`${v.counts.headsUp} need${v.counts.headsUp === 1 ? "s" : ""} a quick review`); + if (v.counts.blocker) + parts.push(`${v.counts.blocker} ha${v.counts.blocker === 1 ? "s" : "ve"} no direct equivalent`); + return esc(parts.join("; ") + "."); +} + +function tile(kind: "clean" | "warn" | "block", n: number, label: string): string { + const icon = kind === "clean" ? ICON.clean : kind === "warn" ? ICON.warn : ICON.block; + return ` +
+
${icon}${n}
+
${label}
+
`; +} + +// ---- Findings ---- +function renderFindings(v: PreflightView): string { + const groups: Array<[string, "block" | "warn" | "clean", VerbCard[]]> = [ + ["Blockers", "block", v.blockers], + ["Heads-up", "warn", v.headsUp], + ["Clean", "clean", v.clean], + ]; + const textColor = { block: "var(--color-block-text)", warn: "var(--color-warn-text)", clean: "var(--color-clean-text)" }; + const iconName = { block: "block", warn: "warn", clean: "clean" } as const; + + const sections = groups + .filter(([, , cards]) => cards.length > 0) + .map( + ([label, kind, cards]) => ` +
+ ${smallIcon(iconName[kind])} + ${label} + +
+
${cards.map((c) => card(c, kind)).join("")}
`, + ) + .join(""); + + return ` +
+

Findings

+

Every verb we saw, and what happens to it on Bandwidth.

+ ${sections} +
`; +} + +function card(c: VerbCard, kind: "block" | "warn" | "clean"): string { + const verbLabel = c.target ? `<${esc(c.verb)}> → <${esc(c.target)}>` : `<${esc(c.verb)}>`; + const messages = c.messages.map((m) => `

${esc(m)}

`).join(""); + const docs = c.docsUrl + ? `Docs ${ICON.docs}` + : ""; + return ` +
+
+
+ ${verbLabel} + ${esc(c.pill)} +
+ ${messages} +
+ ${docs} +
`; +} + +// ---- BXML disclosure ---- +function renderBxml(v: PreflightView): string { + return ` +
+ + +
`; +} + +// ---- Orchestration ---- +let current: PreflightView | null = null; + +function render(twiml: string): void { + const v = buildView(twiml); + current = v; + const verdict = $("pf-verdict"); + const findings = $("pf-findings"); + const bxml = $("pf-bxml"); + const error = $("pf-error"); + + if (!v.ok) { + verdict.innerHTML = ""; + findings.innerHTML = ""; + bxml.innerHTML = ""; + error.innerHTML = `
${esc(v.error ?? "Something went wrong.")}
`; + return; + } + + error.innerHTML = ""; + verdict.innerHTML = renderVerdict(v); + findings.innerHTML = renderFindings(v); + bxml.innerHTML = renderBxml(v); + + const toggle = $("pf-bxml-toggle"); + const body = $("pf-bxml-body"); + toggle.addEventListener("click", () => { + const open = toggle.getAttribute("aria-expanded") === "true"; + toggle.setAttribute("aria-expanded", String(!open)); + body.style.display = open ? "none" : "block"; + }); +} + +function renderChips(active: string): void { + const chips = $("pf-chips"); + chips.innerHTML = EXAMPLES.map( + (e) => ``, + ).join(""); + for (const btn of Array.from(chips.querySelectorAll(".pf-chip"))) { + btn.addEventListener("click", () => { + const ex = EXAMPLES.find((e) => e.id === btn.dataset.id)!; + ($("twiml-input") as HTMLTextAreaElement).value = ex.twiml; + renderChips(ex.id); + render(ex.twiml); + }); + } +} + +function init(): void { + const ta = $("twiml-input") as HTMLTextAreaElement; + ta.value = DEFAULT_EXAMPLE.twiml; + renderChips(DEFAULT_EXAMPLE.id); + render(DEFAULT_EXAMPLE.twiml); + + // Re-analyze as the SE edits or pastes; clear the active chip. + ta.addEventListener("input", () => { + renderChips(""); + render(ta.value); + }); + + $("pf-print").addEventListener("click", () => window.print()); + + const copyBtn = $("pf-copy"); + const copyLabel = $("pf-copy-label"); + let resetTimer: ReturnType | undefined; + copyBtn.addEventListener("click", async () => { + const text = current?.ok ? current.reportMarkdown : ""; + try { + await navigator.clipboard.writeText(text); + copyLabel.textContent = "Copied"; + } catch { + copyLabel.textContent = "Press Ctrl/Cmd-C"; + } + clearTimeout(resetTimer); + resetTimer = setTimeout(() => (copyLabel.textContent = "Copy report"), 2000); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/web/examples.ts b/web/examples.ts new file mode 100644 index 0000000..22cfb7c --- /dev/null +++ b/web/examples.ts @@ -0,0 +1,58 @@ +// Curated TwiML examples for the demo. Each is a realistic snippet chosen to +// show a distinct part of the story; the verdict for each is computed live by +// the real engine (buildView), not hardcoded here. + +export interface Example { + id: string; + label: string; + twiml: string; +} + +export const EXAMPLES: Example[] = [ + { + id: "ivr", + label: "IVR Menu", + twiml: ` + + Thanks for calling Northwind Support. + + For billing, press 1. To reach an agent, press 2. + + https://cdn.northwind.example/hold-music.mp3 + /main-menu +`, + }, + { + id: "recording", + label: "Call Recording", + twiml: ` + + Please leave a message after the tone. + +`, + }, + { + id: "conference", + label: "Dial to Conference", + twiml: ` + + Connecting you to the conference now. + + support-room + +`, + }, + { + id: "queue", + label: "Unsupported (Queue)", + twiml: ` + + All agents are busy. Please hold. + support-agents +`, + }, +]; + +export const DEFAULT_EXAMPLE = EXAMPLES[0]; diff --git a/web/fonts/dm-sans-latin-400-italic.woff2 b/web/fonts/dm-sans-latin-400-italic.woff2 new file mode 100644 index 0000000..d7898c9 Binary files /dev/null and b/web/fonts/dm-sans-latin-400-italic.woff2 differ diff --git a/web/fonts/dm-sans-latin-400-normal.woff2 b/web/fonts/dm-sans-latin-400-normal.woff2 new file mode 100644 index 0000000..0b8bc55 Binary files /dev/null and b/web/fonts/dm-sans-latin-400-normal.woff2 differ diff --git a/web/fonts/dm-sans-latin-700-normal.woff2 b/web/fonts/dm-sans-latin-700-normal.woff2 new file mode 100644 index 0000000..26edc56 Binary files /dev/null and b/web/fonts/dm-sans-latin-700-normal.woff2 differ diff --git a/web/fonts/dm-sans-latin-800-normal.woff2 b/web/fonts/dm-sans-latin-800-normal.woff2 new file mode 100644 index 0000000..6cbd815 Binary files /dev/null and b/web/fonts/dm-sans-latin-800-normal.woff2 differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5749bf3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,107 @@ + + + + + + Twilio → Bandwidth Migration Preflight + + + +
+
+ +
+
+
+ + + + Migration Preflight +
+

Twilio → Bandwidth Migration Preflight

+

+ Paste the TwiML that drives your call flow and see, instantly, how much of it runs on + Bandwidth's network unchanged — and exactly what needs attention. +

+

+ + Analyzes emitted TwiML — exactly what the adapter sees at runtime. +

+
+
+
Report generated
+
+
+
+ + +
+
+ + Pick an example, or paste your own +
+ +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ +

What this tells you — and what it doesn't

+
+

+ We'd rather be straight with you up front than surprise you mid-migration. +

+
+
+ +

+ This reads the TwiML your app emits. For SDK-based apps, paste what your code + actually generates at runtime. +

+
+
+ +

Speech recognition on Gather requires that feature to be enabled on your Bandwidth account.

+
+
+ +

Automated number ordering is experimental — expect a human in the loop for now.

+
+
+ +

Call queues have no Bandwidth equivalent; queueing and agent routing move into your application layer.

+
+
+
+ + +
+ + + Forward this to your team — it prints as a clean report. +
+
+
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..10827c6 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,651 @@ +/* Migration Preflight — styling. + * Brand tokens live in :root; swap them in one place to re-theme. + * Design system: Bandwidth (primary #076ea8, DM Sans). @font-face rules for + * DM Sans are injected by the build (base64) so the artifact stays self-contained. */ + +:root { + /* --- Brand tokens: swap these to re-theme in one place --- */ + --color-primary: #076ea8; + --color-primary-hover: #075e91; + --color-primary-soft: #e6f5fd; + --color-focus: #079cee; + + --color-bg: #f5f6f7; + --color-surface: #ffffff; + --color-surface-alt: #f9f9f9; + --color-border: #e2e1e2; + --color-border-strong: #cdcccd; + + --color-heading: #090306; + --color-text: #2d282b; + --color-muted: #5e5a5c; + --color-help: #777476; + + --color-clean: #019971; + --color-clean-text: #037356; + --color-clean-bg: #e5f8f3; + --color-clean-border: #99e5d1; + + --color-warn: #c9a416; + --color-warn-text: #816810; + --color-warn-bg: #fefae8; + --color-warn-border: #fbdc5e; + + --color-block: #c43837; + --color-block-text: #9a2c2c; + --color-block-bg: #fbebeb; + --color-block-border: #f3c5c4; + + --radius-data: 4px; + --radius-pill: 100px; + --shadow-card: 0 1px 3px rgba(9, 3, 6, 0.1), 0 1px 2px rgba(9, 3, 6, 0.06); + --shadow-lift: 0 10px 24px rgba(9, 3, 6, 0.1), 0 2px 6px rgba(9, 3, 6, 0.06); + + --font-sans: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; + --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + padding: 0; +} +body { + font-family: var(--font-sans); + color: var(--color-text); + background: var(--color-bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + line-height: 1.5; +} +a { + color: var(--color-primary); + text-decoration: none; +} +a:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +.page { + min-height: 100%; + padding: 40px 24px 72px; + display: flex; + justify-content: center; +} +.sheet { + width: 100%; + max-width: 920px; +} + +/* ---------- Header ---------- */ +.pf-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--color-border); +} +.pf-brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.pf-brand-mark { + width: 28px; + height: 28px; + border-radius: 6px; + background: var(--color-primary); + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; +} +.pf-kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-primary); +} +.pf-title { + margin: 0 0 8px; + font-size: 30px; + line-height: 1.15; + font-weight: 700; + color: var(--color-heading); + letter-spacing: -0.01em; +} +.pf-lede { + margin: 0 0 10px; + font-size: 16px; + color: var(--color-muted); + max-width: 560px; +} +.pf-caveat-line { + margin: 0; + font-size: 12.5px; + color: var(--color-help); + display: flex; + align-items: center; + gap: 7px; +} +.pf-meta { + text-align: right; + flex-shrink: 0; + padding-top: 4px; +} +.pf-meta-label { + font-size: 12px; + color: var(--color-help); +} +.pf-meta-value { + font-size: 13px; + font-weight: 600; + color: var(--color-text); +} + +/* ---------- Input ---------- */ +.pf-section { + margin-top: 28px; +} +.pf-input-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 8px; +} +.pf-label { + font-size: 13px; + font-weight: 700; + color: var(--color-text); +} +.pf-hint { + font-size: 12px; + color: var(--color-help); +} +.pf-textarea { + width: 100%; + height: 190px; + resize: vertical; + padding: 16px; + border: 2px solid var(--color-border-strong); + border-radius: var(--radius-data); + background: var(--color-surface-alt); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + color: var(--color-text); + outline: none; +} +.pf-textarea:focus { + border-color: var(--color-focus); +} +.pf-chips { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} +.pf-chip { + border: 1.5px solid var(--color-border-strong); + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + padding: 8px 18px; + border-radius: var(--radius-pill); + cursor: pointer; +} +.pf-chip:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} +.pf-chip.is-active { + border-color: var(--color-primary); + background: var(--color-primary); + color: #fff; +} + +/* ---------- Verdict ---------- */ +.verdict-panel { + margin-top: 28px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 10px; + box-shadow: var(--shadow-lift); + padding: 30px 32px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 36px; +} +.pf-gauge-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + flex-shrink: 0; +} +.pf-gauge { + position: relative; + width: 168px; + height: 168px; +} +.pf-gauge svg { + transform: rotate(-90deg); +} +.pf-gauge-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.pf-gauge-score { + display: flex; + align-items: baseline; + color: var(--color-heading); +} +.pf-gauge-score .n { + font-size: 56px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.02em; +} +.pf-gauge-score .d { + font-size: 22px; + font-weight: 600; + color: var(--color-muted); +} +.pf-gauge-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-top: 4px; + /* Keep multi-word tiers ("Moderate effort") inside the ring's inner diameter. */ + max-width: 104px; + text-align: center; + line-height: 1.15; +} +.pf-gauge-caption { + font-size: 12.5px; + color: var(--color-help); + text-align: center; + max-width: 190px; +} +.pf-verdict-body { + flex: 1; + min-width: 300px; +} +.pf-verdict-headline { + margin: 0 0 6px; + font-size: 22px; + font-weight: 700; + color: var(--color-heading); + letter-spacing: -0.01em; +} +.pf-verdict-sub { + margin: 0 0 20px; + font-size: 15px; + color: var(--color-muted); +} +.pf-tiles { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.pf-tile { + border-radius: var(--radius-data); + padding: 14px 16px; + border: 1px solid; +} +.pf-tile-top { + display: flex; + align-items: center; + gap: 7px; +} +.pf-tile-n { + font-size: 26px; + font-weight: 800; + line-height: 1; +} +.pf-tile-label { + margin-top: 6px; + font-size: 12.5px; + font-weight: 600; +} +.pf-tile--clean { + border-color: var(--color-clean-border); + background: var(--color-clean-bg); +} +.pf-tile--clean .pf-tile-n, +.pf-tile--clean .pf-tile-label { + color: var(--color-clean-text); +} +.pf-tile--warn { + border-color: var(--color-warn-border); + background: var(--color-warn-bg); +} +.pf-tile--warn .pf-tile-n, +.pf-tile--warn .pf-tile-label { + color: var(--color-warn-text); +} +.pf-tile--block { + border-color: var(--color-block-border); + background: var(--color-block-bg); +} +.pf-tile--block .pf-tile-n, +.pf-tile--block .pf-tile-label { + color: var(--color-block-text); +} + +/* ---------- Findings ---------- */ +.pf-findings { + margin-top: 36px; +} +.pf-findings-title { + margin: 0 0 4px; + font-size: 18px; + font-weight: 700; + color: var(--color-heading); +} +.pf-findings-sub { + margin: 0 0 20px; + font-size: 13.5px; + color: var(--color-help); +} +.pf-group-head { + display: flex; + align-items: center; + gap: 8px; + margin: 24px 0 12px; +} +.pf-group-head:first-child { + margin-top: 0; +} +.pf-group-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.pf-group-rule { + flex: 1; + height: 1px; + background: var(--color-border); +} +.pf-stack { + display: flex; + flex-direction: column; + gap: 12px; +} +.pf-card { + display: flex; + gap: 14px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-left: 4px solid var(--color-border-strong); + border-radius: var(--radius-data); + padding: 16px 20px; + box-shadow: var(--shadow-card); +} +.pf-card--block { + border-color: var(--color-block-border); + border-left-color: var(--color-block); +} +.pf-card--warn { + border-color: var(--color-warn-border); + border-left-color: var(--color-warn); +} +.pf-card--clean { + border-color: var(--color-clean-border); + border-left-color: var(--color-clean); +} +.pf-card-main { + flex: 1; +} +.pf-card-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.pf-verb { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 700; + color: var(--color-heading); + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + padding: 2px 8px; + border-radius: var(--radius-data); +} +.pf-pill { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 3px 10px; + border-radius: var(--radius-pill); +} +.pf-pill--clean { + color: var(--color-clean-text); + background: var(--color-clean-bg); +} +.pf-pill--warn { + color: var(--color-warn-text); + background: var(--color-warn-bg); +} +.pf-pill--block { + color: var(--color-block-text); + background: var(--color-block-bg); +} +.pf-card-msg { + margin: 9px 0 0; + font-size: 14.5px; + color: var(--color-text); +} +.pf-card-msg + .pf-card-msg { + margin-top: 6px; +} +.pf-docs { + align-self: flex-start; + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* ---------- BXML disclosure ---------- */ +.pf-disclosure { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: var(--radius-data); + padding: 12px 16px; + cursor: pointer; + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 700; + color: var(--color-text); +} +.pf-caret { + display: inline-block; + transition: transform 0.15s; + color: var(--color-primary); +} +.pf-disclosure[aria-expanded="true"] .pf-caret { + transform: rotate(90deg); +} +.pf-disclosure-note { + margin-left: auto; + font-size: 12px; + font-weight: 500; + color: var(--color-help); +} +.pf-bxml-body { + margin-top: 10px; +} +.pf-bxml { + margin: 0; + overflow-x: auto; + background: #0f1a20; + color: #d7e3ea; + border-radius: var(--radius-data); + padding: 20px; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.65; + white-space: pre; +} + +/* ---------- Caveats ---------- */ +.pf-caveats { + margin-top: 28px; + background: var(--color-primary-soft); + border: 1px solid #b9def2; + border-radius: 10px; + padding: 24px 28px; +} +.pf-caveats-head { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 6px; +} +.pf-caveats-title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--color-primary-hover); +} +.pf-caveats-lede { + margin: 0 0 16px; + font-size: 13.5px; + color: #0a5679; +} +.pf-caveat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 28px; +} +.pf-caveat { + display: flex; + gap: 10px; +} +.pf-caveat-dot { + color: var(--color-primary); + flex-shrink: 0; + margin-top: 2px; +} +.pf-caveat p { + margin: 0; + font-size: 13.5px; + color: var(--color-text); +} + +/* ---------- Actions ---------- */ +.pf-actions { + margin-top: 28px; + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} +.pf-btn { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + padding: 11px 22px; + border-radius: var(--radius-pill); + cursor: pointer; + border: 1.5px solid transparent; +} +.pf-btn--primary { + background: var(--color-primary); + color: #fff; +} +.pf-btn--primary:hover { + background: var(--color-primary-hover); +} +.pf-btn--ghost { + border-color: var(--color-border-strong); + background: var(--color-surface); + color: var(--color-text); +} +.pf-btn--ghost:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} +.pf-actions-note { + font-size: 12.5px; + color: var(--color-help); + margin-left: 4px; +} + +/* ---------- Error state ---------- */ +.pf-error { + margin-top: 28px; + background: var(--color-warn-bg); + border: 1px solid var(--color-warn-border); + border-left: 4px solid var(--color-warn); + border-radius: var(--radius-data); + padding: 16px 20px; + font-size: 14px; + color: var(--color-warn-text); +} + +/* ---------- Print ---------- */ +@media print { + body { + background: #fff; + } + .np { + display: none !important; + } + .page { + padding: 0 !important; + } + .sheet { + box-shadow: none !important; + border: none !important; + padding: 0 !important; + max-width: 100% !important; + } + .card-shadow, + .pf-card { + box-shadow: none !important; + } + .verdict-panel { + box-shadow: none !important; + border: 1px solid var(--color-border-strong) !important; + } + .pf-bxml-body { + display: block !important; + } + .avoid-break { + break-inside: avoid; + } + @page { + margin: 14mm; + } +} diff --git a/web/view-model.ts b/web/view-model.ts new file mode 100644 index 0000000..b7bb49c --- /dev/null +++ b/web/view-model.ts @@ -0,0 +1,173 @@ +// Pure view model for the Migration Preflight page. +// +// This is the ONLY new logic in the web tool, and it is deliberately thin: it +// calls the real engine (analyzeSource / complexityScore / translateTwiml) and +// the real compatibility matrix, then shapes the result for rendering. It does +// NOT reimplement any translation, scoring, or compatibility rules — that is the +// honesty invariant: because the same matrix drives the live adapter and this +// page, the verdict shown here provably matches what the adapter does in +// production. Keep this file DOM-free so it stays unit-testable. + +import { analyzeSource } from "../src/preflight/analyze.js"; +import { complexityScore, renderReport } from "../src/preflight/report.js"; +import { translateTwiml } from "../src/translator/translate.js"; +import { loadMatrix } from "../src/matrix/load.js"; + +const matrix = loadMatrix(); + +export type Bucket = "clean" | "headsUp" | "blocker"; + +export interface VerbCard { + /** Twilio verb name, e.g. "Say" (rendered as ). */ + verb: string; + /** Bandwidth BXML target from the matrix, e.g. "SpeakSentence"; null when unsupported. */ + target: string | null; + bucket: Bucket; + /** Short status label: "Maps 1:1" | "Review needed" | "No equivalent". */ + pill: string; + /** For heads-up/blocker: the engine's finding messages. For clean: one matrix-sourced note. */ + messages: string[]; + docsUrl?: string; +} + +export interface PreflightView { + ok: boolean; + /** Present when the input could not be analyzed. */ + error?: string; + score: number; + scoreLabel: string; + headline: string; + counts: { clean: number; headsUp: number; blocker: number }; + blockers: VerbCard[]; + headsUp: VerbCard[]; + clean: VerbCard[]; + bxml: string; + /** The same markdown migration report the CLI preflight produces (for "Copy report"). */ + reportMarkdown: string; +} + +// A verb is a confident 1:1 mapping only when the matrix marks it "supported" +// and it has a single BXML target. Context-dependent verbs (Dial, Connect, +// Conference, …) are "partial": they translate, but the exact target depends on +// their children/attributes, so we don't claim "Maps 1:1" or show a → arrow. +function isOneToOne(verb: string): boolean { + const e = matrix.verbs[verb]; + return !!e && e.status === "supported" && !!e.bxml && !e.bxml.includes("/"); +} + +function scoreLabel(score: number): string { + if (score <= 3) return "Low effort"; + if (score <= 6) return "Moderate effort"; + if (score <= 8) return "Significant effort"; + return "Heavy lift"; +} + +function headlineFor(counts: { clean: number; headsUp: number; blocker: number }): string { + const total = counts.clean + counts.headsUp + counts.blocker; + if (total === 0) return "No Twilio voice verbs detected."; + if (counts.blocker === 0 && counts.headsUp === 0) return "Everything in this flow moves cleanly."; + if (counts.blocker === 0) return "Most of this flow moves cleanly."; + if (counts.clean >= counts.blocker) return "Most of this flow moves — a few parts need a plan."; + return "Several parts of this flow need a migration plan."; +} + +function uniq(xs: string[]): string[] { + return [...new Set(xs)]; +} + +/** Analyze one TwiML document and shape it for the page. Never throws. */ +export function buildView(twiml: string): PreflightView { + const empty: PreflightView = { + ok: false, + score: 1, + scoreLabel: scoreLabel(1), + headline: "", + counts: { clean: 0, headsUp: 0, blocker: 0 }, + blockers: [], + headsUp: [], + clean: [], + bxml: "", + reportMarkdown: "", + }; + + if (!twiml.trim()) { + return { ...empty, error: "Paste some TwiML, or pick an example above." }; + } + if (!/]/.test(twiml)) { + return { + ...empty, + error: "That doesn't look like TwiML — it should be wrapped in .", + }; + } + + let analysis; + let bxml = ""; + try { + analysis = analyzeSource("pasted.twiml", twiml); + bxml = translateTwiml(twiml).bxml; + } catch { + return { ...empty, error: "Couldn't parse that TwiML. Check for malformed XML and try again." }; + } + + const { verbs, findings } = analysis; + const score = complexityScore([analysis]); + + // One card per verb, driven by the engine's findings. A verb with any error is + // a blocker; any warning (and no error) is a heads-up; otherwise it's clean. + // Union of detected verbs and finding-verbs so nothing the engine flags is dropped. + const verbOrder = uniq([...verbs, ...findings.map((f) => f.verb)]); + const blockers: VerbCard[] = []; + const headsUp: VerbCard[] = []; + const clean: VerbCard[] = []; + + for (const verb of verbOrder) { + const own = findings.filter((f) => f.verb === verb); + const entry = matrix.verbs[verb]; + const bucket: Bucket = own.some((f) => f.severity === "error") + ? "blocker" + : own.some((f) => f.severity === "warning" || f.severity === "info") + ? "headsUp" + : "clean"; + + const oneToOne = isOneToOne(verb); + const messages = + bucket === "clean" + ? [entry?.notes?.trim() || `Translates to <${entry?.bxml}> on Bandwidth.`] + : uniq(own.map((f) => f.message)); + + const pill = + bucket === "blocker" + ? "No equivalent" + : bucket === "headsUp" + ? "Review needed" + : oneToOne + ? "Maps 1:1" + : "Translates"; + + const card: VerbCard = { + verb, + target: oneToOne ? entry!.bxml : null, + bucket, + pill, + messages, + docsUrl: own.find((f) => f.docsUrl)?.docsUrl ?? entry?.docsUrl, + }; + + (bucket === "blocker" ? blockers : bucket === "headsUp" ? headsUp : clean).push(card); + } + + const counts = { clean: clean.length, headsUp: headsUp.length, blocker: blockers.length }; + + return { + ok: true, + score, + scoreLabel: scoreLabel(score), + headline: headlineFor(counts), + counts, + blockers, + headsUp, + clean, + bxml, + reportMarkdown: renderReport([analysis]), + }; +}