diff --git a/src/components/water/LanguageToggle.tsx b/src/components/water/LanguageToggle.tsx
new file mode 100644
index 0000000..41271b9
--- /dev/null
+++ b/src/components/water/LanguageToggle.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+/**
+ * LanguageToggle — EN | ES segmented pill.
+ *
+ * A compact two-button control that switches the active language.
+ * Used in both the mobile app (header of the headgate list) and
+ * the admin client (settings page).
+ *
+ * The pill is intentionally narrow so it can fit in a sticky nav bar
+ * next to the logout icon. The active button has the brand-green
+ * background; the inactive is a subtle pill outline.
+ */
+
+import { useLang } from "@/lib/water-log/lang-context";
+
+type Props = {
+ /** Visual size. `sm` fits inside a 44pt iOS-style nav bar. */
+ size?: "sm" | "md";
+};
+
+export function LanguageToggle({ size = "sm" }: Props) {
+ const { lang, setLang } = useLang();
+
+ const isSmall = size === "sm";
+ const containerStyle: React.CSSProperties = {
+ display: "inline-flex",
+ alignItems: "stretch",
+ borderRadius: 999,
+ background: "rgba(120,120,128,0.14)",
+ padding: 2,
+ height: isSmall ? 28 : 32,
+ };
+
+ const baseButton: React.CSSProperties = {
+ appearance: "none",
+ border: 0,
+ borderRadius: 999,
+ padding: isSmall ? "0 9px" : "0 12px",
+ fontSize: isSmall ? 12 : 13,
+ fontWeight: 700,
+ letterSpacing: "0.06em",
+ cursor: "pointer",
+ transition: "background-color 160ms, color 160ms",
+ minWidth: isSmall ? 30 : 36,
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ };
+
+ const activeStyle: React.CSSProperties = {
+ ...baseButton,
+ background: "#14532d",
+ color: "#ffffff",
+ boxShadow: "0 1px 2px rgba(20,83,45,0.20)",
+ };
+
+ const inactiveStyle: React.CSSProperties = {
+ ...baseButton,
+ background: "transparent",
+ color: "rgba(60,60,67,0.65)",
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+export default LanguageToggle;
\ No newline at end of file
diff --git a/src/lib/water-log/i18n.ts b/src/lib/water-log/i18n.ts
new file mode 100644
index 0000000..e7778ba
--- /dev/null
+++ b/src/lib/water-log/i18n.ts
@@ -0,0 +1,319 @@
+/**
+ * Water Log — internationalization (i18n).
+ *
+ * Single source of truth for the user-facing strings in the mobile
+ * (`/water`) and admin (`/water/admin`) portals. Both portals share
+ * the same `wl_lang` cookie and the same audience (Tuxedo field
+ * workers + ditch supervisors, mostly Spanish-first).
+ *
+ * **Why this lives in `lib/` rather than each component:**
+ *
+ * - The two portals used to have divergent i18n implementations:
+ * `WaterAdminClient.tsx` had a full `LABELS` dict; the mobile
+ * screens had no i18n at all. Lifting the labels here lets the
+ * admin client drop its copy and lets the mobile app adopt
+ * Spanish without re-inventing the dictionary.
+ * - A single source means when we add a string to one screen we
+ * notice the other and add it there too (or at least see that
+ * it's missing).
+ * - The labels are pure data — no React, no `next/headers`, no
+ * `cookies()` — so they're safe to import from client
+ * components, server components, and unit tests alike.
+ *
+ * Persistence: the `wl_lang` cookie. See `./session-server.ts`
+ * (currently `setWaterLang` in `actions/water-log/field.ts` sets it
+ * via a thin wrapper; we keep that wrapper for back-compat).
+ *
+ * Default: Spanish — the deployed audience is predominantly
+ * Spanish-speaking. Override with the cookie or with
+ * `detectInitialLang()`.
+ */
+
+export const WL_LANG_COOKIE = "wl_lang";
+
+export type Lang = "en" | "es";
+
+/**
+ * The full label dictionary. Organized by screen so the writer can
+ * find the right string quickly and so we can see at a glance which
+ * screens are missing translations.
+ */
+export type Labels = {
+ /** Strings shared by multiple screens. */
+ common: {
+ cancel: string;
+ save: string;
+ saving: string;
+ back: string;
+ };
+ /** `/water` PIN entry screen. */
+ pin: {
+ title: string;
+ caption: string;
+ verifying: string;
+ unlock: string;
+ forgot: string;
+ genericError: string;
+ };
+ /** `/water` headgate list screen. */
+ headgates: {
+ title: string;
+ signedInAs: (name: string) => string;
+ activeSection: string;
+ emptyTitle: string;
+ emptyBody: string;
+ pullToRefresh: string;
+ thresholdHigh: string;
+ thresholdLow: string;
+ };
+ /** `/water` measurement entry screen. */
+ log: {
+ title: string;
+ measurement: string;
+ timestamp: string;
+ loggedNow: string;
+ notes: string;
+ notesPlaceholder: string;
+ notesVisibility: string;
+ measurementError: string;
+ implausiblyLarge: string;
+ submit: string;
+ submitting: string;
+ normalRange: (low: number, high: number, unit: string) => string;
+ alertAbove: (high: number, unit: string) => string;
+ alertBelow: (low: number, unit: string) => string;
+ };
+ /** `/water` success screen after submit. */
+ success: {
+ title: string;
+ logAnother: string;
+ backToHeadgates: string;
+ thanks: string;
+ loggedBy: (name: string) => string;
+ };
+ /** Headgate status string shown in card subtitles. */
+ status: {
+ open: string;
+ openReady: string;
+ closed: string;
+ maintenance: string;
+ };
+ /** Unit labels. The keys are the canonical English codes stored in
+ * the DB. Values are the display strings per language. */
+ units: {
+ CFS: string;
+ GPM: string;
+ gal: string;
+ "ac-in": string;
+ "ac-ft": string;
+ holes: string;
+ };
+ /** Short relative-time phrases for the headgate card meta row. */
+ format: {
+ neverLogged: string;
+ justNow: string;
+ minutesAgo: (n: number) => string;
+ hoursAgo: (n: number) => string;
+ };
+};
+
+export const LABELS: Record = {
+ en: {
+ common: {
+ cancel: "Cancel",
+ save: "Save",
+ saving: "Saving…",
+ back: "Back",
+ },
+ pin: {
+ title: "Enter your PIN",
+ caption: "Sign in to log a water reading",
+ verifying: "Verifying…",
+ unlock: "Unlock",
+ forgot: "Forgot your PIN? Ask the ditch supervisor.",
+ genericError: "Something went wrong. Try again.",
+ },
+ headgates: {
+ title: "Headgates",
+ signedInAs: (name) => `Signed in as ${name}`,
+ activeSection: "Active headgates",
+ emptyTitle: "No active headgates",
+ emptyBody:
+ "Once your supervisor activates a headgate, it'll show up here. Pull down to check for updates.",
+ pullToRefresh: "Pull down to refresh the list",
+ thresholdHigh: "Hi",
+ thresholdLow: "Lo",
+ },
+ log: {
+ title: "Log Reading",
+ measurement: "Measurement",
+ timestamp: "Timestamp",
+ loggedNow: "Logged now",
+ notes: "Notes",
+ notesPlaceholder:
+ "Anything the supervisor should know about this reading?",
+ notesVisibility: "Visible to admins only",
+ measurementError:
+ "Enter a measurement greater than or equal to 0",
+ implausiblyLarge: "That value is implausibly large",
+ submit: "Submit Log",
+ submitting: "Submitting…",
+ normalRange: (low, high, unit) =>
+ `Normal range: ${low} – ${high} ${unit}`,
+ alertAbove: (high, unit) => `Alert above ${high} ${unit}`,
+ alertBelow: (low, unit) => `Alert below ${low} ${unit}`,
+ },
+ success: {
+ title: "Reading logged",
+ logAnother: "Log Another Entry",
+ backToHeadgates: "Back to Headgates",
+ thanks: "Thanks for keeping the records flowing.",
+ loggedBy: (name) => `Logged by ${name}`,
+ },
+ status: {
+ open: "Open",
+ openReady: "Open · Ready to log",
+ closed: "Closed",
+ maintenance: "Under maintenance",
+ },
+ units: {
+ CFS: "CFS",
+ GPM: "GPM",
+ gal: "gal",
+ "ac-in": "ac-in",
+ "ac-ft": "ac-ft",
+ holes: "holes",
+ },
+ format: {
+ neverLogged: "Never logged",
+ justNow: "Just now",
+ minutesAgo: (n) => `${n}m ago`,
+ hoursAgo: (n) => `${n}h ago`,
+ },
+ },
+ es: {
+ common: {
+ cancel: "Cancelar",
+ save: "Guardar",
+ saving: "Guardando…",
+ back: "Atrás",
+ },
+ pin: {
+ title: "Ingresa tu PIN",
+ caption: "Inicia sesión para registrar una lectura de agua",
+ verifying: "Verificando…",
+ unlock: "Desbloquear",
+ forgot: "¿Olvidaste tu PIN? Pregúntale al supervisor.",
+ genericError: "Algo salió mal. Inténtalo de nuevo.",
+ },
+ headgates: {
+ title: "Compuertas",
+ signedInAs: (name) => `Sesión iniciada como ${name}`,
+ activeSection: "Compuertas activas",
+ emptyTitle: "No hay compuertas activas",
+ emptyBody:
+ "Cuando tu supervisor active una compuerta, aparecerá aquí. Jala hacia abajo para actualizar.",
+ pullToRefresh: "Jala hacia abajo para actualizar la lista",
+ thresholdHigh: "Alto",
+ thresholdLow: "Bajo",
+ },
+ log: {
+ title: "Registrar lectura",
+ measurement: "Medición",
+ timestamp: "Hora",
+ loggedNow: "Registrando ahora",
+ notes: "Notas",
+ notesPlaceholder:
+ "¿Algo que el supervisor deba saber sobre esta lectura?",
+ notesVisibility: "Visible solo para administradores",
+ measurementError:
+ "Ingresa una medición mayor o igual a 0",
+ implausiblyLarge: "Ese valor es demasiado grande",
+ submit: "Enviar registro",
+ submitting: "Enviando…",
+ normalRange: (low, high, unit) =>
+ `Rango normal: ${low} – ${high} ${unit}`,
+ alertAbove: (high, unit) => `Alerta arriba de ${high} ${unit}`,
+ alertBelow: (low, unit) => `Alerta abajo de ${low} ${unit}`,
+ },
+ success: {
+ title: "Lectura registrada",
+ logAnother: "Registrar otra",
+ backToHeadgates: "Volver a compuertas",
+ thanks: "Gracias por mantener los registros al día.",
+ loggedBy: (name) => `Registrado por ${name}`,
+ },
+ status: {
+ open: "Abierta",
+ openReady: "Abierta · Lista para registrar",
+ closed: "Cerrada",
+ maintenance: "En mantenimiento",
+ },
+ units: {
+ CFS: "CFS",
+ GPM: "GPM",
+ gal: "gal",
+ "ac-in": "ac-in",
+ "ac-ft": "ac-ft",
+ holes: "hoyos",
+ },
+ format: {
+ neverLogged: "Nunca registrada",
+ justNow: "Ahora",
+ minutesAgo: (n) => `hace ${n}m`,
+ hoursAgo: (n) => `hace ${n}h`,
+ },
+ },
+};
+
+/**
+ * Detect the initial language for a freshly-hydrated client.
+ *
+ * Order of preference:
+ * 1. The `wl_lang` cookie (the user's last explicit choice).
+ * 2. `navigator.language` (with weak "starts with" matching).
+ * 3. Spanish (the deployed audience is predominantly Spanish-speaking).
+ *
+ * Safe to call during SSR — when `document` is undefined, returns the
+ * default (`es`). The component calling this should pair the read
+ * with a `useEffect` to re-detect on the client if needed.
+ */
+export function detectInitialLang(): Lang {
+ if (typeof document === "undefined") return "es";
+ const cookie = document.cookie.match(/wl_lang=(en|es)/)?.[1];
+ if (cookie === "en" || cookie === "es") return cookie;
+ if (typeof navigator !== "undefined") {
+ const lang = navigator.language?.toLowerCase() ?? "";
+ if (lang.startsWith("en")) return "en";
+ }
+ return "es";
+}
+
+/** Persist the user's language choice. Client-side only. */
+export function setLangCookie(lang: Lang): void {
+ if (typeof document === "undefined") return;
+ // 1 year max-age — long enough that returning users see their
+ // preferred language without re-prompting.
+ document.cookie = `${WL_LANG_COOKIE}=${lang}; path=/; max-age=31536000; SameSite=Lax`;
+}
+
+/** Get the labels dictionary for a given lang. */
+export function getT(lang: Lang): Labels {
+ return LABELS[lang];
+}
+
+/**
+ * Display string for a headgate's stored `unit` value, localized.
+ * Falls back to the raw unit string when not in the catalog (so we
+ * don't break on legacy or admin-typed custom units).
+ */
+export function displayUnit(unit: string, lang: Lang): string {
+ const t = LABELS[lang].units;
+ if (unit === "CFS") return t.CFS;
+ if (unit === "GPM") return t.GPM;
+ if (unit === "gal") return t.gal;
+ if (unit === "ac-in") return t["ac-in"];
+ if (unit === "ac-ft") return t["ac-ft"];
+ if (unit === "holes") return t.holes;
+ return unit;
+}
\ No newline at end of file
diff --git a/src/lib/water-log/lang-context.tsx b/src/lib/water-log/lang-context.tsx
new file mode 100644
index 0000000..582f597
--- /dev/null
+++ b/src/lib/water-log/lang-context.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+/**
+ * Water Log — React context for the current language.
+ *
+ * Wraps a `LangProvider` around a tree of components that need to
+ * read or change the active language. The provider keeps `lang` in
+ * React state, persists every change to the `wl_lang` cookie, and
+ * exposes a `setLang` callback.
+ *
+ * The provider does NOT call `detectInitialLang()` itself — callers
+ * pass `initialLang` in (typically computed in a `useState`
+ * initializer so SSR and the first client render agree).
+ */
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ type Lang,
+ type Labels,
+ getT,
+ setLangCookie,
+} from "./i18n";
+
+type LangContextValue = {
+ lang: Lang;
+ /** Pre-resolved label dictionary for the current lang. */
+ t: Labels;
+ setLang: (lang: Lang) => void;
+ toggleLang: () => void;
+};
+
+const LangContext = createContext(null);
+
+export function LangProvider({
+ initialLang,
+ children,
+}: {
+ initialLang: Lang;
+ children: ReactNode;
+}) {
+ const [lang, setLangState] = useState(initialLang);
+
+ const setLang = useCallback((next: Lang) => {
+ setLangState(next);
+ setLangCookie(next);
+ }, []);
+
+ const toggleLang = useCallback(() => {
+ setLangState((prev) => {
+ const next: Lang = prev === "en" ? "es" : "en";
+ setLangCookie(next);
+ return next;
+ });
+ }, []);
+
+ const value = useMemo(
+ () => ({ lang, t: getT(lang), setLang, toggleLang }),
+ [lang, setLang, toggleLang],
+ );
+
+ return (
+ {children}
+ );
+}
+
+/**
+ * Read the current language + labels from context. Throws if used
+ * outside a `` — that's almost certainly a bug.
+ */
+export function useLang(): LangContextValue {
+ const ctx = useContext(LangContext);
+ if (!ctx) {
+ throw new Error(
+ "useLang() must be called inside . " +
+ "Wrap your tree (e.g. MobileWaterApp) in .",
+ );
+ }
+ return ctx;
+}
\ No newline at end of file