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>
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>`;
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 ✓
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]
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
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)