diff --git a/api/routers/seo.py b/api/routers/seo.py
index 375a5526061..a63215024b6 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -114,10 +114,18 @@ async def _refresh_sitemap() -> str:
# HTML template for search/social crawlers. Meta tags drive social previews;
# the {body} slot carries what search engines index (headings, code, links,
# JSON-LD) — an empty body reads as a thin page and wastes the crawl.
+#
+# The icon links are absolute and mirror app/index.html: a crawler never
+# receives index.html, so without them Google's only candidate was the
+# /favicon.ico fallback. The files live in app/public/ and are served by the
+# app's nginx, whichever host (anyplot.ai, python.anyplot.ai) proxied the bot.
BOT_HTML_TEMPLATE = """
+
+
+
{title}
@@ -212,7 +220,7 @@ async def _refresh_sitemap() -> str:
"@type": "Organization",
"name": "anyplot",
"url": "https://anyplot.ai",
- "logo": "https://anyplot.ai/og-image.png",
+ "logo": "https://anyplot.ai/icon-512.png",
"description": (
f"Open plot catalogue with AI-generated implementations across {_LIBRARY_COUNT} "
f"libraries in {_LANGUAGE_LIST}."
diff --git a/app/index.html b/app/index.html
index 19bc98db9e5..175fe23c0ea 100644
--- a/app/index.html
+++ b/app/index.html
@@ -2,7 +2,12 @@
+
+
+
@@ -130,7 +135,7 @@
"@type": "Organization",
"name": "anyplot",
"url": "https://anyplot.ai",
- "logo": "https://anyplot.ai/og-image.png",
+ "logo": "https://anyplot.ai/icon-512.png",
"description": "Open plot catalogue with AI-generated implementations across 15 libraries in Python, R, Julia, and JavaScript.",
"sameAs": [
"https://github.com/MarkusNeusinger/anyplot",
diff --git a/app/public/apple-touch-icon.png b/app/public/apple-touch-icon.png
new file mode 100644
index 00000000000..94f5ff3db7f
Binary files /dev/null and b/app/public/apple-touch-icon.png differ
diff --git a/app/public/favicon.ico b/app/public/favicon.ico
new file mode 100644
index 00000000000..8c7f1c9a26c
Binary files /dev/null and b/app/public/favicon.ico differ
diff --git a/app/public/favicon.svg b/app/public/favicon.svg
index 3654d515d9e..94a9e218e5d 100644
--- a/app/public/favicon.svg
+++ b/app/public/favicon.svg
@@ -1,8 +1,5 @@
diff --git a/app/public/icon-512.png b/app/public/icon-512.png
new file mode 100644
index 00000000000..d678dcc2fef
Binary files /dev/null and b/app/public/icon-512.png differ
diff --git a/changelog.d/site-icons.md b/changelog.d/site-icons.md
new file mode 100644
index 00000000000..f6fedef578f
--- /dev/null
+++ b/changelog.d/site-icons.md
@@ -0,0 +1,17 @@
+### Fixed
+
+- **Google showed no icon for anyplot.ai.** Crawlers are routed to the bot HTML
+ from `api/routers/seo.py`, whose `` declared no icon, and Google's
+ `/favicon.ico` fallback returned 404. The bot template now carries the same
+ icon links as `app/index.html`, and `app/public/` ships a real `favicon.ico`
+ (16, 32, 48 px) and an `apple-touch-icon.png`. `Organization.logo` points at
+ the new square `icon-512.png` instead of the 1200×630 banner. (#11838)
+
+### Changed
+
+- **The favicon is drawn from the real MonoLisa outlines.** An SVG favicon
+ loads no webfonts, so the `` mark always rendered in the viewer's
+ system monospace. `scripts/generate_favicon.py` outlines the `ap` monogram
+ from MonoLisa Bold into plain paths on an opaque paper ground (a transparent
+ icon with dark ink vanished on dark result pages) and rasterizes the ICO and
+ PNG siblings from that one SVG. (#11838)
diff --git a/docs/reference/seo.md b/docs/reference/seo.md
index d1920102843..018e3c8f3ad 100644
--- a/docs/reference/seo.md
+++ b/docs/reference/seo.md
@@ -238,6 +238,9 @@ landing deep can walk the site without executing the SPA.
+
+
+
{title}
@@ -259,6 +262,24 @@ landing deep can walk the site without executing the SPA.
```
+### Site icons
+
+The three icon links in the template mirror `app/index.html`. A crawler never receives
+`index.html`, so the bot `` has to declare the icons itself; without them Google's only
+candidate is the `/favicon.ico` fallback. The files live in `app/public/` and are generated by
+`scripts/generate_favicon.py`:
+
+| File | Purpose |
+|------|---------|
+| `favicon.ico` (16, 32, 48 px) | Google's favicon fallback and clients without SVG icon support |
+| `favicon.svg` | Browser tab. Outlined paths, because an SVG favicon loads no webfonts |
+| `apple-touch-icon.png` (180 px) | iOS home screen and link previews |
+| `icon-512.png` | Square `Organization.logo` in the JSON-LD of `index.html` and `seo.py` |
+
+The links are absolute (`https://anyplot.ai/...`) so they resolve the same way whichever host
+proxied the bot. After an icon change, request re-indexing of the home page in Search Console;
+Google refreshes favicons over days to weeks.
+
Per-page body content:
| Page | Body | JSON-LD |
diff --git a/docs/reference/style-guide.md b/docs/reference/style-guide.md
index 85f7d73569d..13f4fb1ba10 100644
--- a/docs/reference/style-guide.md
+++ b/docs/reference/style-guide.md
@@ -128,7 +128,7 @@ font-weight: 700 (bold) for letters, 400 (regular) for parens
| Context | Size | Notes |
|-------------------|-----------|---------------------------------|
-| Favicon / app icon| 16–64px | Reduce to `a.p` — keeps the dot |
+| Favicon / app icon| 16–64px | Reduce to `ap` over the green square — the dot, moved below the `a` so both letters keep their full size. Outlined paths, generated by `scripts/generate_favicon.py` |
| Top-nav | 18–22px | Standard use |
| Hero section | 40–64px | Occasional, paired with headline|
| Large display | 80–96px+ | Landing hero, posters |
diff --git a/scripts/generate_favicon.py b/scripts/generate_favicon.py
new file mode 100644
index 00000000000..53334a09220
--- /dev/null
+++ b/scripts/generate_favicon.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""Generate the site icons in `app/public/` from the real MonoLisa outlines.
+
+The icon is the `ap` monogram over the brand-green square on the paper ground.
+It is written as plain `` outlines: an SVG used as a favicon is rendered
+in an isolated context that loads no webfonts, so `` in MonoLisa always
+fell back to whatever monospace face the viewer's system had. The raster
+siblings exist because crawlers and older clients never read the SVG —
+Google's fallback is `/favicon.ico`, iOS wants `/apple-touch-icon.png`, and
+the schema.org `Organization.logo` needs a square image (`/icon-512.png`).
+
+Usage:
+ uv run --with resvg-py python scripts/generate_favicon.py
+
+The outputs are committed; rerun only when the mark or the palette changes.
+MonoLisa is fetched through `core.images` (GCS, cached in `/tmp/anyplot-fonts`)
+and only its outlines for the two letters end up in the repository.
+"""
+
+from __future__ import annotations
+
+from io import BytesIO
+from pathlib import Path
+
+import resvg_py
+from fontTools.pens.boundsPen import BoundsPen
+from fontTools.pens.svgPathPen import SVGPathPen
+from fontTools.pens.transformPen import TransformPen
+from fontTools.ttLib import TTFont
+from PIL import Image
+
+from core.images import _get_monolisa_font_path
+
+
+PUBLIC_DIR = Path(__file__).resolve().parent.parent / "app" / "public"
+
+PAPER = "#F5F3EC" # --bg-page
+INK = "#1A1A17" # --ink
+GREEN = "#009E73" # --imprint-green
+
+VIEWBOX = 32
+TEXT = "ap"
+WEIGHT = 700
+FONT_SIZE = 19.0
+BASELINE = 16.5
+LETTER_SPACING = -0.4
+SQUARE_TOP = 20.0
+SQUARE_SIZE = 5.0
+CORNER_RADIUS = 6
+
+ICO_SIZES = [(16, 16), (32, 32), (48, 48)]
+PNG_OUTPUTS = {"apple-touch-icon.png": 180, "icon-512.png": 512}
+
+
+def _format(value: float) -> str:
+ return f"{value:.2f}".rstrip("0").rstrip(".")
+
+
+def build_svg(font_path: Path, corner_radius: int = CORNER_RADIUS) -> str:
+ """Outline the monogram at the icon's geometry and return the SVG document."""
+ font = TTFont(font_path)
+ glyphs = font.getGlyphSet(location={"wght": WEIGHT})
+ cmap = font.getBestCmap()
+ scale = FONT_SIZE / font["head"].unitsPerEm
+
+ names = [cmap[ord(char)] for char in TEXT]
+ width = sum(glyphs[name].width * scale for name in names) + LETTER_SPACING * (len(names) - 1)
+ x = VIEWBOX / 2 - width / 2
+
+ commands = []
+ ink_left = None
+ for name in names:
+ transform = (scale, 0, 0, -scale, x, BASELINE)
+ pen = SVGPathPen(glyphs, ntos=_format)
+ glyphs[name].draw(TransformPen(pen, transform))
+ commands.append(pen.getCommands())
+ if ink_left is None:
+ bounds = BoundsPen(glyphs)
+ glyphs[name].draw(TransformPen(bounds, transform))
+ ink_left = bounds.bounds[0]
+ x += glyphs[name].width * scale + LETTER_SPACING
+
+ return (
+ f'\n"
+ )
+
+
+def render_png(svg: str, size: int) -> bytes:
+ return bytes(resvg_py.svg_to_bytes(svg_string=svg, width=size, height=size))
+
+
+def main() -> None:
+ font_path = _get_monolisa_font_path()
+ if font_path is None:
+ raise SystemExit("MonoLisa is unavailable (GCS access needed); refusing to outline a fallback face.")
+
+ svg = build_svg(font_path)
+ (PUBLIC_DIR / "favicon.svg").write_text(svg, encoding="utf-8")
+
+ # The large PNGs are full-bleed squares: iOS and Google apply their own mask,
+ # and transparent corners would come out black on a home screen.
+ square_svg = build_svg(font_path, corner_radius=0)
+ for filename, size in PNG_OUTPUTS.items():
+ (PUBLIC_DIR / filename).write_bytes(render_png(square_svg, size))
+
+ # Each ICO entry is rendered at its own size rather than downscaled from one
+ # large bitmap, so the 16 px entry keeps the hinting resvg gives it.
+ frames = [Image.open(BytesIO(render_png(svg, width))) for width, _ in ICO_SIZES]
+ frames[-1].save(PUBLIC_DIR / "favicon.ico", format="ICO", sizes=ICO_SIZES, append_images=frames[:-1])
+
+ for filename in ("favicon.svg", "favicon.ico", *PNG_OUTPUTS):
+ print(f"{filename}: {(PUBLIC_DIR / filename).stat().st_size} B")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/unit/api/test_seo_helpers.py b/tests/unit/api/test_seo_helpers.py
index 138a443a8ce..e1cf8696315 100644
--- a/tests/unit/api/test_seo_helpers.py
+++ b/tests/unit/api/test_seo_helpers.py
@@ -273,6 +273,13 @@ def test_has_canonical(self) -> None:
assert 'rel="canonical"' in result
assert url in result
+ def test_has_site_icon_links(self) -> None:
+ """Crawlers never receive index.html, so the bot head declares the icons itself."""
+ result = _render_bot_html(title="t", description="d", image="i", url="u")
+ assert '' in result
+ assert '' in result
+ assert '' in result
+
def test_default_body_and_nav(self) -> None:
result = _render_bot_html(title="t", description="d", image="i", url="u")
assert "