From 81e5979f8f667831076e9abff62781db53f92993 Mon Sep 17 00:00:00 2001 From: Aravind Date: Wed, 7 Oct 2026 19:15:44 +0530 Subject: [PATCH] Update Owliver language and suggestions --- package-lock.json | 68 +- package.json | 2 + scripts/i18n-audit.mjs | 58 + scripts/i18n-coverage.mjs | 126 + scripts/i18n-extract.mjs | 307 +++ scripts/skill-check.mjs | 127 +- src/App.tsx | 5 + src/components/UserNotRegisteredError.tsx | 14 +- src/components/admin/PageShell.tsx | 4 +- src/components/agents/AgentCanvas.tsx | 9 +- src/components/agents/AgentConfigure.tsx | 115 +- src/components/agents/AgentInsightsPanel.tsx | 28 +- src/components/agents/AgentTestPanel.tsx | 40 +- .../agents/skills/AgentSkillWorkspace.tsx | 18 +- src/components/agents/skills/AgentTree.tsx | 33 +- .../agents/skills/BoardSkillList.tsx | 39 +- src/components/agents/skills/SkillCatalog.tsx | 19 +- src/components/agents/skills/SkillDetails.tsx | 48 +- src/components/ai-assistant/AgentBadge.tsx | 7 +- .../ai-assistant/AssistantMessage.tsx | 4 +- .../ai-assistant/AssistantPanel.tsx | 12 +- src/components/ai-assistant/KrowAssistant.tsx | 31 +- src/components/ai-assistant/PromptChips.tsx | 4 +- src/components/ai-assistant/PromptInput.tsx | 10 +- src/components/ai-assistant/ResizeDivider.tsx | 4 +- src/components/ai-assistant/dynamic.ts | 61 +- src/components/ai-assistant/insights.ts | 8 + src/components/ai-assistant/useAssistant.ts | 2 +- .../charts/DepartmentPerformance.tsx | 80 +- src/components/ds/Alert.tsx | 4 +- src/components/ds/ChartContainer.tsx | 4 +- src/components/ds/DataTable.tsx | 4 +- src/components/ds/FilterBar.tsx | 6 +- src/components/ds/MultiSelect.tsx | 6 +- src/components/ds/Pagination.tsx | 12 +- src/components/ds/Progress.tsx | 4 +- src/components/ds/Skeleton.tsx | 10 +- src/components/ds/StatusBadge.tsx | 16 +- src/components/forge/AddSkillTraining.tsx | 80 +- src/components/forge/ChallengeDirectory.tsx | 20 +- src/components/forge/ForgeHeader.tsx | 11 +- src/components/forge/ForgeInsight.tsx | 6 +- src/components/forge/ForgeProgress.tsx | 4 +- src/components/forge/ForgeRail.tsx | 12 +- src/components/forge/NextSkill.tsx | 12 +- src/components/forge/SkillCard.tsx | 10 +- src/components/forge/SkillDetail.tsx | 28 +- src/components/forge/SkillLibrary.tsx | 18 +- src/components/forge/SkillProgression.tsx | 38 +- src/components/forge/YourForge.tsx | 8 +- src/components/krow/AIInterviewModal.tsx | 34 +- src/components/krow/ActivityCharts.tsx | 8 +- src/components/krow/ActivityLogTable.tsx | 18 +- src/components/krow/AddToPositionModal.tsx | 14 +- src/components/krow/ApplyIdentityCard.tsx | 32 +- src/components/krow/CandidateCard.tsx | 20 +- .../krow/CandidateExpandedDetails.tsx | 10 +- src/components/krow/CertificationManager.tsx | 16 +- src/components/krow/HiringAnalytics.tsx | 16 +- src/components/krow/ImpactMetrics.tsx | 4 +- src/components/krow/InterviewAnalytics.tsx | 6 +- src/components/krow/InterviewResults.tsx | 14 +- src/components/krow/InviteApplicantsModal.tsx | 18 +- .../krow/KROWCandidateLeaderboard.tsx | 6 +- src/components/krow/KROWJobPostingCard.tsx | 8 +- src/components/krow/KrowIdPreview.tsx | 14 +- src/components/krow/KrowIdentityCard.tsx | 18 +- src/components/krow/MatchedCandidates.tsx | 39 +- src/components/krow/MessageCandidateModal.tsx | 16 +- src/components/krow/OwliverAvatar.tsx | 4 +- src/components/krow/OwliverChatBubble.tsx | 16 +- src/components/krow/PipelineFunnel.tsx | 10 +- src/components/krow/PositionDetails.tsx | 4 +- src/components/krow/RateEndorseModal.tsx | 16 +- .../krow/ReferencesEndorsements.tsx | 6 +- src/components/krow/RetentionMetrics.tsx | 10 +- .../krow/ScheduleInterviewModal.tsx | 47 +- src/components/krow/ScoreDistribution.tsx | 6 +- .../krow/SkillRequirementsField.tsx | 14 +- src/components/krow/SuggestedTalent.tsx | 14 +- src/components/krow/TalentDetailModal.tsx | 32 +- src/components/krow/TalentPoolCard.tsx | 9 +- src/components/krow/UniversityCourseCard.tsx | 8 +- src/components/krow/VerifiedSkills.tsx | 21 +- src/components/krow/WorkforceReputation.tsx | 21 +- .../krow/proving/ChallengeRunner.tsx | 30 +- .../krow/proving/HazardIdentifier.tsx | 16 +- .../krow/proving/MediaChallenge.tsx | 10 +- .../krow/proving/RoleplayChallenge.tsx | 10 +- src/components/krow/proving/VideoRecorder.tsx | 8 +- src/components/krow/talent/CareerGrowth.tsx | 10 +- src/components/krow/talent/CareerPassport.tsx | 6 +- .../krow/talent/CareerScoreGauge.tsx | 6 +- .../krow/talent/EmployerInsights.tsx | 6 +- .../krow/talent/ExperienceEndorsements.tsx | 6 +- .../krow/talent/ExperienceNetwork.tsx | 6 +- .../krow/talent/IdentityCareerCard.tsx | 41 +- .../krow/talent/IncreaseMarketValue.tsx | 8 +- src/components/krow/talent/MarketOffers.tsx | 16 +- src/components/krow/talent/Opportunities.tsx | 15 +- src/components/krow/talent/TalentHero.tsx | 19 +- .../krow/talent/VerifiedExperienceList.tsx | 6 +- src/components/skills/SkillSections.tsx | 8 +- src/components/ui-editor/NodeInspector.tsx | 39 +- src/components/ui-editor/NodePicker.tsx | 12 +- src/components/ui-editor/TreePanel.tsx | 10 +- src/components/ui-editor/UiEditor.tsx | 16 +- src/layouts/AdminLayout.tsx | 76 +- src/layouts/Layout.tsx | 24 +- src/lib/i18n/index.ts | 84 + .../ai-assistant => lib/i18n}/language.ts | 21 + src/lib/i18n/locales/en.json | 2194 +++++++++++++++++ src/lib/i18n/locales/es.json | 2194 +++++++++++++++++ src/pages/Analytics.tsx | 8 +- src/pages/Apply.tsx | 16 +- src/pages/Candidates.tsx | 28 +- src/pages/CourseDetail.tsx | 33 +- src/pages/CreatePosition.tsx | 76 +- src/pages/DesignSystem.tsx | 364 +-- src/pages/HiredHistory.tsx | 38 +- src/pages/KrowIdentity.tsx | 40 +- src/pages/OpportunityDetail.tsx | 34 +- src/pages/Overview.tsx | 20 +- src/pages/PositionDetail.tsx | 41 +- src/pages/Positions.tsx | 24 +- src/pages/Profile.tsx | 16 +- src/pages/TalentPool.tsx | 20 +- src/pages/University.tsx | 8 +- src/pages/UserTracking.tsx | 6 +- src/pages/WorkerProfile.tsx | 6 +- src/pages/admin/Activity.tsx | 6 +- src/pages/admin/AgentDetail.tsx | 18 +- src/pages/admin/Analytics.tsx | 6 +- src/pages/admin/CandidateProfile.tsx | 110 +- src/pages/admin/Candidates.tsx | 6 +- src/pages/admin/CandidatesAnalysis.tsx | 6 +- src/pages/admin/ControlCenter.tsx | 103 +- src/pages/admin/HiredHistory.tsx | 25 +- src/pages/admin/Login.tsx | 31 +- src/pages/admin/OwliverSkillEditor.tsx | 105 +- src/pages/admin/Positions.tsx | 77 +- src/pages/admin/Profile.tsx | 130 +- src/pages/admin/Settings.tsx | 90 +- src/pages/admin/SkillDevelopment.tsx | 28 +- src/pages/admin/TalentPool.tsx | 6 +- src/pages/admin/TalentProfile.tsx | 30 +- src/pages/admin/Workspace.tsx | 42 +- src/pages/admin/WorkspaceAgents.tsx | 88 +- src/pages/admin/WorkspaceSkills.tsx | 52 +- src/pages/admin/activity/nodes.tsx | 25 +- src/pages/admin/analytics/nodes.tsx | 44 +- src/pages/admin/candidates-analysis/nodes.tsx | 46 +- src/pages/admin/candidates/nodes.tsx | 17 +- src/pages/admin/control-center/nodes.tsx | 132 +- src/pages/admin/hired-history/nodes.tsx | 17 +- src/pages/admin/talent-pool/nodes.tsx | 25 +- 156 files changed, 7330 insertions(+), 1755 deletions(-) create mode 100644 scripts/i18n-audit.mjs create mode 100644 scripts/i18n-coverage.mjs create mode 100644 scripts/i18n-extract.mjs create mode 100644 src/lib/i18n/index.ts rename src/{components/ai-assistant => lib/i18n}/language.ts (86%) create mode 100644 src/lib/i18n/locales/en.json create mode 100644 src/lib/i18n/locales/es.json diff --git a/package-lock.json b/package-lock.json index 55277bf..cb67256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,10 +31,12 @@ "clsx": "^2.1.1", "framer-motion": "^11.16.4", "html2canvas": "^1.4.1", + "i18next": "^26.4.2", "lucide-react": "^0.475.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.16", "react-markdown": "^9.0.1", "react-router-dom": "^6.26.0", "recharts": "^3.10.1", @@ -6315,6 +6317,15 @@ "react-is": "^16.7.0" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6366,6 +6377,34 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8701,6 +8740,33 @@ "react-dom": ">=16" } }, + "node_modules/react-i18next": { + "version": "17.0.16", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz", + "integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -10073,7 +10139,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index f33a70d..33bb88c 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ "clsx": "^2.1.1", "framer-motion": "^11.16.4", "html2canvas": "^1.4.1", + "i18next": "^26.4.2", "lucide-react": "^0.475.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.16", "react-markdown": "^9.0.1", "react-router-dom": "^6.26.0", "recharts": "^3.10.1", diff --git a/scripts/i18n-audit.mjs b/scripts/i18n-audit.mjs new file mode 100644 index 0000000..9e6884e --- /dev/null +++ b/scripts/i18n-audit.mjs @@ -0,0 +1,58 @@ +/** + * Every t('key') in the source, checked against the catalogues. + * + * A key the code calls and the catalogue lacks renders as humanised English at + * best, so this is the check that says the extraction is actually complete. + * The reverse — a catalogue entry nothing calls — is only waste, and reported + * separately. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const walk = (dir, acc = []) => { + for (const e of readdirSync(dir)) { + if (e === 'node_modules' || e.startsWith('.')) continue; + const p = join(dir, e); + if (statSync(p).isDirectory()) walk(p, acc); + else if (/\.(tsx|ts|jsx|js)$/.test(p)) acc.push(p); + } + return acc; +}; + +const flat = (obj, prefix = '', out = {}) => { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object') flat(v, key, out); + else out[key] = v; + } + return out; +}; + +const en = flat(JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8'))); +const es = flat(JSON.parse(readFileSync('src/lib/i18n/locales/es.json', 'utf8'))); + +const used = new Set(); +const dynamic = []; +for (const file of walk('src')) { + const src = readFileSync(file, 'utf8'); + for (const m of src.matchAll(/\bt\(\s*'([^']+)'/g)) used.add(m[1]); + for (const m of src.matchAll(/\bt\(\s*`([^`]+)`/g)) dynamic.push(`${file}: ${m[1]}`); +} + +/* A plural key is stored as name_one / name_other, so the base name is never + itself in the catalogue and is not missing. */ +const present = (k) => k in en || `${k}_other` in en || `${k}_one` in en; + +const missingEn = [...used].filter((k) => !present(k)).sort(); +const missingEs = [...used].filter((k) => present(k) && !(k in es || `${k}_other` in es)).sort(); +const unused = Object.keys(en).filter((k) => !used.has(k) && !used.has(k.replace(/_(one|other)$/, ''))).sort(); + +console.log(`\n t() calls with a literal key : ${used.size}`); +console.log(` built from a template : ${dynamic.length} (checked by hand)`); +console.log(` MISSING from en.json : ${missingEn.length}`); +console.log(` MISSING from es.json : ${missingEs.length}`); +console.log(` in en.json, never called : ${unused.length}`); + +for (const k of missingEn.slice(0, 15)) console.log(` missing en: ${k}`); +for (const k of missingEs.slice(0, 10)) console.log(` missing es: ${k}`); +if (missingEn.length) process.exitCode = 1; diff --git a/scripts/i18n-coverage.mjs b/scripts/i18n-coverage.mjs new file mode 100644 index 0000000..522c8ea --- /dev/null +++ b/scripts/i18n-coverage.mjs @@ -0,0 +1,126 @@ +/** + * How much of each page is actually translated. + * + * Renders every migrated page twice — once in English, once in Spanish — and + * reports the text that did NOT change. That residue is the untranslated part, + * and it is the only honest measure of coverage: a catalogue can be full of + * Spanish that no component ever calls, and counting keys would score that as + * finished. + * + * Reports rather than asserts. The point is a number per page while the work is + * half done; `skill-check.mjs` is where a finished page gets a check that keeps + * it finished. + * + * node scripts/i18n-coverage.mjs + */ + +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { createServer } from 'vite'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { withSourceResolution } from './ssr-resolve.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/* The pages reach for `window` on the way up — a media query, a resize + listener. The same minimal stub the skill-check uses, installed before any + page module is loaded. */ +if (!('window' in globalThis)) { + globalThis.window = { + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener() {}, removeEventListener() {}, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + }; +} + +const server = await createServer({ + root: ROOT, + server: { middlewareMode: true }, + appType: 'custom', + logLevel: 'error', + resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } }, +}); +withSourceResolution(server); + +const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query'); +const { MemoryRouter } = await import('react-router-dom'); +const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx'); +const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default; + +const PAGES = [ + ['Control Center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'], + ['Positions', '/src/pages/admin/Positions.jsx', '/admin/positions', 'positions'], + ['Candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'], + ['Talent Pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'], + ['Analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'], + ['Hired History', '/src/pages/admin/HiredHistory.jsx', '/admin/hired', 'hired-history'], + ['Activity', '/src/pages/admin/Activity.jsx', '/admin/activity', 'activity'], +]; + +/* Text only, split into phrases on the punctuation the interface uses to + separate one label from the next, so "17 total" counts separately from the + heading above it. Numbers and symbols alone are not evidence either way — + "27" is the same in both languages — so anything without a letter is + dropped rather than scored as translated. */ +const phrases = (html) => + html + .replace(/<[^>]*>/g, '\n') + .split(/[\n·|•]+/) + .map((p) => p.replace(/\s+/g, ' ').trim()) + .filter((p) => p.length > 2 && /[A-Za-z]{3}/.test(p)); + +async function render(path, page) { + const Page = (await server.ssrLoadModule(path)).default; + const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }); + return renderToStaticMarkup( + React.createElement(MemoryRouter, { initialEntries: [page.route] }, + React.createElement(QueryClientProvider, { client }, + React.createElement(UiEditingProvider, { page: page.key }, + React.createElement(Page)))) + ); +} + +console.log('\n Translation coverage — text that changes when the language does\n'); + +let totalSame = 0; +let totalAll = 0; + +for (const [name, path, route, key] of PAGES) { + let en; + let es; + try { + await i18n.changeLanguage('en'); + en = phrases(await render(path, { route, key })); + await i18n.changeLanguage('es'); + es = phrases(await render(path, { route, key })); + await i18n.changeLanguage('en'); + } catch (err) { + console.log(` ${name.padEnd(16)} could not render — ${String(err.message).slice(0, 60)}`); + continue; + } + + /* A phrase present in BOTH renders is one the switch did not touch. Proper + nouns and figures land here too, which is why the sample below is printed: + a human reads it and sees at once whether the residue is "KROW Forge" or a + whole untranslated paragraph. */ + const esSet = new Set(es); + const untouched = en.filter((p) => esSet.has(p)); + + totalSame += untouched.length; + totalAll += en.length; + + const pct = en.length ? Math.round(((en.length - untouched.length) / en.length) * 100) : 0; + const bar = '█'.repeat(Math.round(pct / 5)).padEnd(20, '·'); + console.log(` ${name.padEnd(16)} ${bar} ${String(pct).padStart(3)}% ${en.length - untouched.length}/${en.length} phrases`); + + if (untouched.length) { + const sample = untouched.filter((p) => /[a-z]{4}/.test(p)).slice(0, 4); + if (sample.length) console.log(` ${''.padEnd(16)} still English: ${sample.join(' / ').slice(0, 96)}`); + } +} + +const overall = totalAll ? Math.round(((totalAll - totalSame) / totalAll) * 100) : 0; +console.log(`\n Overall: ${overall}% of rendered phrases change with the language.\n`); + +await server.close(); diff --git a/scripts/i18n-extract.mjs b/scripts/i18n-extract.mjs new file mode 100644 index 0000000..f214a34 --- /dev/null +++ b/scripts/i18n-extract.mjs @@ -0,0 +1,307 @@ +/** + * Lift hard-coded English out of components and into the catalogue. + * + * node scripts/i18n-extract.mjs --dry src/components/krow + * node scripts/i18n-extract.mjs src/components/krow/SomeCard.tsx + * + * An AST codemod rather than a regex pass, because the hard part is not finding + * the string — it is knowing which component encloses it, so `const { t } = + * useTranslation()` lands in the right function and not in a module-level + * constant where hooks cannot run. + * + * WHAT IT WILL TOUCH + * - JSX text children that are entirely static words + * - string literals on a short list of text-bearing attributes + * + * WHAT IT LEAVES ALONE, and why each one would be a bug: + * - anything inside a module-level object or array; those are built once at + * import, so a t() there freezes the first language the app loaded + * - strings with no letters, single characters, and pure punctuation + * - `className`, `id`, `key`, `type`, `name`, routes, urls, test ids + * - text inside ,
,