Skip to content

Commit eed6a0f

Browse files
committed
fix: inline brand SVGs to fix mobile blur
The mono SVG files are themselves <mask>+<rect> documents, and iOS Safari blurs masked-SVG sources both when used as <img> + filter and as CSS mask-image. Inline the SVG markup as live DOM and recolor the painted rect via currentColor instead. Vite resolves the source bytes at build time via ?raw, so logos still ship in the bundle without a runtime fetch. Mask IDs are namespaced per instance via useId() so multiple inlined logos don't collide. Amp-Thread-ID: https://ampcode.com/threads/T-019e022a-fabb-703a-85e7-a4c1dcf38b1f
1 parent 58b7fa4 commit eed6a0f

2 files changed

Lines changed: 72 additions & 30 deletions

File tree

src/lib/logos.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Build-time-bundled mono brand-mark SVGs, indexed by slug.
3+
*
4+
* The mono SVG files in `public/logos/mono/` are generated as
5+
* `<mask> + <rect fill="white" mask="url(#m)">` documents. Rendering
6+
* those as `<img src="*.svg">` (with `filter` recolor) or as a CSS
7+
* `mask-image` causes blur on iOS Safari — Safari rasterizes masked
8+
* SVG images at 1× device pixels in both paths.
9+
*
10+
* Solution: inline the SVG as live DOM and recolor via `currentColor`.
11+
* Vite resolves `?raw` imports at build time, so the bytes ship in
12+
* the bundle without a runtime fetch.
13+
*/
14+
const sources = import.meta.glob<string>('../../public/logos/mono/*.svg', {
15+
query: '?raw',
16+
import: 'default',
17+
eager: true,
18+
})
19+
20+
const bySlug: Record<string, string> = Object.fromEntries(
21+
Object.entries(sources).map(([path, raw]) => {
22+
const slug = path.match(/\/([^/]+)\.svg$/)?.[1] ?? ''
23+
return [slug, raw]
24+
}),
25+
)
26+
27+
/**
28+
* Return the SVG markup for `slug` rewired for inline rendering:
29+
*
30+
* 1. Swap the painted `<rect>`'s `fill="white"` for `fill="currentColor"`
31+
* so the mark inherits the host element's CSS color.
32+
* 2. Namespace the internal `id="m"` mask reference per instance so
33+
* multiple inlined logos don't collide on a shared id.
34+
*
35+
* Returns `undefined` when the slug isn't in the manifest.
36+
*/
37+
export function get(slug: string, instanceId: string): string | undefined {
38+
const raw = bySlug[slug]
39+
if (!raw) return undefined
40+
const id = `m-${slug}-${instanceId}`
41+
return raw
42+
.replace(
43+
/(<rect\b[^>]*?)\bfill="white"(\s+mask="url\(#m\)")/,
44+
`$1fill="currentColor"$2`,
45+
)
46+
.replace(/\bid="m"/, `id="${id}"`)
47+
.replace(/mask="url\(#m\)"/, `mask="url(#${id})"`)
48+
}

src/routes/index.tsx

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { createFileRoute } from '@tanstack/react-router'
2-
import { useEffect, useState } from 'react'
2+
import { useEffect, useId, useState } from 'react'
33
import StarIcon from '~icons/ic/outline-star'
44
import NpmIcon from '~icons/file-icons/npm-old'
55
import GithubIcon from '~icons/simple-icons/github'
66
import * as Config from '~/lib/config'
7+
import * as Logos from '~/lib/logos'
78
import * as Sources from '~/lib/sources'
89
import type * as Github from '../../worker/sources/github'
910

@@ -317,16 +318,17 @@ const logoOverrides = Object.fromEntries(
317318
)
318319

