Skip to content

Add Microsoft Clarity; give Google a favicon and a site name - #18

Merged
sgf36 merged 16 commits into
mainfrom
site/microsoft-clarity
Aug 28, 2026
Merged

Add Microsoft Clarity; give Google a favicon and a site name#18
sgf36 merged 16 commits into
mainfrom
site/microsoft-clarity

Conversation

@sgf36

@sgf36 sgf36 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Two related changes to how the site is seen — one by visitors' consent choices, one by Google.

1. Microsoft Clarity, behind consent

Project y49v1x7kti, loaded from inside enableAnalytics() so it sits behind the same consent as GA; nothing reaches Microsoft on a decline. Empty or PLACEHOLDER disables it cleanly.

The banner and privacy policy change with it. Both named Google alone, and the policy called it "the only third party on the site". Shipping the loader by itself would leave two public documents describing a site that no longer exists.

On masking the policy states only what Microsoft guarantees: input boxes and drop-downs are masked in every mode and cannot be unmasked; Balanced additionally masks numbers and email addresses. A property of the product, not a setting made here.

This forecloses the PECR statistical-purposes exception. A session replay is not aggregate information, so the banner stays while Clarity is present.

2. A favicon Google can fetch, and a site name to print

The Wren result in Google showed a generic globe and the words "spencerfields.com". This site had the same gaps and one more: no favicon of any kind, and no Open Graph tags at all.

  • Favicon — Google needs a crawlable file linked from the home page. Three now, generated from the desktop app's own icon so search matches the taskbar: favicon.ico (six resolutions to 256), icon-192.png, apple-touch-icon.png.
  • Site name — Google reads WebSite structured data, then og:site_name, then the title. The page offered neither of the first two, so it fell back to the domain. Both now say "Easy-Post Desktop".

3. deploy_site.py could not ship an image

It read every file as UTF-8 text and posted it through save_file_content, a text API that performs a charset conversion. The first PNG died with UnicodeDecodeError — which is the good outcome. The bad one is a file of the right length and the wrong bytes, surfacing later as a broken image rather than an error.

Binary suffixes now upload as multipart and are verified byte-for-byte, rather than through the whitespace-normalising text comparison that would be meaningless for an image.

⚠️ The head tags will be evicted unless the generator emits them

These exact tags were deployed earlier today and then overwritten when the localisation tooling regenerated and redeployed index.html. The icons survived only because they are separate files.

Until the generator emits the icon links, the Open Graph block and the JSON-LD, every regeneration silently removes them and the result quietly reverts to a globe. That is the fix worth making.

Verification

Live after deploy: two icon links, og:site_name, the JSON-LD and the localisation markers all present on the served page; all three icon files return 200 with correct MIME types; robots.txt blocks nothing. Clarity verified separately — nothing before consent, tag and library on Accept, two collect calls.

Generated with Claude Code

sgf36 and others added 3 commits August 18, 2026 01:50
Publishing site/contact.php wrote correctly and then failed the script. The
read-back fetched https://easy-post.spencerfields.com/contact.php, the host
answered 409, raise_for_status() raised, and deploy_site.py exited 1 with a
traceback on a perfectly good deployment. The 409 is still live today, so this
was not transient.

Fetching the public URL cannot verify a PHP file at all. The server runs it
instead of serving it, so the response is whatever the script chose to emit.
Even without the 409 the comparison would have been wrong: contact.php
302-redirects to index.html#support on any request that is not a POST, so a
successful fetch would have compared the home page against the source.

upload() now picks a read-back route by suffix. Executed files are read back
with Fileman::get_file_content on the same UAPI that wrote them, which returns
the source rather than the output. Static files keep the existing HTTPS path
unchanged, browser User-Agent and forced UTF-8 decode included, because for
those the served document is the thing worth checking and the File Manager
listing is not.

Both routes share _equivalent(). That matters more than it looks: cPanel's save
collapses whitespace between tags and normalises CRLF to LF, so the deployed
contact.php differs from the local source in 23 characters despite being a
correct deployment. An exact match would fail here.

