diff --git a/TRACKER.md b/TRACKER.md
index 8f5ed38..187490f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -4,10 +4,10 @@
## Status
-- **Current phase:** 2 — Instance graph & cross-file data flow
-- **Next step:** 2.5 — Portals, modals, toasts
-- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.4
-- **Gates passed:** Gate 0 (CI + red-path, PRs #5/#6) · Gate 1 (precision 1.000 ≥ 0.90, recall 0.895 ≥ 0.80 across C2/C3/C5/A2/A7/A8/D4, zero forbidden hits)
+- **Current phase:** 3 — Journey graph
+- **Next step:** 3.1 — Router adapters
+- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5
+- **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000)
## What CodeRadar is
@@ -173,7 +173,7 @@ The heart of the project. C1 and B1 live here.
- `traceLineage` through a StateNode now continues to its writers: reader component → slice → populating API.
**Accept:** fixture `c6-store-decoupled` green — component with **no fetch of its own** correctly attributed to the API called on a different page at login.
-### [ ] 2.5 Portals, modals, toasts
+### [x] 2.5 Portals, modals, toasts
**Failure modes:** A9
**Build:** `createPortal` detection + adapter list for common modal/toast libs (`react-modal`, radix `Dialog`, `react-hot-toast`): the triggering instance gets a `triggers-render` edge to the portal-content instance.
**Accept:** fixture `a9-modal-portal` green: matching the modal's text surfaces both the modal component and its trigger site. **Gate 2 passes.**
diff --git a/eval/fixtures/a9-modal-portal/app/ConfirmDialog.tsx b/eval/fixtures/a9-modal-portal/app/ConfirmDialog.tsx
new file mode 100644
index 0000000..01a7dcc
--- /dev/null
+++ b/eval/fixtures/a9-modal-portal/app/ConfirmDialog.tsx
@@ -0,0 +1,19 @@
+import { createPortal } from "react-dom";
+
+export function ConfirmDialog({
+ open,
+ onConfirm,
+}: {
+ open: boolean;
+ onConfirm: () => void;
+}) {
+ if (!open) return null;
+ return createPortal(
+
+
Delete this order?
+
This cannot be undone.
+
+
,
+ document.body,
+ );
+}
diff --git a/eval/fixtures/a9-modal-portal/app/OrdersToolbar.tsx b/eval/fixtures/a9-modal-portal/app/OrdersToolbar.tsx
new file mode 100644
index 0000000..fe05e79
--- /dev/null
+++ b/eval/fixtures/a9-modal-portal/app/OrdersToolbar.tsx
@@ -0,0 +1,21 @@
+import { useState } from "react";
+import { toast } from "react-hot-toast";
+
+import { ConfirmDialog } from "./ConfirmDialog";
+
+export function OrdersToolbar() {
+ const [confirming, setConfirming] = useState(false);
+
+ const handleConfirm = () => {
+ fetch("/api/orders/selected", { method: "DELETE" });
+ setConfirming(false);
+ toast("Order deleted");
+ };
+
+ return (
+
+
+
+
+ );
+}
diff --git a/eval/fixtures/a9-modal-portal/golden.json b/eval/fixtures/a9-modal-portal/golden.json
new file mode 100644
index 0000000..fa313c9
--- /dev/null
+++ b/eval/fixtures/a9-modal-portal/golden.json
@@ -0,0 +1,25 @@
+{
+ "failureMode": "A9",
+ "note": "Portals & toasts: the dialog renders into document.body via createPortal — a screenshot of it shows nothing from its trigger's DOM subtree. Matching the modal text must surface ConfirmDialog (whose instance list points at the OrdersToolbar trigger site). Toast text ('Order deleted') exists only as a call argument; it must match the CALLING component.",
+ "expect": {
+ "components": [
+ { "name": "ConfirmDialog", "instances": 1 },
+ { "name": "OrdersToolbar", "instances": 0 }
+ ],
+ "attributions": [
+ { "component": "OrdersToolbar", "endpoints": ["/api/orders/selected"] },
+ {
+ "component": "ConfirmDialog",
+ "instanceAt": "OrdersToolbar.tsx",
+ "endpoints": ["/api/orders/selected"],
+ "_note": "not data-in: the dialog's confirm button TRIGGERS the DELETE via the resolved handler chain (2.3) — correct effect lineage"
+ }
+ ],
+ "queries": [
+ { "terms": ["Delete this order?"], "status": "ok", "top": "ConfirmDialog" },
+ { "terms": ["Confirm delete"], "status": "ok", "top": "ConfirmDialog" },
+ { "terms": ["Order deleted"], "status": "ok", "top": "OrdersToolbar" },
+ { "terms": ["Delete order"], "status": "ok", "top": "OrdersToolbar" }
+ ]
+ }
+}
diff --git a/eval/history.jsonl b/eval/history.jsonl
index 0f541a0..b4106a2 100644
--- a/eval/history.jsonl
+++ b/eval/history.jsonl
@@ -9,3 +9,4 @@
{"generatedAt":"2026-07-13T11:16:56.457Z","commitSha":"ce7a3bcbf95481114cd60ef62f8e840874ef7453","pass":108,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
{"generatedAt":"2026-07-13T11:25:04.854Z","commitSha":"cec11f7aab21ef6ec2d6dd0bb247e2cd29981ed9","pass":119,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
{"generatedAt":"2026-07-14T16:11:46.552Z","commitSha":"4c01687263ff4ed2d656970a2532754636714604","pass":129,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
+{"generatedAt":"2026-07-14T16:19:16.873Z","commitSha":"acbf574f777e001b553ece6ea206ddbe1f1a3892","pass":137,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 826f348..d5ac678 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -47,10 +47,12 @@ export interface RenderedText {
text: string;
/**
* Where the text comes from: JSX children, a string attribute
- * (placeholder/label/title/alt/aria-label), or an i18n key resolved
- * against locale files.
+ * (placeholder/label/title/alt/aria-label), an i18n key resolved against
+ * locale files, or portal-rendered content (toast("Order deleted") — the
+ * text appears far from the caller in the DOM, but the CALLER is the
+ * component a screenshot of it should match).
*/
- source: "jsx" | "attribute" | "i18n";
+ source: "jsx" | "attribute" | "i18n" | "portal";
/** i18n entries only: the translation key, e.g. "team.title". */
key?: string;
/** i18n entries only: which locale this text belongs to. */
diff --git a/packages/parser-react/src/portals.test.ts b/packages/parser-react/src/portals.test.ts
new file mode 100644
index 0000000..b622fc6
--- /dev/null
+++ b/packages/parser-react/src/portals.test.ts
@@ -0,0 +1,40 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { matchComponentsByText } from "@coderadar/core";
+import { describe, expect, it } from "vitest";
+
+import { scanReact } from "./scan.js";
+
+const fixture = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures/a9-modal-portal/app",
+);
+
+const graph = scanReact({ root: fixture });
+
+describe("portals & toasts (a9 fixture)", () => {
+ it("flags createPortal components", () => {
+ const dialog = graph.nodes.find((n) => n.kind === "component" && n.name === "ConfirmDialog");
+ expect(dialog?.flags).toContain("portal");
+ });
+
+ it("matching modal text surfaces the component AND its trigger site", () => {
+ const result = matchComponentsByText(graph, ["Delete this order?"]);
+ expect(result.status).toBe("ok");
+ const match = result.candidates[0]?.value;
+ expect(match?.component.name).toBe("ConfirmDialog");
+ expect(match?.instances.map((i) => i.loc.file)).toEqual(["OrdersToolbar.tsx"]);
+ });
+
+ it("attributes toast text to the calling component with portal provenance", () => {
+ const toolbar = graph.nodes.find(
+ (n) => n.kind === "component" && n.name === "OrdersToolbar",
+ );
+ if (toolbar?.kind !== "component") throw new Error("OrdersToolbar not found");
+ const entry = toolbar.renderedText.find((e) => e.text === "Order deleted");
+ expect(entry?.source).toBe("portal");
+ const result = matchComponentsByText(graph, ["Order deleted"]);
+ expect(result.candidates[0]?.value.component.name).toBe("OrdersToolbar");
+ });
+});
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index 03d90a9..0488a38 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -100,6 +100,7 @@ const TEXT_ATTRIBUTES = new Set([
"value",
]);
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
+const TOAST_CALLEES = /^(toast(\.\w+)?|enqueueSnackbar|message\.(success|error|info|warning))$/;
/** Scan a directory of React source and produce a lineage graph. */
export function scanReact(options: ScanOptions): LineageGraph {
@@ -144,6 +145,12 @@ export function scanReact(options: ScanOptions): LineageGraph {
const id = nodeId(kind, file, decl.name);
if (isComponent) {
+ // Portal components (A9): rendered into document.body etc., far from
+ // where they're triggered — flagged so agents know the screenshot's
+ // DOM position won't match the render-tree position.
+ const usesPortal = decl.fn
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
+ .some((c) => /^(ReactDOM\.)?createPortal$/.test(c.getExpression().getText()));
nodes.set(id, {
id,
kind: "component",
@@ -156,6 +163,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
...(localeTable !== null ? i18nRenderedText(decl.fn, localeTable) : []),
],
rendersComponents: extractRenderedComponents(decl.fn),
+ ...(usesPortal ? { flags: ["portal"] } : {}),
});
collectInstanceSites(decl.fn, id, file, pendingInstances);
} else {
@@ -292,6 +300,17 @@ function extractRenderedText(fn: Node): RenderedText[] {
}
}
+ // Toast/notification calls (A9): toast("Order deleted") renders via a portal
+ // mounted elsewhere — the text belongs to the CALLING component for matching.
+ for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
+ if (!TOAST_CALLEES.test(call.getExpression().getText())) continue;
+ const arg = call.getArguments()[0];
+ if (arg !== undefined && Node.isStringLiteral(arg)) {
+ const text = arg.getLiteralValue().trim();
+ if (text.length > 0) add({ text, source: "portal", ...branchTag(call, fn) });
+ }
+ }
+
// Template literals rendered as JSX children: {`${count} items in cart`} →
// "* items in cart" (unknown segments become * wildcards).
for (const expr of fn.getDescendantsOfKind(SyntaxKind.JsxExpression)) {
@@ -1213,6 +1232,10 @@ function resolvePropFlow(
const init = varDecl.getInitializer();
if (init !== undefined) {
+ // Function values are callbacks, not data: the fetch inside
+ // onConfirm={handleConfirm} is an EFFECT the child can trigger
+ // (handler chains, step 2.3), never data flowing into it.
+ if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) continue;
// Direct result: const { data } = useQuery(...) / const r = useApi("/x")
// — including calls wrapped in await / as-casts.
fromCall(init);
@@ -1264,6 +1287,8 @@ function resolvePropFlow(
if (instanceId === null || instanceId === undefined) continue;
for (const attr of pending.element.getAttributes()) {
if (!Node.isJsxAttribute(attr)) continue;
+ // Event-handler props (onConfirm, onClick…) carry effects, not data.
+ if (/^on[A-Z]/.test(attr.getNameNode().getText())) continue;
const init = attr.getInitializer();
if (init === undefined || !Node.isJsxExpression(init)) continue;
const expr = init.getExpression();
diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json
index 15cca29..23dc702 100644
--- a/schemas/lineage-graph.schema.json
+++ b/schemas/lineage-graph.schema.json
@@ -191,9 +191,10 @@
"enum": [
"jsx",
"attribute",
- "i18n"
+ "i18n",
+ "portal"
],
- "description": "Where the text comes from: JSX children, a string attribute (placeholder/label/title/alt/aria-label), or an i18n key resolved against locale files."
+ "description": "Where the text comes from: JSX children, a string attribute (placeholder/label/title/alt/aria-label), an i18n key resolved against locale files, or portal-rendered content (toast(\"Order deleted\") — the text appears far from the caller in the DOM, but the CALLER is the component a screenshot of it should match)."
},
"key": {
"type": "string",