319320
/**
320-
* Render a brand mark via CSS `mask-image` + `background: currentColor`
321-
* when the slug is in the manifest, else a `<span>` plain-text fallback.
322-
* No client-side image fallback so SSR HTML is final and there's no
323-
* broken-image flicker. Width is computed from the manifest aspect
324-
* ratio so the layout box matches the visual size (no CLS).
321+
* Render a brand mark by inlining the mono SVG as live DOM and
322+
* recoloring via `currentColor`. Falls back to a `<span>` plain-text
323+
* mark when the slug isn't in the manifest. No client-side image
324+
* fallback so SSR HTML is final and there's no broken-image flicker.
325325
*
326-
* Why mask instead of `<img>` + `filter`: iOS Safari has a long-standing
327-
* bug where applying `filter` to an `<img>` containing an SVG forces
328-
* rasterization at 1× device pixels, producing blur on retina/mobile.
329-
* `mask-image` recolors via the GPU and stays sharp at any DPR.
326+
* Why inline SVG instead of `<img>` + `filter` or `<span>` + `mask-image`:
327+
* the mono SVGs are themselves `<mask>+<rect>` documents (see
328+
* [scripts/lib/svg.ts](scripts/lib/svg.ts) `wrapMask`). iOS Safari blurs
329+
* any path that rasterizes a masked SVG image — both `<img>` + `filter`
330+
* and CSS `mask-image` hit that bug at 3× DPR. Inlining the SVG renders
331+
* it as live vector DOM and stays sharp at any scale.
330332
*/
331333
function Mark({
332334
height,
@@ -339,37 +341,29 @@ function Mark({
339341
name: string
340342
slug: string
341343
}) {
344+
// `useId()` returns IDs like `:r0:` — colons are valid HTML but ugly
345+
// inside SVG `url(#…)` references; strip them for cleaner markup.
346+
const id = useId().replace(/:/g, '')
342347
const ratio = manifest?.[slug]
343-
if (ratio === undefined)
348+
const svg = ratio !== undefined ? Logos.get(slug, id) : undefined
349+
if (ratio === undefined || !svg)
344350
return <span className="text-lg font-medium tracking-[-0.01em] text-primary">{name}</span>
345351
// Per-slug visual scale lives in config (not the SVG bytes) so we
346-
// don't have to reason about server-side viewBox clipping. We
347-
// multiply both dimensions so the layout box reflects the visual
348-
// size — important for the marquee, where transform-based scaling
349-
// would overflow into the overflow-hidden clip.
350-
// lowercase to match the case-insensitive override map
352+
// don't have to reason about server-side viewBox clipping. We size
353+
// the wrapper box in CSS — important for the marquee, where
354+
// transform-based scaling would overflow into the overflow-hidden
355+
// clip. lowercase to match the case-insensitive override map.
351356
const scale = logoOverrides[slug.toLowerCase()]?.scale ?? 1
352357
const renderedHeight = height * scale
353358
const renderedWidth = Math.round(renderedHeight * ratio)
354-
const url = `/logos/mono/${slug}.svg`
355359
return (
356360
<span
357361
aria-hidden="true"
358-
className="block bg-current transition-colors duration-100"
362+
className="block transition-colors duration-100 [&>svg]:block [&>svg]:h-full [&>svg]:w-full"
359363
data-mark
364+
dangerouslySetInnerHTML={{ __html: svg }}
360365
role="img"
361-
style={{
362-
height: `${renderedHeight}px`,
363-
width: `${renderedWidth}px`,
364-
WebkitMaskImage: `url(${url})`,
365-
maskImage: `url(${url})`,
366-
WebkitMaskRepeat: 'no-repeat',
367-
maskRepeat: 'no-repeat',
368-
WebkitMaskPosition: 'left center',
369-
maskPosition: 'left center',
370-
WebkitMaskSize: 'contain',
371-
maskSize: 'contain',
372-
}}
366+
style={{ height: `${renderedHeight}px`, width: `${renderedWidth}px` }}
373367
title={name}
374368
/>
375369
)

0 commit comments

Comments
 (0)