Verified against the live host without a production write, since
save_file_content is untouched: the matching file passes, a CRLF working copy
passes, and altered and truncated content are both still reported as
mismatches, so the new path is not verifying vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google Analytics reports what happened in aggregate. It cannot show where on a
page someone gave up, which is the question a pricing page that converts badly
actually poses. Clarity records that.

Loaded from inside enableAnalytics(), so it sits behind the same consent as GA
and nothing reaches Microsoft on a decline. Leaving CLARITY_ID empty or at
PLACEHOLDER disables it cleanly, matching the pattern checkout.js already uses
for the Paddle token.

**The banner and the privacy policy change in the same commit, deliberately.**
Both previously named Google Analytics alone, and the policy called it "the only
third party on the site". Shipping the loader by itself would have left two
public documents describing a site that no longer existed. A consent notice that
does not name what it is asking about is not consent to the thing it loads.

On masking, the policy now states only what Microsoft guarantees. Content in
input boxes and drop-downs is masked in every mode Clarity offers and cannot be
unmasked, so what a visitor types into the contact form is never uploaded; the
default Balanced mode additionally masks numbers and email addresses appearing
in page content. An earlier draft of this text said Clarity had been "told to"
mask form input, which implied a configuration that had not been made. It is a
guarantee of the product, not a setting here, and the policy now says that.

**This forecloses the PECR statistical-purposes exception, on purpose.** The
Data (Use and Access) Act 2025 route, in force since February 2026, requires the
result to be aggregate information that cannot identify people. A session replay
is neither. Consent was always required for GA as configured, so nothing is lost
today — but the option of dropping the banner by moving to a cookieless,
aggregate-only tool is now closed for as long as Clarity is present. That is a
trade worth making only while there is something to watch.

Verified against the deployed file on a real page: nothing loads before a
choice, Accept injects the tag and the library and produces two collect calls,
and the banner names both services.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o ship images

The Wren result in Google showed a generic globe and the words
"spencerfields.com". This site had the same two gaps and one more: no favicon
of any kind, and no Open Graph tags at all.

**Favicon.** Google needs a favicon file that Googlebot-Image can crawl, linked
from the home page. There was none, so there was nothing to show. Three files
now, generated from the desktop app's own icon so the mark in search matches the
mark on the taskbar: favicon.ico at six resolutions to 256, icon-192.png at
Google's recommended multiple of 48, and apple-touch-icon.png at 180.

**Site name.** Google reads WebSite structured data first, then og:site_name,
then the title. The page offered none of the first two, so it fell back to the
domain. Both now say "Easy-Post Desktop".

**deploy_site.py could not ship an image at all.** It read every file with
read_text(encoding="utf-8") and posted it through save_file_content, which is a
text API that performs a charset conversion. A PNG did not merely fail — the
first attempt died with UnicodeDecodeError, which is the good outcome. The bad
one would have been a file of the right length and the wrong bytes, showing up
later as a broken image and not as an error. Binary suffixes now go up as a
multipart upload and are verified byte-for-byte rather than through the
whitespace-normalising text comparison, which would be meaningless for an image.

index.html here also carries the localisation head and language picker. That was
already deployed and the repository was behind it; this brings the two back into
agreement rather than reverting anyone's work.

**The head tags are generated-file tenants and will be evicted.** These same
tags were deployed earlier today and then overwritten when the localisation
tooling regenerated and redeployed index.html — the icons survived only because
they are separate files. Until the generator emits the icon links, the Open
Graph block and the JSON-LD itself, every regeneration silently removes them and
the search result quietly reverts to a globe. That is the fix worth making.

Verified live after deploying: two icon links, og:site_name, the JSON-LD and the
localisation markers all present on the served page, and all three icon files
return 200 with correct MIME types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sgf36 sgf36 changed the title Add Microsoft Clarity, and say so in the banner and the privacy policy Add Microsoft Clarity; give Google a favicon and a site name Aug 18, 2026
sgf36 and others added 13 commits August 19, 2026 14:33
… apart

