All posts
Frontend

Red flags on a page that can change

· 8 min read

How highlights in Munitron's learning flow builder find their element on a phishing email or landing page, whether an admin placed them or a model wrote them, and what happens when that page is edited.

Munitron is a cybersecurity awareness and phishing simulation platform I work on at Akera. When an employee interacts with one of its simulated phishing emails, they get a short lesson in a chat window, where a trainer asks questions and they pick from a few answers. Admins build these lessons in the last step of the scenario wizard, and most of that step is my code, including the AI generation behind it and the preview.

This post is about one kind of message in those lessons: the scenario's real email or landing page, shown in the chat with its red flags outlined. Each highlight has to find its element on a page the admin can still edit, and sometimes the only description of it comes from a model that never saw the page rendered.

What a lesson is made of#

A lesson is a list of turns. Each turn holds messages from the trainer and the answers the employee can pick, marked correct or not. A teammate designed that shape when the wizard was first built, with text and image messages. I added a third type, html, whose content is this object as a JSON string:

export interface HtmlAnnotation {
  id: string;
  selector: string;
  searchText: string;
  tooltip: string;
  color: 'red' | 'amber' | 'blue';
}

export interface HtmlTurnContent {
  source: 'landing_page' | 'phishing_email';
  html?: string;
  annotations: HtmlAnnotation[];
}

source says which of the scenario's two documents to show. While the lesson is being edited, the message carries no HTML. The email and the landing page are built in steps two and three of the same wizard, and the turn editor looks the HTML up from wizard state on every render, so changing the landing page updates every turn that shows it.

Saving is different. When I brought these turns to the employee-facing app, the create and edit pages started copying the resolved HTML into the optional html field, so the employee's lesson page reads the page straight from the turn.

selector and searchText are two ways of finding the same element. Which one gets used depends on who made the annotation.

Asking the model for a lesson#

The step has a Generate with AI button. It sends what the wizard knows about the scenario, from its name and difficulty to the email body and the landing page HTML, to POST /tenants/:id/scenarios/generate-training-turns. The route sits behind the permission check a teammate wrote for creating scenarios.

On the server, formatScenarioContext turns that into labelled lines, and a Mastra agent sends them to Gemini through OpenRouter. The instructions ask for strict JSON: five or six turns, each with exactly one correct answer. A language rule, marked highest priority, has the model write every message and answer in the language of the email, so an Arabic scenario gets an Arabic lesson with no English headings mixed in.

For html turns, the instructions tell the model to leave selector empty and put a short piece of the element's visible text, at most 80 characters, in searchText. The model only sees the HTML as text, so any CSS selector it wrote would be a guess. A phrase copied out of that text is something it can get right.

Each html turn also has its own button that asks a separate agent to annotate just that document. Its results are merged with the existing annotations and deduplicated by searchText, so a highlight placed by hand isn't doubled. Generating a whole lesson, by contrast, replaces every turn in the step.

Checking what comes back#

The service doesn't take the reply on trust:

const cleaned = stripJsonFences(response.text ?? '');
const parsed = trainingTurnsOutputSchema.parse(JSON.parse(cleaned));
if (parsed.turns.length === 0) {
  throw new Error('AI returned empty turns array');
}

parsed.turns.forEach((turn) => {
  const hasFalse = turn.responses.some((r) => r.isCorrect === false);
  const hasTrue = turn.responses.some((r) => r.isCorrect === true);
  if (hasFalse && !hasTrue && turn.responses.length > 0) {
    const intended = turn.responses.find((r) => r.isCorrect === null) ?? turn.responses[0]!;
    intended.isCorrect = true;
  }
});

parsed.turns = shuffleTrainingTurnResponses(parsed.turns);

The prompt asks for no Markdown fences, and stripJsonFences removes them anyway. Zod then checks the shape, and an empty list is an error.

I added the loop so every turn has a correct answer. When a turn comes back without one, the first answer left as null is marked correct, or failing that the first answer. A teammate later narrowed the check to turns with at least one explicit false. They also added the shuffle on the last line, so the correct answer's position changes from turn to turn.

Showing the real page safely#

