- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Bulk import
- Playwright
- Prettier
- Block Rebound
- Transportation
Translation runs on two tiers: dictionary JSON files for static UI (parity-enforced across ar/en by a CI test) and the batched localize() engine for dynamic database content via a three-tier cache (in-memory LRU → Translation table → provider chain). The provider chain (engine.ts) tries Google Translate first and falls back to a Groq LLM (free tier) — each behind a circuit breaker — so a dead provider (as happened 2026-06-14 → 2026-07-12 when the Google quota died and silently killed all dynamic translation) can never again take translations down platform-wide. A registry-driven sweep (pnpm i18n:backfill + the daily /api/cron/translation-sweep) backfills and self-heals the cache for existing content, person names, new schools, and bulk imports. Drift can no longer ship: en/ar key parity, untranslated placeholders, hardcoded-string counts, and RTL physical classes are all gated in pnpm test and CI. The remaining work is the hardcoded-string sweep tracked by the ratchet baselines — run npx tsx scripts/i18n-hardcoded-ratchet.ts --by-dir for the live count.
Architecture
| Tier | What | How |
|---|---|---|
| 1 — Static UI | Sidebar, table headers, status badges, labels | Dictionary JSON → getDictionary() → dictionary.key |
| 2 — Dynamic | Announcements, subjects, departments, events | Single-language storage → batched localize()/getLabels() → LRU → DB cache → Google → Groq (LLM) |
Infrastructure files
| File | Purpose |
|---|---|
src/components/translation/localize.ts | Batched localize()/localizeOne() — preferred |
src/components/translation/registry.ts | TRANSLATABLE map (model → fields) |
src/components/translation/prewarm.ts | Cache-fill on write via after() |
src/components/translation/person.ts | getNames()/getLabels() — batched names/labels |
src/components/translation/display.ts | getText(), getFields() — single values only |
src/components/translation/actions.ts | translate() core (LRU → DB → provider chain) |
src/components/translation/engine.ts | Provider chain: Google → Groq, circuit breakers |
src/components/translation/google.ts | Google client: timeout, chunking, retry policy |
src/components/translation/groq.ts | Groq LLM fallback: JSON batch protocol, free tier |
src/components/translation/sweep.ts | Cache sweep core (backfill CLI + daily cron) |
src/components/translation/util.ts | withLang, detectScript |
src/components/internationalization/helpers/index.ts | ValidationHelper, ToastHelper, ErrorHelper |
src/components/internationalization/namespaces.ts | Single namespace registry (server + client) |
src/components/internationalization/dictionaries.ts | Route-scoped dictionary loaders (cache()-wrapped) |
Coverage gaps
| Gap | Status |
|---|---|
Button text (Save, Cancel, Delete…) | Keys exist in dictionary.common.* |
| Validation (Zod messages) | Needs schema-factory refactor |
| Config / select options | Needs option factories |
| Form labels and placeholders | Partial adoption |
| Server action errors | Needs error-code pattern |
| Wizard tab / group labels | Pending |
| Empty states | Pending |
| Toast messages | Needs ToastHelper plumbing |
Cards and Lab sidebar items also lack dictionary keys. Run /i18n-check for the current per-area count.
Priority order
- Button text — keys already exist in
dictionary.common.*. - Wizard group labels — high visibility.
- Form labels — largest UX impact.
- Select options — needs config factory.
- Validation — needs schema factory refactor.
- Server action errors — needs error code pattern.
- Empty states.
- Toast messages — needs dictionary plumbing.
getTextexpansion to remaining content files.
Form pattern
Pass dictionary slices into client forms.
// content.tsx
const dictionary = await getDictionary(lang)
return <StudentForm dictionary={dictionary.school.students} />
// form.tsx
<FormLabel>{dictionary.form.name}</FormLabel>
<Input placeholder={dictionary.form.namePlaceholder} {...field} />Select pattern
Export option factories, not static arrays.
export const getGenderOptions = (d: Dictionary["school"]) => [
{ value: "male", label: d.students.gender.male },
{ value: "female", label: d.students.gender.female },
]Validation pattern
Schemas become factories that take a ValidationHelper. Already shipping in onboarding and auth.
import { getValidationMessages } from "@/components/internationalization/helpers"
export const createStudentSchema = (
v: ReturnType<typeof getValidationMessages>
) =>
z.object({
name: z.string().min(1, v.required()),
email: z.string().email(v.email()),
phone: z.string().min(10, v.minLength(10)),
})Blocker: validation files across the dashboard need to convert from module-level schemas to factories.
Toast pattern
import { getToastMessages } from "@/components/internationalization/helpers"
const t = useMemo(() => getToastMessages(dictionary), [dictionary])
if (result.success) toast.success(t.success.student.created())
else toast.error(t.error.student.createFailed())Blocker: toasts run client-side; ToastHelper needs dictionary in scope.
Server action error pattern
Return error codes; translate on the client.
// actions.ts
if (!session?.user) return { success: false, errorCode: "NOT_AUTHENTICATED" }
if (!schoolId) return { success: false, errorCode: "MISSING_SCHOOL_CONTEXT" }
// form.tsx
const ERROR_MAP: Record<string, string> = {
NOT_AUTHENTICATED: dictionary.messages.errors.auth.notAuthenticated,
MISSING_SCHOOL_CONTEXT:
dictionary.messages.errors.tenant.missingSchoolContext,
}
toast.error(
ERROR_MAP[result.errorCode] ?? dictionary.messages.errors.server.internalError
)Per-area status
All 21 areas (Dashboard, Students, Teachers, Classes, Subjects, Announcements, Events, Assignments, Parents, Grades, Classrooms, Exams, Finance, Attendance, Timetable, Profile, Settings, Notifications, Admission, School Config, Reports) use the dictionary for table headers and sidebar but are partial on getText, forms, selects, validation, toasts, and actions. Students/Teachers/Classes/Subjects/Announcements/Parents/Classrooms/Exams/Attendance/Profile use getText for some surfaces; the rest are display-text-pending.
Wizard routes (/students/add, /teachers/add, /exams/manage, /exams/qbank, /finance/invoice) have hardcoded forms, selects, validation, toasts, and action errors.
Guardrails
| Layer | What it does |
|---|---|
.claude/rules/translation.md | Path-scoped rule fires on src/components/** and src/app/** edits |
.claude/hooks/check-i18n.sh | PostToolUse hook flags hardcoded <FormLabel> and toast.success/error strings |
CLAUDE.md gotcha #12 | Reminds that all UI text must use dictionary keys |
See also
- Translation Guide — deep guide to DB-content translation (cache, name helpers, trade-offs, tooling)
- Internationalization — architecture reference
- Multi-Tenancy
- Pattern
- Validation
- Form