From 97ae7cdf04d5851d027c4212381d03e29af399ea Mon Sep 17 00:00:00 2001 From: MahmoudHassan77 Date: Wed, 5 Aug 2026 00:55:26 +0300 Subject: [PATCH] feat: add multi-language support (Arabic, German, French, Italian) Adds a client-side i18n layer with a top-bar language switcher (choice persisted to localStorage; kept in sync). English remains the default and the fallback source. Every user-facing string is translated across all five locales: UI chrome (nav, buttons, modals, viewer tools, aria-labels, image alt text) and the full organ dataset (names, systems, descriptions, key facts, medical notes, conditions, and all 3D hotspot labels/details). Latin scientific names are kept universal. Arabic renders fully right-to-left: CSS physical properties converted to logical equivalents so the layout mirrors, an Arabic-capable font stack applies under dir=rtl, directional arrows flip, and an inline bootstrap script applies the saved locale before first paint to avoid an LTR flash. New: app/lib/i18n/{config,ui,organs,context} and LanguageSwitcher. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/components/AnatomyApp.tsx | 176 ++-- app/components/LanguageSwitcher.tsx | 67 ++ app/components/OrganViewer.tsx | 34 +- app/globals.css | 122 ++- app/layout.tsx | 12 +- app/lib/i18n/config.ts | 41 + app/lib/i18n/context.tsx | 135 +++ app/lib/i18n/organs.ts | 1230 +++++++++++++++++++++++++++ app/lib/i18n/ui.ts | 645 ++++++++++++++ app/page.tsx | 7 +- 10 files changed, 2345 insertions(+), 124 deletions(-) create mode 100644 app/components/LanguageSwitcher.tsx create mode 100644 app/lib/i18n/config.ts create mode 100644 app/lib/i18n/context.tsx create mode 100644 app/lib/i18n/organs.ts create mode 100644 app/lib/i18n/ui.ts diff --git a/app/components/AnatomyApp.tsx b/app/components/AnatomyApp.tsx index a133e79..e673f96 100644 --- a/app/components/AnatomyApp.tsx +++ b/app/components/AnatomyApp.tsx @@ -23,10 +23,22 @@ import { X, } from "lucide-react"; import { OrganViewer } from "./OrganViewer"; -import { organById, organs, type Organ, type OrganId } from "../lib/anatomy-data"; +import { LanguageSwitcher } from "./LanguageSwitcher"; +import { useI18n } from "../lib/i18n/context"; +import { type Organ, type OrganId } from "../lib/anatomy-data"; type Modal = "lesson" | "quiz" | "animation" | "system" | null; +/** Renders `text` with `\n` turned into line breaks. */ +function multiline(text: string) { + return text.split("\n").map((line, index) => ( + + {index > 0 ?
: null} + {line} +
+ )); +} + /** * Renders an organ illustration, or its accent glyph for organs that ship as a * 3D model without the painted asset set. Keeps every image slot filled instead @@ -67,6 +79,7 @@ function OrganArt({ } export function AnatomyApp() { + const { t, dict, organs, organById } = useI18n(); const [organId, setOrganId] = useState("heart"); const [autoRotate, setAutoRotate] = useState(true); const [compare, setCompare] = useState(false); @@ -79,7 +92,7 @@ export function AnatomyApp() { const reference = organById[organId === "heart" ? "brain" : "heart"]; const filteredOrgans = useMemo( () => organs.filter((item) => `${item.name} ${item.system}`.toLowerCase().includes(query.toLowerCase())), - [query], + [query, organs], ); useEffect(() => { @@ -113,31 +126,34 @@ export function AnatomyApp() { return (
- -
@@ -175,96 +191,96 @@ export function AnatomyApp() { />
{compare && ( -
-
Comparing{organ.name}{organ.system}
- vs. -
Reference{reference.name}{reference.system}
-
Primary role
{organ.function}
Scale
{organ.size}
- +
+
{dict.compareComparing}{organ.name}{organ.system}
+ {dict.compareVs} +
{dict.compareReference}{reference.name}{reference.system}
+
{dict.comparePrimaryRole}
{organ.function}
{dict.compareScale}
{organ.size}
+
)} -
+
-

Learning is
an act of curiosity.

Keep exploring! +

{multiline(dict.curiosityQuote)}

{dict.curiosityCta}
-
Microscopic view

{organ.tissue}

-
- +
{dict.cardMicroTitle}

{organ.tissue}

+
+
-
Compare organs

{organ.comparison}

-
- +
{dict.cardCompareTitle}

{organ.comparison}

+
+
-
Function animation

{organ.function}

+
{dict.cardFuncTitle}

{organ.function}

{/* The artwork itself is the control, so the play badge inside it is decorative rather than a nested button. */} - +
-
Clinical notes

