diff --git a/AGENTS.md b/AGENTS.md
index 283fcbd..cbe107d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,7 +45,8 @@ Guidelines for AI coding agents working on the official Micro Club website.
- `src/actions/`: Server Actions (`"use server"`), in `admin/` (staff only, guarded by `requireStaff()`) or `public/` (site visitors, rate limited), kept thin: validate input, rate limit, then call a `server-only` module in `src/lib/` (e.g. `src/lib/microbot-api.ts`, `src/lib/cloudinary.ts`), the only place that reads its secrets from `process.env`. Never expose a secret with `NEXT_PUBLIC_`; document every variable in `.env.example`.
- `src/config/`: site metadata, section anchors, menu links, admin routes (`admin.ts`).
- `src/const/`: static content constants, one file per section named like the section file (e.g. `src/const/statistics.ts` exports `STATISTICS` and its types).
-- Error UI: `src/app/not-found.tsx` (global 404), `src/app/(site)/error.tsx` (error boundary with menu/footer), `src/app/global-error.tsx` (root layout failures, no global styles).
+- Error UI: `src/app/not-found.tsx` (global 404), `src/app/(site)/error.tsx` and `src/app/(registrations)/error.tsx` (error boundaries inside their layout, both rendering `ErrorFallback` from `src/components/shared/`), `src/app/global-error.tsx` (root layout failures, no global styles).
+- `src/instrumentation-client.ts` runs before the app is interactive: it installs `guardDomAgainstTranslation()` (`src/lib/translate-dom-guard.ts`), which keeps React from crashing on pages Chrome's Google Translate rewrote. Keep it; mark the applicant's own text `translate="no"` where it must stay as typed (the registration recap).
## Local services
diff --git a/src/app/(registrations)/error.tsx b/src/app/(registrations)/error.tsx
new file mode 100644
index 0000000..c256516
--- /dev/null
+++ b/src/app/(registrations)/error.tsx
@@ -0,0 +1,6 @@
+"use client"
+
+import { ErrorFallback } from "@/components/shared/error-fallback"
+
+// Without it a crash in the form reaches `global-error.tsx`, which replaces the whole root layout.
+export default ErrorFallback
diff --git a/src/app/(site)/error.tsx b/src/app/(site)/error.tsx
index 7ec6497..edd76b9 100644
--- a/src/app/(site)/error.tsx
+++ b/src/app/(site)/error.tsx
@@ -1,32 +1,5 @@
"use client"
-import { useEffect } from "react"
+import { ErrorFallback } from "@/components/shared/error-fallback"
-export default function Error({
- error,
- retry,
-}: {
- error: Error & { digest?: string }
- retry: () => void
-}) {
- useEffect(() => {
- // TODO: report to an error tracking service.
- console.error(error)
- }, [error])
-
- return (
-
-
Something went wrong
-
- An unexpected error occurred. Please try again.
-
+ An unexpected error occurred. Please try again.
+
+
+
+ )
+}
diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts
new file mode 100644
index 0000000..93c31ee
--- /dev/null
+++ b/src/instrumentation-client.ts
@@ -0,0 +1,4 @@
+import { guardDomAgainstTranslation } from "@/lib/translate-dom-guard"
+
+// Runs before the app becomes interactive, so React never touches the DOM unguarded.
+guardDomAgainstTranslation()
diff --git a/src/lib/translate-dom-guard.test.ts b/src/lib/translate-dom-guard.test.ts
new file mode 100644
index 0000000..c1e60fa
--- /dev/null
+++ b/src/lib/translate-dom-guard.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest"
+
+import { guardDomAgainstTranslation } from "@/lib/translate-dom-guard"
+
+type FakeNode = { name: string; parentNode: FakeNode | null }
+
+const node = (name: string, parentNode: FakeNode | null = null): FakeNode => ({ name, parentNode })
+
+/** A prototype whose native methods only record the calls they get, guarded. */
+function guardedProto() {
+ const calls: string[] = []
+ const proto = {
+ insertBefore(this: FakeNode, added: FakeNode, child: FakeNode | null) {
+ calls.push(`insert ${added.name} into ${this.name} before ${child?.name ?? "end"}`)
+ return added
+ },
+ removeChild(this: FakeNode, child: FakeNode) {
+ calls.push(`remove ${child.name} from ${this.name}`)
+ return child
+ },
+ }
+ guardDomAgainstTranslation(proto as unknown as Pick)
+ return { calls, proto }
+}
+
+describe("guardDomAgainstTranslation", () => {
+ it("leaves calls on nodes still in place unchanged", () => {
+ const { calls, proto } = guardedProto()
+ const button = node("button")
+ const label = node("label", button)
+ const spinner = node("spinner")
+
+ proto.insertBefore.call(button, spinner, label)
+ proto.insertBefore.call(button, spinner, null)
+ proto.removeChild.call(button, label)
+
+ expect(calls).toEqual([
+ "insert spinner into button before label",
+ "insert spinner into button before end",
+ "remove label from button",
+ ])
+ })
+
+ it("inserts before the wrapper that now holds the reference node", () => {
+ const { calls, proto } = guardedProto()
+ const button = node("button")
+ const font = node("font", button)
+ const label = node("label", node("inner font", font))
+
+ proto.insertBefore.call(button, node("spinner"), label)
+
+ expect(calls).toEqual(["insert spinner into button before font"])
+ })
+
+ it("appends when the reference node left the page (replaced by the translation)", () => {
+ const { calls, proto } = guardedProto()
+
+ proto.insertBefore.call(node("button"), node("spinner"), node("label"))
+
+ expect(calls).toEqual(["insert spinner into button before end"])
+ })
+
+ it("removes the wrapper that now holds the node", () => {
+ const { calls, proto } = guardedProto()
+ const dd = node("dd")
+ const font = node("font", dd)
+ const text = node("text", font)
+
+ expect(proto.removeChild.call(dd, text)).toBe(text)
+ expect(calls).toEqual(["remove font from dd"])
+ })
+
+ it("does nothing when the node to remove already left the page", () => {
+ const { calls, proto } = guardedProto()
+ const text = node("text")
+
+ expect(proto.removeChild.call(node("dd"), text)).toBe(text)
+ expect(calls).toEqual([])
+ })
+
+ it("guards a prototype only once", () => {
+ const { calls, proto } = guardedProto()
+ guardDomAgainstTranslation(proto as unknown as Pick)
+
+ proto.insertBefore.call(node("button"), node("spinner"), node("label"))
+
+ expect(calls).toEqual(["insert spinner into button before end"])
+ })
+})
diff --git a/src/lib/translate-dom-guard.ts b/src/lib/translate-dom-guard.ts
new file mode 100644
index 0000000..25962a2
--- /dev/null
+++ b/src/lib/translate-dom-guard.ts
@@ -0,0 +1,42 @@
+type GuardedMethods = Pick
+
+const GUARDED = Symbol.for("mc.translate-dom-guard")
+
+/** The direct child of `parent` that holds `node` (`node` itself or an ancestor), or `null`. */
+function childHolding(parent: Node, node: Node): Node | null {
+ let current: Node | null = node
+ while (current && current.parentNode !== parent) current = current.parentNode
+ return current
+}
+
+/**
+ * Keeps React from crashing on pages rewritten by a browser translator (Chrome's Google Translate).
+ * The translator replaces text nodes with its own `` elements, so React later inserts before,
+ * or removes, a node that is no longer where it left it, and the DOM throws `NotFoundError`
+ * (facebook/react#11538). The page then falls to the error boundary: on the registration form,
+ * right after SUBMIT, while the request still goes through.
+ *
+ * Instead of throwing, both calls fall back on the closest correct move: before (or remove) the
+ * child of the parent that still holds the node, else append (or do nothing) when the node left
+ * the page. Calls on nodes still in place run unchanged, so pages that are not translated never
+ * reach the fallback. A text React updates after the rewrite can stay in its translated form.
+ */
+export function guardDomAgainstTranslation(proto: GuardedMethods = Node.prototype) {
+ const target = proto as GuardedMethods & { [GUARDED]?: true }
+ if (target[GUARDED]) return
+ target[GUARDED] = true
+
+ const { insertBefore, removeChild } = proto
+
+ proto.insertBefore = function (this: Node, node: T, child: Node | null): T {
+ if (!child || child.parentNode === this) return insertBefore.call(this, node, child) as T
+ return insertBefore.call(this, node, childHolding(this, child)) as T
+ }
+
+ proto.removeChild = function (this: Node, child: T): T {
+ if (child.parentNode === this) return removeChild.call(this, child) as T
+ const holder = childHolding(this, child)
+ if (holder) removeChild.call(this, holder)
+ return child
+ }
+}