The contact endpoint served two products and could not tell them apart: both
sites posted the same five fields and the topic lists overlap on "Bug report"
and "Something else". A Wren enquiry was therefore answered from Easy-Post's
facts and acknowledged as "Easy-Post Desktop Support". Both sites now send a
product field, and a PRODUCTS registry holds each one's name, Resend account,
sender, auto-reply topics, triage facts and brand palette.

Wren runs on its own Resend account, so resendSend takes the account for the
product in hand rather than reading one key off env. An unknown or absent
product falls back to Easy-Post, so the sites and the Worker can deploy in
either order without dropping mail.

Support mail is now translated. Inbound is detected and rendered into English
for the owner, with the original beneath it and the language named; the triage
model reads the English so it stays grounded in English facts. Replies go out
in the language the customer wrote in.

Outbound is a relay: the forward carries reply+<case>.<hmac>@<domain> as its
Reply-To, so replying in Outlook comes back through Resend, is translated, and
reaches the customer with the English beneath. It works in both directions, so
a customer reply is translated to English and forwarded on.

The MAC is not decoration. A bare case id is a small enough space to search,
and a guessed relay address would let a stranger send mail to a customer that
appears to come from us. Automated mail, mail from the relay itself, senders
not party to the case and threads past thirty messages are all refused, and a
refusal answers 200 with a reason because a non-2xx makes Resend retry a
message we have already decided not to relay.

Quoted history is cut mechanically rather than by a model: a model that
misjudges the boundary mails the customer their own message back, silently.

With REPLY_TOKEN_SECRET unset the relay is off and Reply-To reverts to the
customer address, exactly as before it existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
English plus the fourteen most-spoken languages by total speakers: Mandarin,
Hindi, Spanish, Standard Arabic, French, Bengali, Portuguese, Indonesian, Urdu,
Russian, German, Japanese, Marathi and Telugu. Nigerian Pidgin and Egyptian
Arabic are in the published top fifteen but have no usable locale here, so the
next two the app ships take their place.

Fifteen languages that can be checked beat forty-nine that cannot. Every string
was translated, then reviewed by a second pass acting as a native-speaker
reviewer, then linted mechanically. The review is what earned its place: it
caught Apple's actual localised term for Maps Guides in Spanish, Russian and
Portuguese, a Bengali rendering of "Why" as "because", and gender and register
inconsistencies no automated check would see.

Pages are pre-rendered per language rather than swapped by script, so they are
indexable and work without JavaScript. Each carries a canonical pointing at its
own URL, reciprocal hreflang for the set that actually exists, and a language
picker below the navigation. The sitemap stays English-only on purpose: five
pages are already stuck at "Discovered, currently not indexed", and adding a
few hundred URLs would not help that.

Legal pages carry a notice that the English text governs. Machine translation
must not quietly create fifteen binding versions of a refund clause.

The English pages themselves are rewritten because every page now passes
through the same parser and serialiser; that normalisation has to happen before
translation or the memory is keyed to hashes that no longer match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…at itself

The favicon, Open Graph block and site-name JSON-LD were added to the English
home page three minutes after the fourteen language directories were generated,
so the translations were built from the version before it and shipped without
any of it. Rebuilt from the current English, which is what the pipeline is for:
head content is copied, not reconstructed.

og:url was wrong everywhere and had been from the start. Canonical was rewritten
per language but Open Graph was not, so the German page told Google it was the
German page and told every social crawler it was the English home page. It now
points at its own URL, and og:locale is set with it, because an Open Graph block
with no locale is read as en_US.

Only index.html carries this markup, matching the English pages; the pipeline
mirrors each page rather than adding tags of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A third product: software.spencerfields.com, the business address of the whole
operation. It has no Resend domain of its own -- each account allows one, and
both are used -- so its mail goes out on the Easy-Post account under a
"Spencer Fields Support" display name, and replies arrive on the Easy-Post
domain, which is the one carrying an inbound MX. Set SOFTWARE_FROM_EMAIL if the
domain is ever verified; nothing else changes.

Its auto-reply topic set is deliberately empty. Every enquiry reaches a person.

The footer read "a product of Spencer Fields, London, United Kingdom" on every
message. The published descriptor is deliberately city-less -- a registered
address exists precisely so a home city is not published -- and the line also
read "Spencer Fields, a product of Spencer Fields" for the new brand. It is a
per-brand token now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
software.spencerfields.com is verified in its own account, so it sends as
support@software.spencerfields.com and receives replies on its own domain
rather than borrowing Easy-Post's.

Each part degrades on its own. Without the key or the sender it falls back to
the Easy-Post account under a display name; without the webhook secret,
replyDomainFor keeps reply addresses on the Easy-Post domain. That last one
matters: issuing a reply address on a domain nothing is listening to would have
Resend accept the message and drop it, and the owner would believe they had
replied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The form carries a hidden language field, set per language by the build, so the
handler answers in the language the visitor was reading rather than in English.
Right-to-left languages get dir="rtl" on the result page too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The answer was translated but everything around it was not, so a Spanish reader
got their reply in Spanish wrapped in an English greeting, heading, reference
label and sign-off. The template's own wording is now translated too, from a
static table rather than a model call: the sentences never change, and paying a
round-trip on every acknowledgement to re-derive the same six lines would be
poor design. It also means the standard acknowledgement needs no model call at
all.

Each half now carries a small coloured chip naming its language. A bilingual
message without one is confusing -- the reader cannot tell at a glance which
half is theirs, and the second looks like a mistake.

The footer is translated with the rest, and its links point into the reader's
own language where that language has published pages. A translated email whose
footer lands on English pages undoes the point of translating it.

Right-to-left is set on the translated block only. The English half sits beside
it and must stay left-to-right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sender read "Spencer Fields", which is the person and the trading name but
not the thing the reader is dealing with. It is set verbatim rather than by
appending the usual " Support" suffix: "Software - Spencer Fields Support"
would read as a third entity again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The subject line and the header bar read "Spencer Fields", which is the trading
name but not the thing the reader is dealing with. Both now read
"Software - Spencer Fields", matching the sender.

The footer is deliberately left as "Spencer Fields": that sentence names the
legal entity, and the entity is the person.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The canonical claimed "/index.html" while the sitemap listed "/". Google treats
those as two URLs, so the sitemap entry would be reported as an alternative of
a page it was never asked to index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine screenshots from the app itself on the home page, with the captions
already approved for the Microsoft Store listing rather than new claims written
for the web.

They are resized to 1400px and served as WebP: 733 KB of store originals become
368 KB. A first attempt at "optimising" them made the files larger, because
resizing promoted palette PNGs to full RGB and added resampling noise PNG
cannot compress.

Easy-Post Mobile Companion is on the App Store, so the two sentences saying it
was "arriving" and "on its way" are now false and corrected. The link is the
country-neutral apps.apple.com/app/id6798723985, which redirects to the
visitor's own store -- necessary on a site published in sixteen languages.

Swedish added throughout: both apps ship it, and it is wanted for testers in
Sweden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch was cut before #21, so its translated pages still offer the signed
APK that #21 withdrew from the English page. Merging it as it stood would have
put the Download for Android button back on fifteen pages at once, and nothing
would have failed to say so.

Same edit as the English page, in the same order: iPhone first with the store
button, Android as in testing ahead of Google Play, and one licence line
covering both platforms. Each page keeps its own wording for pairing and for
the App Store, and its own dash, which is an en dash in Swedish and an em dash
everywhere else.

mobile-privacy.html needed it too. Fifteen copies still told readers the Android
app is a direct download from this site and that the iPhone edition is awaiting
release. Both had stopped being true. A privacy policy that misdescribes how the
software reaches people is worse than a marketing page that does.

The language picker is untouched: its markup and its inline style are
byte-identical to this branch's own copy in all thirty-two files, checked
against the ref rather than by eye. The first version of that check fed Windows
backslash paths to git show, which failed silently and compared every file
against nothing at all — it now fails loudly instead of reporting a green tick
over an empty fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sgf36
sgf36 merged commit a55c60b into main Aug 28, 2026
4 checks passed
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.

1 participant