The editor renders the email or landing page in an iframe through srcDoc, with sandbox="allow-scripts allow-forms". Leaving out allow-same-origin gives the frame a null origin, so nothing inside it can touch the admin app's page or storage.

It also means the admin app can't reach in. Everything goes through a script I inject just before </body> (or append, if there isn't one), and the two sides talk with postMessage. A null origin can't be named as a target, so both sides post to '*'. The editor makes up for that by ignoring any message whose source isn't its own iframe's contentWindow, then passing the data to parseBridgeMessage, which checks every field and returns a typed union or null.

What the bridge does#

The editor sends the bridge the current mode (view or annotate) and the list of annotations. The bridge sends back four kinds of message: that it's ready, a click, its height, and which annotations it managed to place. The height lets the frame grow to fit the page.

Every click and form submit inside the frame is cancelled in the capture phase, so the page can't navigate. In annotate mode, a click, or a text selection of at least two characters, also posts a CSS selector for the element and up to 80 characters of text, either the element's or the selected phrase. The selector is the element's id if it has one, or else a chain of tag names with :nth-of-type wherever siblings share a tag.

Finding the element#

So two kinds of annotation reach the bridge. One made by clicking has a selector and some text, and one from the model has only text. The bridge tries the selector first, then the text:

// Collapse runs of whitespace (\s also matches non-breaking spaces) and
// lowercase, so AI-generated anchors match rendered text despite spacing/casing.
function normalizeText(s) { return (s || '').replace(/\\s+/g, ' ').trim().toLowerCase(); }

function applyAnnotations() {
  // …
  var resolved = [];
  annotations.forEach(function(ann) {
    var el = null;
    if (ann.selector) { try { el = document.querySelector(ann.selector); } catch(e) {} }
    if (!el && ann.searchText) {
      var needle = normalizeText(ann.searchText);
      if (needle) {
        // First, the FIRST text node whose normalized text contains the phrase.
        // If the same text appears multiple times we cannot tell which instance
        // was originally annotated — see file-level caveat in the editor.
        var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
        var node;
        while ((node = walker.nextNode())) {
          if (normalizeText(node.nodeValue).indexOf(needle) !== -1) { el = node.parentElement; break; }
        }
        // Fallback for phrases split across inline tags (e.g. <strong>/<a>):
        // the smallest element whose normalized text contains the phrase.
        if (!el) {
          var els = document.body.getElementsByTagName('*');
          for (var i = 0; i < els.length; i++) {
            if (normalizeText(els[i].textContent).indexOf(needle) !== -1 &&
                (!el || els[i].textContent.length < el.textContent.length)) {
              el = els[i];
            }
          }
        }
      }
    }
    if (el) {
      var color = COLORS[ann.color] || COLORS.blue;
      el.style.outline = '3px solid ' + color;
      el.style.outlineOffset = '2px';
      el.style.boxShadow = '0 0 0 9999px ' + color + '14 inset';
      el.setAttribute('data-ann-id', ann.id);
      el.setAttribute('title', ann.tooltip);
      resolved.push(ann.id);
    }
  });
  window.parent.postMessage({ type: 'ANN_RESOLVED', resolved: resolved }, '*');
}

The script is a template string in a TypeScript file, which is why the backslash in the regular expression is doubled. A found element gets an outline and a faint inset tint in its colour. The tooltip becomes its title, and its id goes into resolved, which is posted back to the editor after every pass.

Matching loosely#

The first version looked for the first text node that contained searchText exactly. In June I made it tolerant, so anchors from the model would match despite differences in spacing and case. A text node keeps the line breaks and indentation of the source, which the browser only collapses on screen, so an anchor written with single spaces, or in a different case, wouldn't match exactly.

A phrase can also run across a <strong> or an <a>, which splits it over several text nodes. normalizeText collapses whitespace, non-breaking spaces included, and lowercases both sides. If no single text node matches, the fallback checks every element and keeps the smallest one whose text contains the phrase, so the outline goes round the tightest element that holds it.

Repeated text#

The comment in the code admits the limit. If a phrase appears twice, the first one wins, and nothing records which the author meant. For clicked annotations the selector is tried first, so this only comes up once it stops matching. For generated ones, the annotation agent's prompt asks for text unique enough to match one element. The lesson prompt doesn't.

