Skip to content

fix: replace the wordmark's SVG mask with plain filled paths - #1185

Merged
vivek7405 merged 1 commit into
mainfrom
fix/wordmark-svg-mask-safari
Jul 30, 2026
Merged

fix: replace the wordmark's SVG mask with plain filled paths#1185
vivek7405 merged 1 commit into
mainfrom
fix/wordmark-svg-mask-safari

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Summary

  • On mobile Safari, the header logo's "W" rendered as a solid near-white rectangle in dark theme (screenshot from the user), while "bJs" rendered fine.
  • Root cause: the four theme-variant SVGs (webjs-lockup-on-{dark,light}.svg, webjs-mark-on-{dark,light}.svg) built the "W" via an SVG <mask>. An <img> toggled from display:none to visible (exactly what hidden dark:block does for the two theme variants) is a known WebKit failure mode for masks, showing the raw unmasked base rect instead.
  • Confirmed this is not the separate Cloudflare cache staleness issue: committed source, Railway origin, and Cloudflare-served bytes are identical.
  • Replaced the mask in all four files with plain filled <path> data (same technique "bJs" already uses, never affected), computed as the miter-stroke outline of the same zigzag clipped against the same horizontal band.

Test plan

  • Verified pixel-identical to the original mask rendering for all four files: 0 differing pixels at 3% fuzz (ImageMagick compare -metric AE, neutral background to expose any transparency difference too)
  • Verified visually in a real browser, both themes, on the home page header and /brand
  • webjs check clean
  • npm test (unit + browser), 35/35 browser tests pass
  • Rebuilt webjs-brand-assets.zip; the existing zip-freshness test in test/ssr/brand-page.test.ts passes
  • Not verified on an actual iOS Safari device (no device available); the fix removes the masking mechanism entirely rather than attempting to patch around the specific WebKit repro, so it should hold regardless of the exact trigger condition

Closes #1184

Reported live: on mobile Safari the header logo's 'W' rendered as a
solid near-white rectangle in dark theme, while 'bJs' (plain filled
paths, no mask) rendered fine. Confirmed the committed source, the
Railway origin, and the Cloudflare-served asset are byte-identical, so
this is not the separate Cloudflare staleness issue, it is a genuine
rendering bug in the asset technique.

All four affected files (both lockup variants, both mark variants)
built the 'W' via an SVG <mask>: a stroked zigzag painted white inside
a black rect, with a further black rect punching a horizontal slice
through the middle. An <img> that starts display:none (exactly what
hidden dark:block produces for the two theme variants) and later
becomes visible is a known WebKit failure mode for masks: the browser
can skip reapplying the mask on first paint and show the raw unmasked
base rect instead, which for the dark variant is #f7f5f2, matching the
reported white box exactly.

Replace the mask in all four files with two plain filled <path>
elements (the miter-stroke outline of the same zigzag, clipped against
the same horizontal band via Sutherland-Hodgman), the same technique
'bJs' already uses and which never exhibits this bug. Verified
pixel-identical to the mask rendering for all four files: 0 differing
pixels at 3% fuzz (antialiasing only) against a neutral background.

webjs-monogram.svg and webjs-monogram-on-light.svg are unaffected:
they cut the slice by painting a rect in the tile's own solid
background color, which needs no mask since that tile's background is
known and solid, unlike the wordmark/mark files sitting on arbitrary
page content.

Rebuilt webjs-brand-assets.zip so the download bundle matches.

Closes #1184
@vivek7405
vivek7405 force-pushed the fix/wordmark-svg-mask-safari branch from af824b4 to 7640ab8 Compare July 30, 2026 09:12
@vivek7405
vivek7405 marked this pull request as ready for review July 30, 2026 09:19
@vivek7405
vivek7405 merged commit 086b3c7 into main Jul 30, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/wordmark-svg-mask-safari branch July 30, 2026 09:19
vivek7405 added a commit that referenced this pull request Jul 30, 2026
* ci: purge the Cloudflare cache after each site deploy

webjs.dev's static assets are served with max-age=14400 at stable urls,
so after a deploy the edge keeps serving the previous copy for up to four
hours. That shipped two visible regressions in one day (a pre-redesign
tailwind.css after #1179, then the un-fixed logo marks after #1185), each
cleared by a manual dashboard purge somebody had to remember. Staleness is
also per-asset rather than all-or-nothing, so the site can sit half-updated
with no signal.

The workflow deliberately does not purge on push. Railway auto-deploys from
the same push and the build takes minutes, so an immediate purge would evict
the cache while the origin still served the old bytes and the next visitor
would repopulate the edge with exactly those bytes, leaving the site stale
for another four hours and burning the run for nothing. Instead it polls
/__webjs/version (#239) on the Railway origin, bypassing the cache it is
about to purge, until the reported uptime is lower than the elapsed time
since the run began, which proves the restart belongs to THIS push rather
than some earlier deploy. On timeout it fails loudly instead of purging.

Purge is zone-wide: all four hostnames are proxied in the one webjs.dev
zone, so a single call covers them and cannot silently miss an asset the
way a hand-maintained path list does. curl uses --fail-with-body and the
response is asserted on .success, since a bare curl exits 0 on a 4xx and
would report a rejected purge as a success.

workflow_dispatch is wired for on-demand purges, which is what the two
incidents above actually needed.

Closes #1188

* ci: harden the purge workflow against transient origin failures

Self-review of the new workflow turned up four real defects, all in the
wait step's failure handling.

1. Under `set -e`, `uptime=$(... | jq ...)` aborts the whole step when jq
   fails, so a single non-JSON body from the origin (exactly what a
   mid-deploy error page returns) turned a transient blip into a hard
   failure instead of another retry. Every extraction now ends in
   `|| true`, and the numeric comparison defaults to false.

2. The loop was 60 iterations of (10s curl + 15s sleep), a 25 minute
   worst case against `timeout-minutes: 25`, so the job-level kill could
   fire before the step printed why it gave up. The loop is now
   deadline-driven on an explicit 15 minute WAIT_BUDGET, which holds
   whatever the per-request latency turns out to be, leaving 10 minutes
   of headroom for the diagnostic.

3. The verify step reported "Edge matches the origin" when EITHER side
   sent no ETag, and Cloudflare strips ETags under some zone settings, so
   the reassuring message was reachable with no comparison behind it. A
   missing header is now reported as inconclusive.

4. The job declared no `permissions`, inheriting the default token scopes
   despite never checking out the repo or calling the GitHub API. Now
   `permissions: {}`.

Verified by running the real wait step against stub origins: the happy
path (uptime below elapsed) exits 0 after detecting the restart, and a
dead origin exits 1 with the refuse-to-purge guidance.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: header logo shows a solid rect on mobile Safari (SVG mask bug)

1 participant