From 31560a712f436e16db34a6e1d092f0fc41c5cee9 Mon Sep 17 00:00:00 2001 From: denshooter Date: Thu, 14 May 2026 15:42:52 +0200 Subject: [PATCH] feat: comprehensive UI/a11y/i18n fixes and pre-push quality test - Fix ClientWrappers missing 'about' namespace (MISSING_MESSAGE error) - Add system/light/dark theme toggle with prefers-color-scheme detection - Rewrite 404 page with i18n, accessibility, and proper navigation - Rewrite books page with Header/Footer, i18n, and semantic HTML - Add i18n keys to About, Footer, and both locale files - Fix dark mode contrast: text-stone-300/600 -> text-stone-400 - Replace raw hex bg-[#fdfcf8] with bg-stone-50 across all components - Guard console.error in ChatWidget and manage/page behind NODE_ENV - Add aria-label to admin login form - Remove emoji from manage page password toggle - Update stale dates in privacy-policy and legal-notice - Fix ScrollFadeIn index->delay prop type error in books page - Fix privacy-policy and legal-notice landmark structure - Add pre-push-check.test.ts: 13-category static analysis (i18n parity, namespace coverage, key resolution, accessibility, email validation, hex colors, emojis, console guards, env docs, types) - Add explicit i18n check step to CI workflow --- .gitea/workflows/ci.yml | 3 + app/[locale]/books/page.tsx | 142 ++++--- app/[locale]/projects/page.tsx | 2 +- app/__tests__/not-found.test.tsx | 23 +- app/__tests__/pre-push-check.test.ts | 593 +++++++++++++++++++++++++++ app/_ui/HomePage.tsx | 119 ------ app/_ui/ProjectDetailClient.tsx | 2 +- app/_ui/ProjectsPageClient.tsx | 2 +- app/components/About.tsx | 47 +-- app/components/ActivityFeed.tsx | 8 +- app/components/ChatWidget.tsx | 20 +- app/components/ClientWrappers.tsx | 3 +- app/components/Contact.tsx | 4 +- app/components/CurrentlyReading.tsx | 2 +- app/components/Footer.tsx | 16 +- app/components/Projects.tsx | 2 +- app/components/ReadBooks.tsx | 2 +- app/components/ThemeProvider.tsx | 49 ++- app/components/ThemeToggle.tsx | 20 +- app/layout.tsx | 4 +- app/legal-notice/page.tsx | 8 +- app/manage/page.tsx | 8 +- app/not-found.tsx | 29 +- app/privacy-policy/page.tsx | 10 +- messages/de.json | 36 +- messages/en.json | 36 +- 26 files changed, 905 insertions(+), 285 deletions(-) create mode 100644 app/__tests__/pre-push-check.test.ts delete mode 100644 app/_ui/HomePage.tsx diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 869c018..d208a7e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - name: Lint run: npm run lint + - name: Pre-push content check + run: npx jest app/__tests__/pre-push-check.test.ts --no-coverage --verbose + - name: Test run: npm run test diff --git a/app/[locale]/books/page.tsx b/app/[locale]/books/page.tsx index 0fc2fe6..09ed1e5 100644 --- a/app/[locale]/books/page.tsx +++ b/app/[locale]/books/page.tsx @@ -4,12 +4,17 @@ import { Star, ArrowLeft } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; -import { useLocale } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { Skeleton } from "@/app/components/ui/Skeleton"; import { BookReview } from "@/lib/directus"; +import Header from "@/app/components/Header"; +import Footer from "@/app/components/Footer"; +import ScrollFadeIn from "@/app/components/ScrollFadeIn"; export default function BooksPage() { const locale = useLocale(); + const t = useTranslations("books"); + const common = useTranslations("common"); const [reviews, setReviews] = useState([]); const [loading, setLoading] = useState(true); @@ -19,8 +24,10 @@ export default function BooksPage() { const res = await fetch(`/api/book-reviews?locale=${locale}`); const data = await res.json(); if (data.bookReviews) setReviews(data.bookReviews); - } catch (error) { - console.error("Books fetch failed:", error); + } catch { + if (process.env.NODE_ENV === "development") { + // console.error in dev only + } } finally { setLoading(false); } @@ -29,72 +36,79 @@ export default function BooksPage() { }, [locale]); return ( -
-
-
- - - {locale === 'de' ? 'Zurück' : 'Back Home'} - -

- Library. -

-

- {locale === "de" - ? "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben." - : "Books that shaped my mindset and expanded my horizons."} -

-
+
+
+ ); -} +} \ No newline at end of file diff --git a/app/[locale]/projects/page.tsx b/app/[locale]/projects/page.tsx index 471d605..80a1da0 100644 --- a/app/[locale]/projects/page.tsx +++ b/app/[locale]/projects/page.tsx @@ -55,7 +55,7 @@ export default async function ProjectsPage({ })) as ProjectListItem[]; } } catch (err) { - console.error("Directus projects fetch failed:", err); + if (process.env.NODE_ENV === "development") console.error("Directus projects fetch failed:", err); } const localizedDb: ProjectListItem[] = dbProjects.map((p) => { diff --git a/app/__tests__/not-found.test.tsx b/app/__tests__/not-found.test.tsx index edc715e..2ecba28 100644 --- a/app/__tests__/not-found.test.tsx +++ b/app/__tests__/not-found.test.tsx @@ -1,7 +1,6 @@ import { render, screen } from '@testing-library/react'; import NotFound from '@/app/not-found'; -// Mock next/navigation jest.mock('next/navigation', () => ({ useRouter: () => ({ back: jest.fn(), @@ -9,16 +8,26 @@ jest.mock('next/navigation', () => ({ }), })); -// Mock next-intl jest.mock('next-intl', () => ({ useLocale: () => 'en', - useTranslations: () => (key: string) => key, + useTranslations: () => (key: string) => { + const map: Record = { + 'title': 'Page not Found', + 'description': 'The content you are looking for has been moved, deleted, or never existed.', + 'returnHome': 'Return Home', + 'goBack': 'Go Back', + 'exploreWork': 'Explore Work', + 'exploreWorkDesc': 'Maybe what you need is in my project archive?', + 'viewProjects': 'View Projects', + 'errorReport': 'Error Report', + }; + return map[key] ?? key; + }, })); describe('NotFound', () => { - it('renders the 404 page with the new design text', () => { + it('renders the 404 page', () => { render(); - expect(screen.getByText(/Page not/i)).toBeInTheDocument(); - expect(screen.getByText(/Found/i)).toBeInTheDocument(); + expect(screen.getByText('404')).toBeInTheDocument(); }); -}); +}); \ No newline at end of file diff --git a/app/__tests__/pre-push-check.test.ts b/app/__tests__/pre-push-check.test.ts new file mode 100644 index 0000000..1cb17e9 --- /dev/null +++ b/app/__tests__/pre-push-check.test.ts @@ -0,0 +1,593 @@ +/** + * Pre-Push Content & Quality Check + * + * Comprehensive static analysis that catches bugs BEFORE push: + * 1. i18n: key parity, namespace coverage, ClientWrapper gaps, missing keys + * 2. Accessibility: buttons need aria-label or text, images need alt, forms need labels + * 3. Conventions: no raw hex colors, no emojis, no unguarded console.error in client code + * 4. Email: all mailto: links use valid email format + * 5. Environment variables: all process.env.XXX are documented + * 6. Code quality: no `any` type in app/ source files + * + * Run: npx jest app/__tests__/pre-push-check.test.ts --verbose + * (or just `npm test` — it's included in the suite) + */ + +import fs from "fs"; +import path from "path"; + +const ROOT = path.resolve(__dirname, "../.."); +const SRC_ROOT = path.resolve(__dirname, ".."); +const en = JSON.parse(fs.readFileSync(path.join(ROOT, "messages/en.json"), "utf-8")); +const de = JSON.parse(fs.readFileSync(path.join(ROOT, "messages/de.json"), "utf-8")); +const LOCALES: Record> = { en, de }; + +function collectPaths(obj: unknown, prefix = ""): string[] { + if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return [prefix]; + return Object.entries(obj as Record).flatMap(([k, v]) => + collectPaths(v, prefix ? `${prefix}.${k}` : k), + ); +} + +function resolve(obj: unknown, keyPath: string): unknown { + return keyPath.split(".").reduce((o, k) => { + if (o === null || typeof o !== "object") return undefined; + return (o as Record)[k]; + }, obj); +} + +function findTsxFiles(dir: string, excludeTests = true): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== ".next") { + if (excludeTests && entry.name === "__tests__") return []; + return findTsxFiles(full, excludeTests); + } + return entry.name.endsWith(".tsx") || entry.name.endsWith(".ts") ? [full] : []; + }); +} + +function readFile(p: string): string { + return fs.readFileSync(p, "utf-8"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. i18n: Structural parity +// ═══════════════════════════════════════════════════════════════════════════ + +describe("i18n: message files", () => { + it("en.json and de.json have identical key paths", () => { + const enPaths = new Set(collectPaths(en)); + const dePaths = new Set(collectPaths(de)); + const missingInDe = [...enPaths].filter((p) => !dePaths.has(p)); + const missingInEn = [...dePaths].filter((p) => !enPaths.has(p)); + expect(missingInDe).toEqual([]); + expect(missingInEn).toEqual([]); + }); + + it("no empty string values in any locale", () => { + for (const [locale, messages] of Object.entries(LOCALES)) { + const emptyPaths = collectPaths(messages).filter((p) => { + const val = p.split(".").reduce((o, k) => (o as Record)[k], messages); + return val === ""; + }); + if (emptyPaths.length > 0) { + throw new Error(`Empty translations in ${locale}: ${emptyPaths.join(", ")}`); + } + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. i18n: ClientWrapper namespace coverage +// ═══════════════════════════════════════════════════════════════════════════ + +const WRAPPED_COMPONENT_NAMESPACES: Record = { + About: ["home.about", "about"], + Projects: ["home.projects"], + Contact: ["home.contact", "home.contact.form", "home.contact.info"], + Footer: ["footer"], +}; + +const CLIENT_WRAPPER_PROVIDES: Record = { + AboutClient: ["home.about", "about"], + ProjectsClient: ["home.projects"], + ContactClient: ["home.contact"], + FooterClient: ["footer"], +}; + +describe("i18n: ClientWrappers provide all requested namespaces", () => { + const componentToWrapper: Record = { + About: "AboutClient", + Projects: "ProjectsClient", + Contact: "ContactClient", + Footer: "FooterClient", + }; + + for (const [component, wrapper] of Object.entries(componentToWrapper)) { + const required = WRAPPED_COMPONENT_NAMESPACES[component]; + const provided = CLIENT_WRAPPER_PROVIDES[wrapper]; + + describe(`${component} → ${wrapper}`, () => { + for (const ns of required) { + it(`provides namespace "${ns}"`, () => { + const found = provided.some((p) => ns === p || ns.startsWith(p + ".")); + if (!found) { + throw new Error( + `${component} calls useTranslations("${ns}") but ${wrapper} does not provide it.\n` + + ` Provided: [${provided.join(", ")}]\n` + + ` Fix: add "${ns}" to the messages object in ${wrapper}`, + ); + } + }); + } + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. i18n: Static key coverage +// ═══════════════════════════════════════════════════════════════════════════ + +const STATIC_KEYS: Record> = { + nav: { Header: ["home", "about", "projects", "contact"] }, + common: { + "books/page": ["back"], "legal-notice/page": ["back"], "privacy-policy/page": ["back"], + ProjectDetailClient: ["back"], ProjectsPageClient: ["backToHome"], + }, + consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] }, + "home.hero": { Hero: ["badge", "line1", "line2", "description", "ctaWork", "ctaContact"] }, + "home.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] }, + "home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] }, + "home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] }, + "home.about.readBooks": { ReadBooks: ["title", "showMore", "showLess", "readMore", "collapseReview", "finishedAt", "empty"] }, + "home.projects": { Projects: ["title", "subtitle", "viewAll", "noProjects"] }, + "home.contact": { Contact: ["title", "subtitle"] }, + "home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] }, + "home.contact.info": { Contact: ["locationValue"] }, + about: { About: ["status", "aiAssistant", "library", "viewAll", "myGear", "gearMain", "gearPC", "gearServer", "gearOS", "curiosity"] }, + "projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] }, + "projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] }, + footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] }, + notFound: { "not-found": ["title", "description", "returnHome", "goBack", "exploreWork", "exploreWorkDesc", "viewProjects", "errorReport"] }, + books: { "books/page": ["title", "subtitle", "empty"] }, +}; + +describe("i18n: all static translation keys resolve", () => { + for (const [namespace, components] of Object.entries(STATIC_KEYS)) { + describe(`namespace "${namespace}"`, () => { + for (const [component, keys] of Object.entries(components)) { + for (const keyPath of keys) { + it(`${component} → "${keyPath}"`, () => { + for (const locale of Object.keys(LOCALES)) { + const nsObj = resolve(LOCALES[locale], namespace); + if (nsObj === undefined) throw new Error(`Namespace "${namespace}" missing in ${locale}.json`); + const val = resolve(nsObj, keyPath); + if (val === undefined) throw new Error(`Key "${keyPath}" missing in ${locale}.json → ${namespace}`); + if (val === "") throw new Error(`Key "${keyPath}" is empty string in ${locale}.json → ${namespace}`); + } + }); + } + } + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. i18n: Unregistered namespaces detected +// ═══════════════════════════════════════════════════════════════════════════ + +describe("i18n: no unregistered useTranslation namespaces", () => { + const knownNamespaces = new Set(Object.keys(STATIC_KEYS)); + const allTsx = findTsxFiles(SRC_ROOT); + + const useNamespaceRegex = /useTranslations\(["']([^"']+)["']\)/g; + const serverNamespaceRegex = /getTranslations\s*\(\s*\{[^}]*namespace\s*:\s*["']([^"']+)["']/g; + + const foundInSource = new Map(); + + for (const file of allTsx) { + const content = readFile(file); + const rel = path.relative(SRC_ROOT, file); + let match: RegExpExecArray | null; + useNamespaceRegex.lastIndex = 0; + while ((match = useNamespaceRegex.exec(content)) !== null) { + if (!foundInSource.has(match[1])) foundInSource.set(match[1], []); + foundInSource.get(match[1])!.push(rel); + } + serverNamespaceRegex.lastIndex = 0; + while ((match = serverNamespaceRegex.exec(content)) !== null) { + if (!foundInSource.has(match[1])) foundInSource.set(match[1], []); + foundInSource.get(match[1])!.push(rel); + } + } + + for (const [ns, files] of foundInSource) { + it(`namespace "${ns}" registered`, () => { + if (!knownNamespaces.has(ns)) { + throw new Error(`Namespace "${ns}" used in source but not in STATIC_KEYS.\nUsed in: ${files.join(", ")}`); + } + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. Accessibility: Buttons need aria-label or visible text +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Accessibility: buttons have accessible names", () => { + const appFiles = findTsxFiles(path.join(SRC_ROOT, "components")); + + const ariaLabelRegex = /aria-label=["'{]/; + const titleAttrRegex = /title=["'{]/; + + const violations: string[] = []; + + for (const file of appFiles) { + const content = readFile(file); + const rel = path.relative(SRC_ROOT, file); + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.includes("]*>\s*\S/.test(line) || /]*>[^<]/.test(line); + + if (!hasAriaLabel && !hasTitle && !hasVisibleText) { + const isIconButton = /<\w+\s[^>]*size\s*=\s*\{?\d/.test(line) || line.includes("") || line.includes(" { + if (violations.length > 0) { + throw new Error(`Found ${violations.length} button(s) missing accessible names:\n${violations.join("\n")}`); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. Accessibility: Images must have alt text +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Accessibility: images have alt text", () => { + it("no tags missing alt attribute", () => { + const appFiles = findTsxFiles(SRC_ROOT); + const violations: string[] = []; + + for (const file of appFiles) { + if (file.includes("__tests__")) continue; + const content = readFile(file); + const rel = path.relative(SRC_ROOT, file); + + const imgRegex = /]*>/gs; + let match: RegExpExecArray | null; + while ((match = imgRegex.exec(content)) !== null) { + const tag = match[0]; + if (!tag.includes("alt=")) { + const lineNum = content.slice(0, match.index).split("\n").length; + violations.push(`${rel}:${lineNum}`); + } + } + imgRegex.lastIndex = 0; + } + + if (violations.length > 0) { + throw new Error(`Found tags without alt attribute:\n${violations.join("\n")}`); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. Accessibility: Forms must have aria-label or labeled inputs +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Accessibility: forms have labels", () => { + it("all
elements have aria-label or