DEV Community

Florian Demartini
Florian Demartini

Posted on

iOS Safari ate my SVG share card. My agent caught it 3 weeks later. Here's the bug and the fix.

My SaaS for French rental compliance — BailleurVérif — has a share card feature. After a tenant runs our compliance quiz, they get a verdict: does their rent exceed the legal ceiling? If yes, by how much per year, and how much is recoverable under French law?

The share card turns that verdict into a downloadable PNG — headline + subline — designed to be sent to a landlord or posted on a tenant forum.

The subline carries the real hook: something like "+6 000 €/an récupérables (rétroactif 3 ans max)." Without it, the card is just a declaration. With it, it's a call to action.

On desktop Chrome? Renders perfectly. On Android Chrome? Perfect. On iOS Safari — the browser of the target persona, a French renter with an iPhone — the subline was silently absent. For 3 weeks.

My autonomous agent caught this at run-588, 05:44Z on June 17. Here's what happened.


The Root Cause: foreignObject and Safari's Dirty Secret

The share card is 118 lines of vanilla JS. It builds an SVG string, renders it to a canvas via canvas.drawImage(), then exports as a PNG data URL.

The original implementation used foreignObject to embed the subline as styled HTML inside the SVG:

<!-- Before — silently broken on iOS Safari -->
<foreignObject x="60" y="280" width="1080" height="200">
  <div xmlns="http://www.w3.org/1999/xhtml"
       style="font: 400 32px/1.4 'Helvetica Neue', sans-serif;
              color: rgba(255,255,255,0.88)">
    ${esc(subline)}
  </div>
</foreignObject>
Enter fullscreen mode Exit fullscreen mode

Chrome and Firefox serialize foreignObject content when drawing SVG to canvas. iOS Safari doesn't. It renders the SVG with the foreignObject block silently removed — no error, no warning, no visual indicator. Just a blank area where the subline should be.

This is documented in WebKit's bug tracker and well-discussed on Stack Overflow. But it's easy to miss when you test on a desktop or an Android device.


The Fix: Native text and tspan

The fix: remove foreignObject entirely. Replace it with SVG's native <text> and <tspan> elements. The only complication is word wrapping — SVG text doesn't break lines automatically.

// +8L: word-boundary wrap helper
function wrapAt(s, maxChars = 46) {
  const words = s.split(" ");
  const lines = [];
  let cur = "";
  for (const w of words) {
    if ((cur + w).length > maxChars) {
      lines.push(cur.trim());
      cur = w + " ";
    } else {
      cur += w + " ";
    }
  }
  lines.push(cur.trim());
  return lines;
}

// +4L: replaces the entire foreignObject block
const lines = wrapAt(subline, 46);
const sublineBlock = `
<text x="60" y="340" font-size="32" font-family="Helvetica Neue, sans-serif" opacity="0.92">
  ${lines.map((l, i) =>
    `<tspan x="60" dy="${i === 0 ? 0 : 42}">${esc(l)}</tspan>`
  ).join("")}
</text>`;
Enter fullscreen mode Exit fullscreen mode

12 lines net, no dependencies, no foreignObject in prod. Smoke-tested on three verdict types:

severity=danger (Paris, 28.5m²) → "Encadrement Paris • Soit 6 000 €/an" / "récupérables (rétroactif 3 ans max)" ✓
severity=warn  (Saint-Denis)    → single line, no wrap needed ✓
severity=ok    (Lille, 14.2m²)  → no recoverable amount subline ✓
Enter fullscreen mode Exit fullscreen mode

Committed as d066a28. Zero foreignObject in prod confirmed.


How the Agent Found It

The agent wasn't looking for this bug specifically. It was mid-way through a 72h observation cycle — tracking whether a friction fix (helper text on quiz questions 3 and 4) was improving conversion.

The funnel tracks every step from quiz entry to verdict:

home_visit → q1 → q2 → q3 → q4 → q5 → verdict_displayed
→ [verdict_dwell_ms, email_gate_reached, share_card_post_verdict_clicked]
Enter fullscreen mode Exit fullscreen mode

At run-579 (June 16, 10:06Z), candidate #14 arrived via /encadrement-loyer-paris-2026.html on Android Chrome. They spent 2 minutes and 13 seconds on question 2 — deliberate reading — then completed the full funnel. First post-friction-fix data point.

That session prompted a code review of the share card path: cross-reference share_card_post_verdict_clicked events by user-agent class. The agent noticed zero mobile Safari sessions had triggered the download. A suspiciously clean zero given ~40% of qualifying visits are mobile.

Line 52 of share-card.js: foreignObject. Immediately recognizable.

This is run 588 out of 591 total. The agent has been running every 2 hours since run 1, 105 days ago. It surfaces silent bugs not by being smarter than a developer, but by checking patterns across time that no developer would manually inspect.


Bonus Finding: 35 Days of LLM Traffic Data

Run-591 (today, 11:46Z) honored a request from the strategic critic sub-agent: audit actual LLM crawler traffic against the live llms.txt file.

The agent grepped visits.jsonl (484 lifetime visits) for 10 explicit LLM user-agent strings: GPTBot, Claude-Web, PerplexityBot, YouBot, Diffbot, Bytespider, and others.

Result: 0 hits. In 35 days of live traffic.

The llms.txt file exists, serves HTTP 200, returns 9,712 bytes. No LLM crawler has touched it.

The only LLM-sourced signal in the logs: utm_source=chatgpt.com referrals — 5 events across 3 distinct IP hashes over 35 days. Cadence ~1 visit per 10 days. That's not crawling; that's ChatGPT's web browsing feature when a user explicitly searches and clicks through.

One corrective action emerged: llms.txt wasn't referenced in robots.txt. Only sitemap.xml was. Fix — add a comment block:

# LLM discovery
# llms.txt: https://bailleurverif.fr/llms.txt
# llms-full.txt: https://bailleurverif.fr/llms-full.txt

Sitemap: https://bailleurverif.fr/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

Whether LLM crawlers actually read robots.txt comments for discovery is an open empirical question. Re-grep scheduled for 2026-07-17.


Current Project State

Honest numbers:

Metric Value
Total visits (35 days) 484
Bot pre-classification rejections 17
Qualifying human visits (conf-adjusted) 7–9
SEO city page → full quiz → verdict 3 confirmed / 5 pattern
Real email captures 0
LLM crawler hits against llms.txt 0
ChatGPT referrals 5 events / 35 days

Lessons Learned

  • foreignObject + canvas.drawImage() = silently broken on iOS Safari. Native <text> + <tspan> with a simple word-wrap helper is the fix. 12 lines, no dependencies.
  • LLM SEO in practice: 0 explicit LLM crawler hits in 35 days on a live, well-formed llms.txt. The only real signal is ChatGPT web browsing at about 3 visits/month. Don't build your acquisition strategy around it yet.
  • Autonomous agents catch silent bugs because they measure across time. The iOS Safari bug was invisible in staging. The agent found it by noticing a zero in a segment breakdown — the kind of check no developer runs manually on a daily cadence.

🔗 Code MIT github.com/Creariax5/bailleurverif · Site bailleurverif.fr · Wikidata Q139857638

Top comments (0)