Common conditions

+
{dict.cardClinicalTitle}

{dict.cardClinicalHeading}

    {organ.conditions.map((condition) =>
  • {condition}
  • )}
- +
-
Where it works

{organ.system}

+
{dict.cardSystemTitle}

{organ.system}

- +
{modal && setModal(null)} />} - {mobileLibrary &&
); } @@ -277,15 +293,13 @@ const MODAL_ICON: Record, string> = { }; function LearningModal({ type, organ, onClose }: { type: Exclude; organ: Organ; onClose: () => void }) { - const organName = organ.name; + const { t, dict } = useI18n(); + const name = organ.name; const title = - type === "quiz" ? `${organName} quick quiz` - : type === "animation" ? `${organName} in motion` - // Avoids gluing onto `system`, whose wording varies per organ - // ("Cardiovascular" vs "Nervous System"), and stays grammatical for the - // plural organs too. - : type === "system" ? `${organName} in the body` - : `Inside the ${organName.toLowerCase()}`; + type === "quiz" ? t("modalQuizTitle", { name }) + : type === "animation" ? t("modalAnimationTitle", { name }) + : type === "system" ? t("modalSystemTitle", { name }) + : t("modalLessonTitle", { name }); return (
; o aria-labelledby="modal-title" onMouseDown={(event) => event.stopPropagation()} > - + {MODAL_ICON[type]} - Guided discovery + {dict.modalGuided} {type === "quiz" ? (
-

Which statement best describes the {organName.toLowerCase()}?

- - - +

{t("quizQuestion", { name })}

+ + +
) : type === "system" ? ( <> -

{organ.location}. Trace how the {organName.toLowerCase()} connects to the rest of the body.

+

{t("systemIntro", { location: organ.location, name })}

{/* Shown whole rather than cropped into the circular demo — the point of this view is the figure and its vessels. */}
- +
-
System
{organ.system}
-
Primary role
{organ.function}
-
Blood supply
{organ.bloodSupply}
+
{dict.modalSystemLabel}
{organ.system}
+
{dict.modalPrimaryRole}
{organ.function}
+
{dict.modalBlood}
{organ.bloodSupply}
- + ) : ( <> -

Follow the highlighted structures, rotate the specimen, and connect form with function. This short study moment is designed to build a durable mental model.

-
- +

{dict.lessonBody}

+
+ )}
diff --git a/app/components/LanguageSwitcher.tsx b/app/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..52e09f7 --- /dev/null +++ b/app/components/LanguageSwitcher.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Check, ChevronDown, Globe } from "lucide-react"; +import { LOCALES, LOCALE_META } from "../lib/i18n/config"; +import { useI18n } from "../lib/i18n/context"; + +export function LanguageSwitcher() { + const { locale, setLocale, dict } = useI18n(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + {open && ( +
    + {LOCALES.map((code) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/components/OrganViewer.tsx b/app/components/OrganViewer.tsx index 35a02f4..2718e98 100644 --- a/app/components/OrganViewer.tsx +++ b/app/components/OrganViewer.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import type { Hotspot, Organ } from "../lib/anatomy-data"; import type { AnatomyViewer } from "../lib/three/viewer"; +import { useI18n } from "../lib/i18n/context"; type Props = { organ: Organ; @@ -24,6 +25,7 @@ type Props = { }; export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompare }: Props) { + const { t, dict } = useI18n(); const mountRef = useRef(null); const viewerRef = useRef(null); const organRef = useRef(organ); @@ -112,21 +114,21 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar }; const tools = [ - { id: "rotate", label: "Rotate", icon: RotateCcw }, - { id: "zoom", label: "Zoom", icon: Search }, - { id: "isolate", label: "Isolate", icon: CircleDashed }, - { id: "section", label: "Cross-section", icon: ScanLine }, - { id: "layers", label: "Layers", icon: Layers3 }, - { id: "compare", label: "Compare", icon: Box }, - { id: "reset", label: "Reset", icon: RotateCcw }, + { id: "rotate", label: dict.toolRotate, icon: RotateCcw }, + { id: "zoom", label: dict.toolZoom, icon: Search }, + { id: "isolate", label: dict.toolIsolate, icon: CircleDashed }, + { id: "section", label: dict.toolSection, icon: ScanLine }, + { id: "layers", label: dict.toolLayers, icon: Layers3 }, + { id: "compare", label: dict.toolCompare, icon: Box }, + { id: "reset", label: dict.toolReset, icon: RotateCcw }, ]; return ( -
+
-
+
{tools.map(({ id, label, icon: Icon }) => ( {selected.label} @@ -169,18 +171,18 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar {loading && slowLoad && (
- Preparing the {organ.name.toLowerCase()} + {t("loaderPreparing", { name: organ.name })} {Math.max(8, Math.round(progress * 100))}%
)}
- 3D specimen · click a dot to explore + {dict.viewCaption} {organ.scientificName}
diff --git a/app/globals.css b/app/globals.css index e7a866b..71129c1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -16,6 +16,23 @@ html { background: #e9ddd0; } +/* Arabic renders right-to-left; the Latin display fonts have no Arabic glyphs, + so swap in an Arabic-capable stack for the whole document when dir=rtl. */ +:root[dir="rtl"] { + --font-sans: "Noto Sans Arabic", "Segoe UI", "Geeza Pro", Tahoma, sans-serif; + --font-serif: "Noto Naskh Arabic", "Amiri", "Segoe UI", Georgia, serif; +} + +/* Chevrons/arrows that point along the reading direction should mirror in RTL. */ +[dir="rtl"] :is( + .lesson-button, + .view-all, + .action-grid button, + .learning-cards article > button:not(.organ-card-image) +) svg { + transform: scaleX(-1); +} + body { margin: 0; min-width: 320px; @@ -58,7 +75,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { padding: 0; background: transparent; cursor: pointer; - text-align: left; + text-align: start; } .brand strong { @@ -67,7 +84,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { white-space: nowrap; } -.brand sup { color: var(--coral); font: 400 14px var(--font-sans), sans-serif; padding-left: 3px; } +.brand sup { color: var(--coral); font: 400 14px var(--font-sans), sans-serif; padding-inline-start: 3px; } .brand em, .info-panel em, .learning-cards em, .learning-modal > em { color: var(--lavender); font-family: "Comic Sans MS", "Bradley Hand", cursive; @@ -134,6 +151,59 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { } .mobile-library-trigger { display: none; } +.topbar-end { justify-self: end; display: flex; align-items: center; gap: 12px; } + +.lang-switcher { position: relative; } +.lang-trigger { + display: flex; + align-items: center; + gap: 6px; + height: 38px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255,255,255,.28); + color: var(--ink); + cursor: pointer; + font: 500 12px var(--font-serif), serif; +} +.lang-trigger:hover { background: rgba(255,255,255,.5); } +.lang-trigger span { letter-spacing: .04em; } +.lang-menu { + position: absolute; + z-index: 60; + top: calc(100% + 8px); + inset-inline-end: 0; + min-width: 180px; + margin: 0; + padding: 6px; + list-style: none; + border: 1px solid var(--line); + border-radius: 14px; + background: #fffaf2; + box-shadow: 0 18px 44px rgba(63,42,28,.18); + animation: modal-in .22s cubic-bezier(.2,.85,.3,1); +} +.lang-menu li { margin: 0; } +.lang-menu button { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 11px; + border: 0; + border-radius: 9px; + background: transparent; + cursor: pointer; + text-align: start; + color: var(--ink); + font: 500 13px var(--font-serif), serif; +} +.lang-menu button:hover { background: rgba(238,124,106,.08); } +.lang-menu button.active { background: rgba(238,124,106,.1); color: var(--coral); } +.lang-menu button svg { flex: none; } + .workspace { max-width: 1720px; height: min(760px, calc(100vh - 128px)); @@ -169,7 +239,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { grid-template-columns: 54px 1fr 18px; align-items: center; gap: 8px; - text-align: left; + text-align: start; padding: 8px 10px; border: 1px solid transparent; border-radius: 15px; @@ -229,7 +299,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { .viewer-tools { position: absolute; - left: 18px; + inset-inline-start: 18px; top: 50%; z-index: 3; transform: translateY(-50%); @@ -250,7 +320,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { .tip-note { position: absolute; - right: 16px; + inset-inline-end: 16px; top: 18px; z-index: 2; width: 155px; @@ -279,7 +349,8 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { position: absolute; top: -30px; width: 168px; - padding: 10px 26px 11px 12px; + padding-block: 10px 11px; + padding-inline: 12px 26px; border: 1px solid rgba(116,89,67,.14); border-radius: 13px; background: rgba(255,252,247,.92); @@ -302,10 +373,10 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { } .hotspot-callout[data-side="right"] .callout-body:before { right: 100%; } .hotspot-callout[data-side="left"] .callout-body:before { left: 100%; } -.callout-body b { display: block; padding-left: 13px; font: 500 13px var(--font-serif), serif; } -.callout-body b:before { content: ""; position: absolute; left: 12px; top: 15px; width: 7px; height: 7px; border-radius: 50%; background: var(--hotspot-color); } -.callout-body small { display: block; margin-top: 3px; padding-left: 13px; color: var(--muted); font-size: 10px; line-height: 1.35; } -.callout-close { position: absolute; top: 6px; right: 6px; width: 20px; height: 20px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--muted); cursor: pointer; } +.callout-body b { display: block; padding-inline-start: 13px; font: 500 13px var(--font-serif), serif; } +.callout-body b:before { content: ""; position: absolute; inset-inline-start: 12px; top: 15px; width: 7px; height: 7px; border-radius: 50%; background: var(--hotspot-color); } +.callout-body small { display: block; margin-top: 3px; padding-inline-start: 13px; color: var(--muted); font-size: 10px; line-height: 1.35; } +.callout-close { position: absolute; top: 6px; inset-inline-end: 6px; width: 20px; height: 20px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--muted); cursor: pointer; } .callout-close:hover { background: rgba(116,89,67,.09); } @keyframes callout-in { from { opacity: 0; transform: translateX(-6px) scale(.96); } } @@ -313,7 +384,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { .auto-rotate { position: absolute; - right: 17px; + inset-inline-end: 17px; bottom: 16px; z-index: 4; display: flex; @@ -321,7 +392,8 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { gap: 8px; border: 1px solid var(--line); border-radius: 13px; - padding: 8px 10px 8px 12px; + padding-block: 8px; + padding-inline: 12px 10px; background: rgba(255,252,247,.88); box-shadow: 0 5px 14px rgba(65,45,32,.08); cursor: pointer; @@ -330,8 +402,8 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { .switch { width: 29px; height: 17px; padding: 2px; border-radius: 99px; background: #c8c2bb; } .switch i { display: block; width: 13px; height: 13px; border-radius: 50%; background: white; box-shadow: 0 1px 4px rgba(0,0,0,.17); } .switch.on { background: #70a9cb; } -.switch.on i { margin-left: 12px; } -.view-caption { position: absolute; left: 98px; bottom: 17px; display: flex; flex-direction: column; gap: 2px; color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .1em; } +.switch.on i { margin-inline-start: 12px; } +.view-caption { position: absolute; inset-inline-start: 98px; bottom: 17px; display: flex; flex-direction: column; gap: 2px; color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .1em; } .view-caption strong { color: var(--ink); font: 500 13px var(--font-serif), serif; text-transform: none; letter-spacing: 0; } .model-loader { position: absolute; inset: 0; z-index: 6; display: grid; place-content: center; justify-items: center; gap: 8px; background: rgba(251,246,238,.45); backdrop-filter: blur(4px); } @@ -389,7 +461,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { .learning-cards em { display: block; text-transform: uppercase; font-size: 10px; } .learning-cards h3 { margin: 3px 0 0; font: 500 13px/1.2 var(--font-serif), serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .learning-cards article > button:not(.organ-card-image) { margin-top: auto; width: 100%; min-height: 30px; } -.curiosity-card { justify-content: center; padding-left: 28px !important; background: linear-gradient(145deg, rgba(255,247,234,.7), rgba(247,231,210,.86)) !important; } +.curiosity-card { justify-content: center; padding-inline-start: 28px !important; background: linear-gradient(145deg, rgba(255,247,234,.7), rgba(247,231,210,.86)) !important; } .curiosity-card span { color: #c8a0b8; font-size: 28px; } .curiosity-card p { margin: 5px 0 10px; font: 500 16px/1.4 var(--font-serif), serif; } .curiosity-card em { text-transform: none; font-size: 13px; } @@ -411,7 +483,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible { } .organ-card-image .art-fallback { font-size: 42px; } .organ-glyph .art-fallback { font-size: 21px; } -.compare-organ > .art-fallback { position: absolute; left: 0; top: 50%; width: 38px; height: 38px; border-radius: 50%; transform: translateY(-50%); font-size: 17px; } +.compare-organ > .art-fallback { position: absolute; inset-inline-start: 0; top: 50%; width: 38px; height: 38px; border-radius: 50%; transform: translateY(-50%); font-size: 17px; } .microscope-visual .art-fallback { font-size: 40px; } .modal-demo .art-fallback { font-size: 52px; } @@ -439,15 +511,15 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs .play-badge { position: absolute; width: 40px; height: 40px; border: 1px solid rgba(255,255,255,.8); border-radius: 50%; background: rgba(255,255,255,.7); color: #b4a99f; display: grid; place-items: center; box-shadow: 0 3px 12px rgba(0,0,0,.1); pointer-events: none; } /* Same top and bottom gap as an artwork card, so the footer buttons line up. */ .learning-cards ul { list-style: none; padding: 0; margin: var(--card-gap-top) 0 var(--card-gap-bottom); flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 9px; font: 500 11px var(--font-serif), serif; } -.learning-cards li:before { content: "♥"; color: #e9968b; margin-right: 7px; } +.learning-cards li:before { content: "♥"; color: #e9968b; margin-inline-end: 7px; } /* Sits directly under the header like every other card, rather than being pushed down by an auto top margin. */ .system-visual { overflow: hidden; border-radius: 12px; } .compare-strip { position: fixed; z-index: 20; left: 50%; bottom: 22px; transform: translateX(-50%); width: min(760px, calc(100% - 28px)); padding: 13px 16px; display: grid; grid-template-columns: 1fr auto 1fr 1.5fr auto; align-items: center; gap: 14px; border: 1px solid var(--line); border-radius: 18px; background: rgba(255,251,245,.94); backdrop-filter: blur(18px); box-shadow: 0 22px 60px rgba(64,43,29,.2); } .compare-strip > div { display: flex; flex-direction: column; } -.compare-strip .compare-organ { position: relative; padding-left: 45px; min-height: 38px; justify-content: center; } -.compare-organ > img { position: absolute; left: 0; top: 50%; width: 38px; height: 38px; border-radius: 50%; object-fit: cover; transform: translateY(-50%); mix-blend-mode: multiply; } +.compare-strip .compare-organ { position: relative; padding-inline-start: 45px; min-height: 38px; justify-content: center; } +.compare-organ > img { position: absolute; inset-inline-start: 0; top: 50%; width: 38px; height: 38px; border-radius: 50%; object-fit: cover; transform: translateY(-50%); mix-blend-mode: multiply; } .compare-strip span { color: var(--lavender); font-size: 9px; text-transform: uppercase; letter-spacing: .12em; } .compare-strip strong { font: 500 20px var(--font-serif), serif; } .compare-strip small { color: var(--muted); font-size: 9px; } @@ -461,7 +533,7 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs .learning-modal { width: min(460px, 100%); max-height: min(92vh, 900px); overflow: auto; scrollbar-width: none; position: relative; padding: 32px; border: 1px solid var(--line); border-radius: 26px; background: #fffaf2; box-shadow: 0 30px 90px rgba(63,42,28,.24); text-align: center; animation: modal-in .35s cubic-bezier(.2,.85,.3,1); } .learning-modal.wide { width: min(540px, 100%); } @keyframes modal-in { from { opacity: 0; transform: translateY(14px) scale(.98); } } -.modal-close { position: absolute; top: 13px; right: 13px; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: transparent; cursor: pointer; } +.modal-close { position: absolute; top: 13px; inset-inline-end: 13px; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: transparent; cursor: pointer; } .modal-icon { width: 56px; height: 56px; display: grid; place-items: center; margin: 0 auto 12px; border-radius: 50%; background: rgba(237,126,105,.1); color: var(--coral); font: 500 26px var(--font-serif), serif; } .learning-modal h2 { margin: 6px 0 12px; font: 500 34px var(--font-serif), serif; } .learning-modal > p, .quiz-options p { color: #6e655f; font: 500 15px/1.5 var(--font-serif), serif; } @@ -474,12 +546,12 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs .modal-figure { margin: 18px 0 4px; aspect-ratio: 1; max-height: 46vh; display: grid; place-items: center; overflow: hidden; border-radius: 20px; background: radial-gradient(circle at 50% 42%, #fff, #f8ecdf); } .modal-figure img { width: 100%; height: 100%; object-fit: contain; mix-blend-mode: multiply; } .modal-figure .art-fallback { font-size: 64px; } -.modal-facts { margin: 14px 0 4px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; text-align: left; } +.modal-facts { margin: 14px 0 4px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; text-align: start; } .modal-facts dt { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .1em; } .modal-facts dd { margin: 4px 0 0; font: 500 12px/1.3 var(--font-serif), serif; } .learning-modal.wide .lesson-button { margin-top: 16px; } -.quiz-options { display: flex; flex-direction: column; gap: 8px; text-align: left; } +.quiz-options { display: flex; flex-direction: column; gap: 8px; text-align: start; } .quiz-options button { padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: rgba(255,255,255,.5); cursor: pointer; } .drawer-backdrop { display: none; } @@ -526,7 +598,7 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs background: #fffaf3; } .organ-library.open { transform: translateY(0); } - .panel-heading .mobile-close { display: grid; margin-left: auto; margin-right: 7px; } + .panel-heading .mobile-close { display: grid; margin-inline-start: auto; margin-inline-end: 7px; } .drawer-backdrop { display: block; position: fixed; inset: 0; z-index: 40; border: 0; background: rgba(52,39,30,.24); backdrop-filter: blur(5px); } .organ-list { display: grid; grid-template-columns: 1fr 1fr; } .organ-item { min-height: 76px; } @@ -538,8 +610,8 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs .tool-button { width: 51px; height: 51px; } .tool-button:nth-last-child(1) { border: 0; border-radius: 12px; } .tool-button span { display: none; } - .auto-rotate { bottom: 82px; right: 10px; } - .view-caption { left: 12px; bottom: 84px; } + .auto-rotate { bottom: 82px; inset-inline-end: 10px; } + .view-caption { inset-inline-start: 12px; bottom: 84px; } .tip-note { width: 130px; min-height: 113px; padding: 11px; } .tip-note p { font-size: 12px; } .callout-body { width: 148px; top: -26px; } diff --git a/app/layout.tsx b/app/layout.tsx index 696d3aa..8f1591f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -67,13 +67,23 @@ export const viewport: Viewport = { themeColor: "#f7f0e7", }; +/** + * Applies the persisted language to before first paint, so an + * Arabic reader never sees a left-to-right flash on load. The provider takes + * over once React hydrates. Kept in sync with LOCALE_STORAGE_KEY / RTL_LOCALES. + */ +const LOCALE_BOOTSTRAP = `(function(){try{var l=localStorage.getItem("anatomy-locale");if(l&&["en","ar","de","fr","it"].indexOf(l)>-1){document.documentElement.lang=l;document.documentElement.dir=l==="ar"?"rtl":"ltr";}}catch(e){}})();`; + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - + + +