Add Microsoft Clarity; give Google a favicon and a site name - #18
Merged
Conversation
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>
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 insideenableAnalytics()so it sits behind the same consent as GA; nothing reaches Microsoft on a decline. Empty orPLACEHOLDERdisables 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.ico(six resolutions to 256),icon-192.png,apple-touch-icon.png.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 withUnicodeDecodeError— 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.
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, twocollectcalls.Generated with Claude Code