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:

  1. Understand what we're shipping today and why each piece exists
  2. Find the per-site implementation notes for specifics
  3. Know what's already documented vs. what needs scope work
  4. 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:

  1. 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.
  2. 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-group divs) - Main CF: last <li> inside #site-navigation's <ul>, plus a second instance inline next to #mobile-menu-toggle for mobile breakpoints
  3. Provides the Google Translate scaffolding directly when the plugin's auto-loader doesn't fire. The plugin only enqueues dropdown.js (which defines window.doGTranslate, window.googleTranslateElementInit2, and the #google_translate_element2 div) when one of its widgets is rendered. Solano has WP menu items with the menu-item-gtranslate class, so the plugin auto-mounts; main CF has none, so the mu-plugin inlines the scaffolding verbatim from plugins/gtranslate/js/dropdown.js.
  4. Lazy-loads https://translate.google.com/translate_a/element.js on first hover/focus/click of the toggle.
  5. 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 in wp_head to warm the connection.)
  6. Suppresses the Google Translate top banner (.skiptranslate { display: none !important; } and body { top: 0 !important; }) so the page doesn't shift down by ~40px when Google's iframe injects.
  7. 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

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:

  1. 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.js via a GT_TRANSLATED cookie check at top-of-file. When true, both SplitText invocations fall back to a simple block fade. English path is untouched.
  2. 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 the googtrans cookie manually and calls location.reload(). On the next page load, gsap-animations.js sees 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:

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:


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:

  1. Pick priority pages and build a glossary together (place names, project terms, tone)
  2. AI does first-pass translation per page using the glossary
  3. Bilingual reviewer (CF-supplied, NorCal-familiar) opens each page in a side-by-side editor; edits, signs off, publishes
  4. 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."