Content Favicon Rasterization

A node page identifies itself in the browser tab: it declares its own icon, not the portal's. That has shipped since 2026-08-11 — SeoResolver.ResolveIcon emits the node's mark on the initial server response, before any Blazor circuit exists.

On Safari it did nothing at all. This page is why, and what the fix is.

The defect

MeshNode.Icon for every store package is authored inline <svg> — a census of the plugin repository found 56 of 56 marks in that form, the Chess board among them. The head therefore carried one link:

<link rel="icon" href="data:image/svg+xml,%3Csvg…" type="image/svg+xml" />

Safari renders no SVG favicon. Not from a data: URI, not from a URL, not in a tab, not in a bookmark. So on every Mac and iPhone the per-content favicon was invisible — each tab wore the portal mark — in circuit and out of it alike. A feature a whole browser cannot see is not shipped for the people using it.

The fix: declare both, let the browser choose

The head now declares three links for a node whose mark is svg:

<link rel="icon" href="data:image/svg+xml,…" type="image/svg+xml" />
<link rel="icon" type="image/png" sizes="32x32" href="/api/icon/Chess.png?size=32" />
<link rel="apple-touch-icon" sizes="180x180" href="/api/icon/Chess.png?size=180" />

This is the standards-based shape, not a workaround. A browser that reads SVG prefers the scalable icon — which is worth keeping: a vector mark beats a fixed 32 px on a retina tab strip. Safari skips the one it cannot read and takes the PNG. Nothing is lost and one browser is gained.

apple-touch-icon is a separate channel, not a fallback: it is Safari's large bookmark / Start-Page / Add-to-Dock tile, and with none declared Safari draws its own letter tile rather than consulting the favicon. A node with a mark needs one pointed at that mark or its tile says nothing about it.

The set is produced by SeoResolver.ResolveIconLinks (memex/Memex.Portal.Shared/Seo/). ResolveIcon — the single-icon accessor — is unchanged and still returns the first of them.

🚨 PageIcon gained Rel and Sizes as init PROPERTIES, not as primary-constructor parameters — and that is not a style choice. A record's primary constructor is a binary contract with every module already compiled against it: adding a parameter replaces the signature even when it carries a default, so a published module calls a constructor the new platform no longer has and the host aborts at boot, in both directions. There is no image that can serve a mixed set. scripts/check-record-signatures.py is the gate that says so, and it caught the positional version of exactly this change — a red worth having, because nothing about PageIcon(Href, Type, Rel = "icon", Sizes = null) looks breaking from the call site. A new property is additive and cannot split an image.

`/api/icon/

SeoEndpoints.MapNodeIcon, beside /api/og/{node}.png. Four properties matter:

Why Svg.Skia, and why it moved SkiaSharp

SkiaSharp draws; it does not parse SVG. OgCardRenderer gets away with SkiaSharp alone because it composes its card from primitives it chooses itself. An icon is the opposite: the mark is arbitrary authored markup.

That made the scope decision explicit rather than accidental:

option covers cost
draw generated backplates directly in Skia generated marks only no dependency — but misses Chess, the reported case
add Svg.Skia every mark, authored ones included one dependency

The second option is tempting precisely because it avoids a dependency, and it would have shipped an /api/icon endpoint Safari honours while leaving the exact node in the bug report still showing the portal favicon. A fix that does not cover its own reproduction case is the shape worth refusing, so the dependency was taken.

Measured on the real population, not on a sample: every one of the 56 authored store marks was pushed through IconRasterizer at both declared sizes — 56/56 produced a valid PNG of the right dimensions, 0 blank, 0 parse failures. That is the check worth repeating if a mark ever stops appearing; the marks are the input this was chosen for.

🚨 Svg.Skia and SkiaSharp are one decision, not two. Svg.Skia 4.9.1 floors SkiaSharp at 3.119.2; a lower central pin under a higher transitive floor is NU1605, not a silent resolve — so the central pin moved from 3.116.1 with it. Moving either version moves the other.

Licensing clears the gate with nothing to add to the allowlist: Svg.Skia, Svg.Model, Svg.SceneGraph, Svg.Animation, ShimSkiaSharp, ExCSS and the HarfBuzzSharp natives are MIT; Svg.Custom (the SVG.NET fork) is MS-PL. Both are already permitted.

The NoDependencies posture survives. The portal deliberately ships SkiaSharp.NativeAssets.Linux.NoDependencies so the image needs no fontconfig/libfreetype and no apt packages. The HarfBuzzSharp natives Svg.Skia brings are standalone and change nothing about that.

What is deliberately NOT rasterized

Only inline <svg> is. Two exclusions, each for a reason:

And, unchanged from the day the feature shipped: a node with no mark of its own synthesises nothing. No letter tile, no NodeType stand-in. ResolveIconLinks returns empty and the portal favicon stays — the honest answer for a page with no mark, and the reason the route's 404 is never reached from a page we serve. Redirecting to the site favicon there would look like a fix while telling every consumer that this node's mark is the portal's.

The cross-repo seam

The policy and the endpoint live in core (memex/Memex.Portal.Shared/); the <head> that renders the links lives in MeshWeaver.Plugins (Memex.Portal.Gui/Seo/SeoHead.razor, and the in-circuit swap in MeshWeaver.Blazor/Pages/ApplicationPage.razor), because the portal shell moved there with the GUI split. So core decides what the links are and the shell renders them — ResolveIconLinks is the seam.

That makes this a two-repo change in the ordinary direction: core merges first, and until the Plugins side lands the endpoint is live and correct but only one link is declared, i.e. exactly today's behaviour. Nothing half-lands.

🚨 The residue: the IN-CIRCUIT head is a separate, narrower channel

SeoHead renders for anonymous requests only — it returns early when HttpContext.User.Identity.IsAuthenticated. A signed-in user's tab icon comes instead from ApplicationPage's circuit-side <HeadContent>, which declares one link from MeshNodeImageHelper.ResolveIconLink. That path still declares SVG only, so a signed-in Safari user's tab keeps the portal mark.

It is deliberately not fixed here, because it is not the same problem wearing a different hat:

The reported defect — the anonymous, server-rendered head that crawlers and first paint read, and that every shared link resolves through — is fully covered. This residue is named so the next person measures it rather than rediscovering it from a screenshot.

Verifying it

# the head declares all three
curl -s https://memex.meshweaver.cloud/Chess | grep -oE '<link rel="(icon|apple-touch-icon)"[^>]*>'

# and the raster route really answers with a PNG of that size
curl -s "https://memex.meshweaver.cloud/api/icon/Chess.png?size=180" | file -

A node with no mark answering 404 on that second command is the design, not a failure — check its head declares no icon link either. See also Link Previews for the rest of the crawler-facing head, and Dependency Licensing for the gate the new packages pass.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.