Spanish Translation — Architecture & Implementation Overview
Scope: californiaforever.com + solano.californiaforever.com Status: Track A live on both sites (Solano local + main CF prod). Track B parked pending Anders. Last updated: 2026-05-07
TL;DR
Both California Forever marketing sites use Google Translate (the GTranslate WordPress plugin), customized. The plugin is the translation engine; we override its UI, mount points, lifecycle, and animation interactions with a per-site mu-plugin (gtranslate-nav-placement.php). Visitors see a single-flag dropdown ("currently English ▾", click → choose Spanish); language change reloads the page so static letter-split animations don't fight Google Translate's DOM rewrites.
This is the stop-gap "Track A" in the broader Spanish translation plan. The longer-term Track B (custom AI workflow with bilingual reviewer sign-off, glossary, drift detection on English changes) is parked pending the client's decision and reviewer staffing. See main-site/Retainer/2026-05-06-spanish-translation-scope.md for the full Track A vs. B scoping doc and the email thread with Anders that drove the decision.
Why this doc exists
Google Translate "just works" out of the box for most sites. Both CF sites needed several non-obvious customizations to coexist with their themes (especially main CF's GSAP letter-by-letter hero animations and ACF-driven hardcoded URLs in the DB). The customizations are subtle, span both code and content, and are easy to break with later edits. This doc is the single overview so a future agent (or human) can:
- Understand what we're shipping today and why each piece exists
- Find the per-site implementation notes for specifics
- Know what's already documented vs. what needs scope work
- See where Track B picks up if/when Anders re-engages
The two tracks (recap)
| Track | What it is | Status | Owns |
|---|---|---|---|
| A | GTranslate plugin + custom mu-plugin overlay | ✅ Live on prod for main CF, ⏳ local-only on Solano | Currently the only Spanish surface visitors see |
| B | Custom AI translation workflow with bilingual reviewer | ⏸ Parked pending Anders | Future replacement for Track A on priority pages |
Anders chose Track A "for now" on May 5, 2026 ("can you make the translate toggle larger and more obvious? I'll get back to you on the more intensive one soon"). Track B is real work that will replace Track A on priority pages once Anders re-engages and we have a bilingual NorCal-familiar reviewer staffed. Track B requirements, workflow, and reviewer expectations are written up in the scope doc above (sections "Track B — parked" and "Open questions"). Don't re-derive any of that.
Architecture (Track A)
What ships per site
| Layer | File | Tracked? |
|---|---|---|
| GTranslate plugin (free, latest stable) | wp-content/plugins/gtranslate/ |
No (host-managed) |
| Custom mu-plugin overlay | wp-content/mu-plugins/gtranslate-nav-placement.php |
No (mu-plugins/ is host-managed) — must be SCP'd to prod manually |
| GSAP SplitText guard (main CF only) | wp-content/themes/california-forever/js/gsap-animations.js |
Yes (theme repo) |
Customizations — what the mu-plugin does
The plugin alone gives you a dropdown widget you can drop into a sidebar / nav menu. We do not use that widget. Instead, the mu-plugin:
- Hides the plugin's default widgets. CSS
body.gt-nav-mounted li.menu-item-gtranslate { display: none !important; }. Solano has these menu items in legacy nav menus (which is what triggers the plugin's JS to load); main CF doesn't. - Server-side injects our own toggle markup into the page response via
template_redirect+ob_start. This puts the toggle in the initial paint so there's no layout shift when JS hydrates. Different sites use different injection anchors: - Solano: end of#grouped-nav(sibling to.nav-groupdivs) - Main CF: last<li>inside#site-navigation's<ul>, plus a second instance inline next to#mobile-menu-togglefor mobile breakpoints - Provides the Google Translate scaffolding directly when the plugin's auto-loader doesn't fire. The plugin only enqueues
dropdown.js(which defineswindow.doGTranslate,window.googleTranslateElementInit2, and the#google_translate_element2div) when one of its widgets is rendered. Solano has WP menu items with themenu-item-gtranslateclass, so the plugin auto-mounts; main CF has none, so the mu-plugin inlines the scaffolding verbatim fromplugins/gtranslate/js/dropdown.js. - Lazy-loads
https://translate.google.com/translate_a/element.json first hover/focus/click of the toggle. - Auto-loads the same script at script-execution time when the cookie says non-English, so a returning Spanish user doesn't see an English flash on every page navigation. (Pairs with
<link rel="preconnect">hints inwp_headto warm the connection.) - Suppresses the Google Translate top banner (
.skiptranslate { display: none !important; }andbody { top: 0 !important; }) so the page doesn't shift down by ~40px when Google's iframe injects. - Reloads on language change rather than calling
doGTranslate('en|es')live. This is the most subtle customization — see "The SplitText problem" below.
Hidden-from-plugin → visible-to-user state machine
- Toggle UI: rendered by us, not the plugin
- Language state:
googtrans=/en/<lang>cookie, written by the plugin's JS when translation completes (via the iframe combo's change event) - Visual active state: mu-plugin's hydration JS reads the cookie on load and updates the toggle's flag/highlight to match
- Translation execution: delegated to the plugin's
doGTranslate()function (which we either inline or rely on the plugin to provide)
The user sees a single coherent "click flag, page is now Spanish" experience. Under the hood it's three separate pieces talking via cookies and a hidden <select>.
The SplitText problem (and why we reload on toggle)
The main CF theme uses GSAP SplitText to wrap each character of hero <h1> text in its own <div> for a character-by-character reveal animation. When Google Translate processes that DOM, it treats each per-letter <div> as an independent translation unit and produces gibberish — "Building" becomes "Btúildinortegramo" because "n" translates to "norte" (Spanish for "north"), "g" to "gramo" (gram), etc.
There are two ways to fix this:
- Skip the SplitText on non-English page loads so the H1 stays a single text node — Google Translate translates it cleanly. Implemented in
js/gsap-animations.jsvia aGT_TRANSLATEDcookie check at top-of-file. When true, both SplitText invocations fall back to a simple block fade. English path is untouched. - Reload after toggling language so step 1's check actually fires under the new state. The mu-plugin's click handler doesn't call
doGTranslate('en|es')live — it sets thegoogtranscookie manually and callslocation.reload(). On the next page load,gsap-animations.jssees the cookie, skips the SplitText, and the page renders cleanly translated.
Trade-off: a full page reload on every language toggle (instead of a live in-place swap). For a site with character-split hero animations, this is the right call. Solano doesn't have those animations, so the reload-on-toggle approach is preserved there only for consistency — could revert to live doGTranslate() if needed.
DB-stored URL leak (the "Brooke bug" again)
This bit doesn't affect public production, but it bit us when we tried to share the local main CF preview through a cloudflared tunnel. DB-stored hardcoded URLs leak past WP_HOME. Yoast SEO schema/meta, ACF link fields, ACF news banner, ACF nav menu items — all of these store the literal site URL at the moment the field was saved. Even with WP_HOME and home_url() correctly rewritten to the public host, the leaked URLs ship as-is.
For production this is a non-issue (the literal stored URL IS the production URL). For tunneled dev previews and any future migration, we have a workaround mu-plugin (sites-dashboard-tunnel-content-rewrite.php) that output-buffers the response and swaps the literal *.local / localhost:NNNN host strings for the public tunnel hostname. Filed as a long-term improvement to the dashboard's canonical mu-plugin (Todoist Inbox, 2026-05-07: "Roll tunnel content-rewrite into the dashboard's canonical mu-plugin").
Per-site implementation notes
For implementation specifics — exact selectors, CSS treatments, mobile placement, deploy workflow, gotchas — read the per-site notes:
- Solano —
solano-site/2026-05-07-spanish-toggle-implementation.md. Two-flag layout (legacy treatment, currently live locally only — pending visual update to dropdown to match main CF behavior). - Main CF —
main-site/Retainer/2026-05-07-spanish-toggle-implementation-main-cf.md. Single-flag dropdown, frosted-glass treatment matching the hero callout, deployed to production 2026-05-07.
These notes capture the specific decisions for each property: insertion anchors, color treatments, mobile placement, the Spanish-mode CSS adjustments (nav gap tightening, CTA truncation), and the production push workflow.
Maintenance & gotchas
A short list of things that will break this if you don't know about them:
- The mu-plugin is NOT in git. It lives in
wp-content/mu-plugins/, which is host-managed (Flywheel ignores this directory in its rsync). Local edits don't auto-deploy. Updates need an explicit SCP-equivalent (cat | ssh ... "cat > ..."since Flywheel SSH disables sftp/scp subsystem). - The GSAP guard IS in git (
js/gsap-animations.jsin the main CF theme). When pushing the theme, push that file too. If you split commits, don't forget the guard or English visitors won't see the SplitText animation either (it'll still work — just the wrong fallback path) and Spanish visitors will get the gibberish bug. output_bufferingnesting. The mu-plugin usesob_start()with a callback. Any code inside that callback that itself usesob_start()will fatal with"Cannot use output buffering in output buffering display handlers". The mu-plugin pre-renders its toggle markup BEFORE the outerob_startfor this reason.preg_replacebackreference interpolation. When usingpreg_replace($pattern, $replacement, $subject)with rendered HTML in$replacement, any$1or\\0in that HTML gets interpreted as a backreference and corrupts the output. The mu-plugin usespreg_replace_callbackfor this reason.- Stacking contexts. Theme
<li>elements often havetransform: translate(0,0)from hover-effect CSS, which creates a new stacking context. Az-index: 9999on the dropdown panel inside one of those<li>s is ineffective against header borders / lines outside the<li>. Fix: explicitz-indexon the<li>itself. (See main CF implementation note for the specific instance.) - Tailwind preflight + WP emoji. WP replaces flag emoji with
<img class="emoji">, sized by default atwidth: 1emvia WP's CSS. Tailwind's preflight addsimg { max-width: 100%; }, which inside a zero-widthinline-flexparent collapses the emoji to 0 width. Fix: explicitmax-width: none !importantonimg.emojiinside the toggle. (See main CF note.) - CTA overflow in Spanish. Some English CTAs translate to significantly longer Spanish strings ("Join the call to break ground" → "Únete al llamado para comenzar la construcción", roughly 60% longer). The mu-plugin adds Spanish-mode CSS that tightens nav
gap, shrinks CTA padding, and appliestext-overflow: ellipsisso the button truncates rather than overflowing. The ellipsis only works when the button display is forced frominline-flextoinline-block—text-overflowdoesn't apply to flex containers. - Cache layers in front of prod. Flywheel runs both server-side FlyCache and a Fastly edge CDN. After deploying mu-plugin / theme / option changes, the edge cache will continue to serve the old HTML to non-bot user agents until purged. WP-CLI handles object cache + transients + WP-Optimize (via
WPO_Page_Cache::instance()->purge()); the Fastly edge needs a separate purge via the Flywheel hosting dashboard (Site → Advanced → Flush Cache). Without that step, normal browser visitors see stale HTML even thoughcurl(which Fastly treats as a bot) sees fresh content.
Track B — what's parked and where
Track B is the custom AI translation workflow Eric proposed to Anders on May 4, 2026. Anders deferred it on May 5 ("I'll get back to you on the more intensive one soon"). Full proposal (the email body) and the open questions blocking it live in main-site/Retainer/2026-05-06-spanish-translation-scope.md.
The high-level shape Track B will take when Anders re-engages:
- Pick priority pages and build a glossary together (place names, project terms, tone)
- AI does first-pass translation per page using the glossary
- Bilingual reviewer (CF-supplied, NorCal-familiar) opens each page in a side-by-side editor; edits, signs off, publishes
- Drift detection: when an English page changes, the system flags the Spanish version for review
Architecturally Track B replaces Track A on the priority pages but co-exists for everything else. Until Anders signals intent, no build work — just keep Track A maintained.
Status surface: parent WAITING-ON-CLIENT.md tracks Track B as "blocked-needs-decision."
Related work / index
- Scope + email thread + open questions:
main-site/Retainer/2026-05-06-spanish-translation-scope.md - Solano implementation note (current):
solano-site/2026-05-07-spanish-toggle-implementation.md - Main CF implementation note (current):
main-site/Retainer/2026-05-07-spanish-toggle-implementation-main-cf.md - Client status:
WAITING-ON-CLIENT.md - Todoist project: "California Forever — Spanish Translation" (
6gXVf5mM6PgM8WJv)