When the page changes#

Because a turn points at the landing page through source, an admin can go back two steps, rewrite a paragraph and return to highlights that match nothing. The bridge already reports what it placed, so the editor can work out what it didn't:

useEffect(() => {
  setHasResolved(false);
  setResolvedIds(new Set());
  setReady(false);
  setStaleModalDismissed(false);
}, [html]);

const staleAnnotations = useMemo(
  () => (hasResolved ? annotations.filter((a) => !resolvedIds.has(a.id)) : []),
  [hasResolved, annotations, resolvedIds],
);
const showStaleModal = !readOnly && staleAnnotations.length > 0 && !staleModalDismissed;

const removeStaleAnnotations = useCallback(() => {
  onChange(annotations.filter((a) => resolvedIds.has(a.id)));
  setStaleModalDismissed(true);
}, [annotations, resolvedIds, onChange]);

The effect on html covers the gap between a change and the new frame reporting back. Until hasResolved is true, nothing counts as stale. Without that reset, the chips would briefly show stale flags worked out against the previous HTML.

Stale annotations are struck through in amber, and a dialog titled "Some highlights no longer match" offers to remove them or keep them for now. Removing keeps exactly the ones the bridge placed. Dismissing the dialog stops it asking again until the HTML next changes, which is why the effect resets that flag too.

Playing it back#

The preview page runs a saved lesson the way an employee sees it, with a typing indicator and pauses between messages. It was the first part of this I built: a reducer with six phases, idle, typing, showing_message, awaiting_response, feedback and complete. The only input during a lesson is choosing an answer. Everything else happens on a timer, and one effect moves it along:

// Drive the state machine forward based on phase changes
useEffect(() => {
  clearTimer();
  const turn = turns[state.currentTurnIndex];
  if (!turn) return;

  switch (state.phase) {
    // …
    case 'typing': {
      // Show typing indicator, then reveal message
      timerRef.current = setTimeout(() => {
        const msg = turn.messages[state.currentMessageIndex];
        if (msg) {
          dispatch({
            type: 'SHOW_MESSAGE',
            message: buildVisibleMessage(msg, scenarioAssetsRef.current),
          });
        }
      }, TIMING.TYPING_DURATION);
      break;
    }
    // …
  }

  return clearTimer;
}, [state.phase, state.currentTurnIndex, state.currentMessageIndex, turns, clearTimer]);

Each phase sets at most one timer, and the cleanup clears it whenever the phase or position changes, so an old timeout can't fire into a new state. restart clears it as well before resetting.

scenarioAssetsRef fixes a stale closure. The landing page and email are read inside the timeout but aren't in the dependency list, so the callback could hold the versions from an earlier render. A ref updated after every render always has the current ones.

buildVisibleMessage is where an html message gets its page: the copy saved on the message if there is one, otherwise the scenario's current landing page or email.

The feedback phase came from a bug. The preview used to add the chosen answer and go straight to the next turn, and each generated turn opened by telling the employee they'd got the last one right, so a wrong answer read as a correct one. Now every answer gets its own reply, which either confirms it or names the correct option. The same fix went into the lesson player for training programmes.

What I'd change#

The bridge exists three times. The editor uses html-annotation-bridge.ts. The chat preview and the scenario detail page use HtmlAnnotationViewer, which keeps its own copy in a BRIDGE_SCRIPT constant, and the lesson page in the student app has a third, HTML_BRIDGE_SCRIPT. I wrote all three.

The June change to matching only went into the first. The other two still check searchText against a single text node with includes, case and spacing included, and neither posts ANN_RESOLVED. So a generated highlight can be placed in the editor, with no stale warning, and then not appear in the preview or for the employee, if its case or spacing differs or it runs across a <strong>. Nothing reports the miss.

I'd move the script into a shared package and have every viewer use it through parseBridgeMessage, taking the student copy's ANN_FOCUS handling along (it scrolls to a highlight when its caption is clicked). A highlight the editor can place would then show up everywhere the lesson is played.

NextGiving every word of a story a timestamp