From d6a1f60cf2bfe3cfb7ce932a095db82a864ce07c Mon Sep 17 00:00:00 2001
From: Tomasz Stanisz
Date: Tue, 6 Oct 2026 08:58:12 +0200
Subject: [PATCH 1/2] Add complete English and Polish portfolio localization
---
.gitignore | 3 +
.prettierignore | 3 +
AGENTS.md | 8 +-
README.md | 47 ++-
e2e/blog.spec.ts | 2 +-
e2e/localization.spec.ts | 357 ++++++++++++++++++
e2e/site.spec.ts | 35 +-
next.config.mjs | 2 +
playwright.config.ts | 2 +-
scripts/prepare-blog.mjs | 7 +-
src/app/{ => (en)}/blog/page.tsx | 0
src/app/{ => (en)}/colophon/page.mdx | 0
src/app/(en)/layout.tsx | 6 +
src/app/(en)/look/[look]/card.jpg/route.tsx | 10 +
src/app/{ => (en)}/look/[look]/page.tsx | 0
src/app/{ => (en)}/page.tsx | 0
src/app/(en)/work/[slug]/page.tsx | 13 +
src/app/{ => (pl)}/blog/pl/page.tsx | 0
src/app/(pl)/layout.tsx | 6 +
src/app/(pl)/pl/colophon/page.mdx | 64 ++++
.../(pl)/pl/look/[look]/card.jpg/route.tsx | 10 +
src/app/(pl)/pl/look/[look]/page.tsx | 48 +++
src/app/(pl)/pl/page.tsx | 6 +
src/app/(pl)/pl/work/[slug]/page.tsx | 13 +
src/app/global-not-found.tsx | 24 ++
src/app/not-found.tsx | 30 --
src/app/sitemap.ts | 20 +-
src/commands.ts | 75 +++-
src/components/Blog/BlogArticle.tsx | 9 +-
src/components/Blog/BlogIndex.tsx | 7 +-
src/components/Blog/BlogList.tsx | 16 +-
src/components/Blog/blog.css | 33 --
.../CaseStudy/CaseStudyPage.tsx} | 82 ++--
src/components/CaseStudy/CaseStudyParts.tsx | 10 +-
.../CommandPalette/CommandPalette.tsx | 64 +++-
.../layout.tsx => components/Document.tsx} | 20 +-
src/components/FooterPanel/FooterPanel.tsx | 12 +-
src/components/Hero/Hero.tsx | 12 +-
.../route.tsx => components/LookCard.tsx} | 91 +++--
src/components/NavBar/LanguageSwitch.tsx | 56 +++
src/components/NavBar/NavBar.tsx | 59 ++-
src/components/NavBar/navbar.css | 4 +-
src/content/blog/generation.test.ts | 43 ++-
src/content/looks.ts | 14 +-
src/content/navigation.ts | 21 +-
src/content/publicProfile.ts | 5 +-
src/content/work/bodies.ts | 9 +
src/content/work/orange-cms.pl.mdx | 80 ++++
src/content/work/orange-e2e-testing.pl.mdx | 80 ++++
src/content/work/studies.ts | 65 +++-
src/content/work/theeventa-mvp.pl.mdx | 73 ++++
src/design/skin-terminal.css | 10 +
src/i18n/locales.ts | 2 +
src/i18n/localization.css | 69 ++++
src/i18n/localization.test.ts | 145 +++++++
src/i18n/profile.ts | 190 ++++++++++
src/i18n/routes.ts | 124 ++++++
src/lib/metadata.ts | 33 +-
src/lib/structuredData.ts | 24 +-
src/sections/Approach/Approach.tsx | 38 +-
src/sections/Background/Background.tsx | 63 ++--
src/sections/Contact/Contact.tsx | 36 +-
src/sections/HomePage.tsx | 66 ++--
src/sections/Now/NowSection.tsx | 40 +-
src/sections/Work/SelectedWork.tsx | 141 +++----
src/utils/scrollToSection.ts | 2 +
66 files changed, 2226 insertions(+), 413 deletions(-)
create mode 100644 e2e/localization.spec.ts
rename src/app/{ => (en)}/blog/page.tsx (100%)
rename src/app/{ => (en)}/colophon/page.mdx (100%)
create mode 100644 src/app/(en)/layout.tsx
create mode 100644 src/app/(en)/look/[look]/card.jpg/route.tsx
rename src/app/{ => (en)}/look/[look]/page.tsx (100%)
rename src/app/{ => (en)}/page.tsx (100%)
create mode 100644 src/app/(en)/work/[slug]/page.tsx
rename src/app/{ => (pl)}/blog/pl/page.tsx (100%)
create mode 100644 src/app/(pl)/layout.tsx
create mode 100644 src/app/(pl)/pl/colophon/page.mdx
create mode 100644 src/app/(pl)/pl/look/[look]/card.jpg/route.tsx
create mode 100644 src/app/(pl)/pl/look/[look]/page.tsx
create mode 100644 src/app/(pl)/pl/page.tsx
create mode 100644 src/app/(pl)/pl/work/[slug]/page.tsx
create mode 100644 src/app/global-not-found.tsx
delete mode 100644 src/app/not-found.tsx
rename src/{app/work/[slug]/page.tsx => components/CaseStudy/CaseStudyPage.tsx} (57%)
rename src/{app/layout.tsx => components/Document.tsx} (92%)
rename src/{app/look/[look]/card.jpg/route.tsx => components/LookCard.tsx} (88%)
create mode 100644 src/components/NavBar/LanguageSwitch.tsx
create mode 100644 src/content/work/orange-cms.pl.mdx
create mode 100644 src/content/work/orange-e2e-testing.pl.mdx
create mode 100644 src/content/work/theeventa-mvp.pl.mdx
create mode 100644 src/i18n/locales.ts
create mode 100644 src/i18n/localization.css
create mode 100644 src/i18n/localization.test.ts
create mode 100644 src/i18n/profile.ts
create mode 100644 src/i18n/routes.ts
diff --git a/.gitignore b/.gitignore
index 3c86d8f..ef54361 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,6 @@ workspace.xml
/src/content/blog/.generated/
/src/app/blog/en/
/src/app/blog/pl/*/
+
+/src/app/(en)/blog/en/
+/src/app/(pl)/blog/pl/*/
diff --git a/.prettierignore b/.prettierignore
index 53fac93..f7f4e1d 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,6 @@
src/content/blog/.generated/
src/app/blog/en/
src/app/blog/pl/*/
+
+/src/app/(en)/blog/en/
+/src/app/(pl)/blog/pl/*/
diff --git a/AGENTS.md b/AGENTS.md
index 0abb7d3..7abfe18 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -36,7 +36,7 @@ All of these must pass before a change is done. CI (`.github/actions/check/`) ru
## Where things live
-- `src/app/` — routes. `layout.tsx` is the shell; `page.tsx` is the home page; `colophon/page.mdx` is "How this site is built".
+- `src/app/` — routes. `(en)/layout.tsx` and `(pl)/layout.tsx` share the document shell; `page.tsx` is the home page; `colophon/page.mdx` is "How this site is built".
- `src/content/publicProfile.ts` — all public wording and links, typed by `Profile`. `src/content/navigation.ts` — the home page sections.
- `src/sections/` — the home page sections, rendered as Server Components.
- `src/components/NavBar/` — the only client entry point: header, theme and skin controls, and it mounts `CommandPalette/`.
@@ -44,16 +44,16 @@ All of these must pass before a change is done. CI (`.github/actions/check/`) ru
- `src/utils/` — theme, skin, preferences store, `scrollToSection`, console greeting.
- `src/design/skin-terminal.css` — every rule of the optional Terminal skin.
- `src/content/work/` — case studies: facts and `published` in `studies.ts`, text in `.mdx`, mapped in `bodies.ts`. Pages render at `/work//`.
-- `src/content/looks.ts` and `src/app/look/[look]/` — one copy of the home page per skin and theme, each with a preview card rendered at build time from `card.jpg/route.tsx`. Card colours mirror the CSS tokens; keep them in sync when the design changes.
+- `src/content/looks.ts` and `src/app/(en)/look/[look]/` — one copy of the home page per skin and theme, each with a preview card rendered at build time from `card.jpg/route.tsx`. Card colours mirror the CSS tokens; keep them in sync when the design changes.
- `src/content/blog/` — typed bilingual registry and approved MDX bodies; `scripts/prepare-blog.mjs` generates ignored static routes and metadata before checks/builds. See README for the private local review workflow. Never copy unreviewed manuscripts into tracked public files or deploy a preview build.
- `e2e/site.spec.ts` and `e2e/blog.spec.ts` — Playwright suites; blog routes are discovered from the generated registry.
## Rules that are easy to break
-- **CSS order.** Every stylesheet is imported in `src/app/layout.tsx`, and nowhere else, with `skin-terminal.css` last. Its rules are scoped as `:where(html.skin-terminal) …` and win on order, not specificity. A component that imports its own CSS loads after the skin and breaks it.
+- **CSS order.** Every stylesheet is imported in `src/components/Document.tsx`, and nowhere else, with `skin-terminal.css` last. Its rules are scoped as `:where(html.skin-terminal) …` and win on order, not specificity. A component that imports its own CSS loads after the skin and breaks it.
- **Terminal skin coverage.** New UI needs Terminal styles in `skin-terminal.css`, and must be checked in both skins and both themes.
- **Theme and skin state.** Read it with `usePreferences()`, and change it with `applyTheme` or `applySkin`. Don't read `document` or `localStorage` during render, because the server renders without them.
-- **Before-paint script.** The inline script in `layout.tsx` applies the theme and skin before paint (share-link `?skin=`/`?theme=` first, then the visit's link choice in `sessionStorage`, then saved choices), and owns the `theme-color` meta tag. Keep its colours in sync with `THEME_COLORS` in `src/utils/theme.ts`. Don't add `themeColor` to Next metadata, because React would duplicate the tag.
+- **Before-paint script.** The inline script in `Document.tsx` applies the theme and skin before paint (share-link `?skin=`/`?theme=` first, then the visit's link choice in `sessionStorage`, then saved choices), and owns the `theme-color` meta tag. Keep its colours in sync with `THEME_COLORS` in `src/utils/theme.ts`. Don't add `themeColor` to Next metadata, because React would duplicate the tag.
- **Client code.** Keep content in Server Components. Add `"use client"` only where interaction needs it.
- **Section links.** In-page section links go through `scrollToSection`, which moves focus to the section's heading. Off the home page they point to `/#id`.
- **Per-page metadata.** Every page sets it with `pageMetadata()` from `src/lib/metadata.ts`. New public pages also belong in `src/app/sitemap.ts` (the Playwright suite checks the sitemap matches `PAGES`).
diff --git a/README.md b/README.md
index d9fed85..a2e82cb 100644
--- a/README.md
+++ b/README.md
@@ -42,11 +42,11 @@ GitHub Actions runs all of these on Node 24.21.0, through the shared steps in `.
## Active structure
-- `src/app/layout.tsx` — the page shell: fonts (`next/font`, self-hosted at build), the inline script that applies a saved theme and skin before first paint, the header and footer, and every stylesheet in cascade order
+- `src/components/Document.tsx` — the page shell: fonts (`next/font`, self-hosted at build), the inline script that applies a saved theme and skin before first paint, the header and footer, and every stylesheet in cascade order
- `src/app/page.tsx` — the home page, rendered by `src/sections/HomePage.tsx` (shared with the `/look/` copies): the hero, then Work ("Proven in production"), Now, Approach, Background and Contact
- `src/app/colophon/page.mdx` — "How this site is built", linked from the footer and the palette. Its performance table is a dated snapshot; re-measure before changing it.
- `src/app/work/[slug]/page.tsx` — case study pages. Facts and the published flag live in `src/content/work/studies.ts`, the text in `src/content/work/.mdx` (mapped in `bodies.ts`). A published study gets its page, a link from its card on the home page, and a palette command (`cat work/.md`). Each page has a dark header band with the study's `outcome` tile and a fact strip, an "On this page" list built from the MDX `h2`s (`src/lib/headings.ts`, highlighted by `src/components/CaseStudy/CaseStudyToc.tsx`), and a link to the next study (`nextCaseStudy`). `src/components/Flow/` draws the step diagrams; ``, `` and `` (`src/components/CaseStudy/CaseStudyParts.tsx`) lay out the options and the retrospective without changing their wording.
-- `src/app/look/[look]/` — the home page per look (`page.tsx`) and its 1200×630 preview card (`card.jpg/route.tsx`, drawn with `next/og` using the subset TTF fonts in `src/assets/fonts/`). The looks are listed in `src/content/looks.ts`.
+- `src/app/(en)/look/[look]/` — the home page per look (`page.tsx`) and its 1200×630 preview card (`card.jpg/route.tsx`, drawn with `next/og` using the subset TTF fonts in `src/assets/fonts/`). The looks are listed in `src/content/looks.ts`.
- `src/app/sitemap.ts` — `out/sitemap.xml`: the home page, the case studies and the colophon (not the `/look/` copies). `public/robots.txt` points to it.
- `src/lib/structuredData.ts` — schema.org JSON-LD: a `Person` and `WebSite` on the home page and its look copies, an `Article` on each case study. Rendered by `src/components/JsonLd/`, with `<` escaped.
- `src/app/not-found.tsx` — exported as `out/404.html`
@@ -67,7 +67,7 @@ GitHub Actions runs all of these on Node 24.21.0, through the shared steps in `.
- `src/sections/Work/`, `src/sections/Now/`, `src/sections/Approach/`, `src/sections/Background/`, `src/sections/Contact/` — the sections after the hero, rendered to static HTML through `src/components/SectionBand/`
- `src/components/FooterPanel/` — copyright and GitHub
-All CSS is imported in `src/app/layout.tsx` and nowhere else, with `skin-terminal.css` last. The Terminal rules use `:where()`, so they win on order, not specificity. Next.js loads page-level CSS after layout CSS, so a component importing its own stylesheet would load after the skin and override it.
+All CSS is imported in `src/components/Document.tsx` and nowhere else, with `skin-terminal.css` last. The Terminal rules use `:where()`, so they win on order, not specificity. Next.js loads page-level CSS after layout CSS, so a component importing its own stylesheet would load after the skin and override it.
The standard skin keeps the CV's palette and fonts. The header, the hero, the approach section and the Now building card are dark bands in both themes, and contact is a teal band. Inside a `.band` (and the header's inner row) `src/index.css` re-points the generic tokens (`--text`, `--surface`, `--accent`, …) to band values, so components inside need no colours of their own; the Terminal skin resets them to its own tokens.
@@ -87,7 +87,7 @@ Releases are tagged on `main` and listed on the repository's Releases page. `pac
- Escape closes the mobile menu only while focus is inside that open menu, then returns focus to Menu. Escape elsewhere does not move focus.
- Below 801px the closed menu is not in the tab order. At 801px and above the section links stay available, including after a resize from an open or closed mobile menu.
- The theme button stores `dark` or `light` under `portfolio-theme`. With nothing stored, the page follows `prefers-color-scheme`.
-- The `>_` button switches between the standard style and the optional Terminal style, independently of light/dark. It stores `terminal` under `portfolio-skin` and removes the key for the standard style. The inline script in `src/app/layout.tsx` applies a saved skin before first paint. JetBrains Mono is self-hosted and not preloaded, so the browser downloads it only when Terminal is used.
+- The `>_` button switches between the standard style and the optional Terminal style, independently of light/dark. It stores `terminal` under `portfolio-skin` and removes the key for the standard style. The inline script in `src/components/Document.tsx` applies a saved skin before first paint. JetBrains Mono is self-hosted and not preloaded, so the browser downloads it only when Terminal is used.
- ⌘K (Ctrl+K elsewhere) opens the command palette from anywhere except other text fields. From 1024px wide, a header button opens it too. It can go to a section, open the CV, LinkedIn, GitHub or this repository, copy the email address, and switch theme or style. Arrow keys choose, Enter runs, and Escape or a click outside closes it and returns focus. In the Terminal style, the same palette is a shell prompt: each command has an alias (`cd work`, `cat cv`, `theme dark`), `help` lists them all, `exit` closes it, and unknown input reports `command not found`. Everything in it is also reachable without it.
- Share links pick the exact look: `?skin=terminal|standard` and `?theme=dark|light`, together or alone, on any page (for example `/?skin=terminal&theme=dark`). A link applies only for that visit. It holds across reloads and pages in that tab through `sessionStorage` (`portfolio-link-skin`, `portfolio-link-theme`), and never overwrites the visitor's saved choice. The parameters apply only when the link is opened, not on a reload or Back/Forward, so a choice made after arriving survives a refresh even though the parameters stay in the address bar. Using the theme or style controls saves the visitor's own choice and replaces the linked look. System dark-mode changes don't override a linked theme. Invalid values are ignored, and links still apply when storage is blocked.
- The home page also has one address per look: `/look/standard-light/`, `/look/standard-dark/`, `/look/terminal-light/` and `/look/terminal-dark/`. Each is a full copy of the home page that opens in that look under the same visit-only rule, names `/` as its canonical page, and carries its own link-preview card (`/look//card.jpg`, rendered at build time). Static hosting ignores the query string, so `?skin=` links can't have their own previews; these pages can.
@@ -114,7 +114,7 @@ The blog shares the existing MDX renderer, design tokens, standard/Terminal skin
Maintain approved articles in `src/content/blog/catalog.ts` and MDX bodies under `src/content/blog/articles/`. The typed registry requires English and Polish titles, descriptions, topics and body references together. Set `publishedAt` to the actual release date, and only set `published: true` when both editions have been reviewed and publication is authorized. `updatedAt` is for substantive later changes. The optional `developmentAsOf` renders a dated development note. Reading time is derived independently from each body.
-`npm run prepare:blog` validates the registry and generates concrete static article routes, metadata and localized share-card routes. It runs before dev, build, lint, typecheck and unit tests. Generated files in `src/content/blog/.generated/`, `src/app/blog/en/` and the article subdirectories of `src/app/blog/pl/` are ignored: never edit or commit them. Concrete routes allow an empty initial catalogue without exporting a placeholder article. Run checks sequentially, because preparation replaces those generated files.
+`npm run prepare:blog` validates the registry and generates concrete static article routes, metadata and localized share-card routes. It runs before dev, build, lint, typecheck and unit tests. Generated files in `src/content/blog/.generated/`, `src/app/(en)/blog/en/` and the article subdirectories of `src/app/(pl)/blog/pl/` are ignored: never edit or commit them. Concrete routes allow an empty initial catalogue without exporting a placeholder article. Run checks sequentially, because preparation replaces those generated files.
The homepage shows up to two published articles between Background and Contact and hides the section when none exist. Both indexes and published editions appear in the command palette and sitemap. The production catalogue includes the approved Car Brain/Appwrite article in English and Polish. It builds normally without private manuscript files or BLOG_PREVIEW_DIR.
@@ -136,3 +136,40 @@ npm run test:e2e
```
Article headings must have unique, non-empty generated IDs. Polish characters are normalized for heading anchors. Use `ClientArchitecture` only for the Car Brain article, with `locale="en"` or `locale="pl"`; it distinguishes implemented authentication from planned web data paths. Publication does not enable the independent Car Brain launch card or App Store links.
+
+## English / Polish localization
+
+English URLs stay unchanged. Polish portfolio pages live under `/pl/`, including
+`/pl/work//`, `/pl/colophon/` and `/pl/look//`. The bilingual blog
+keeps `/blog/`, `/blog/pl/`, `/blog/en//` and `/blog/pl//`.
+There is no automatic locale selection or redirect.
+
+- `(en)` and `(pl)` are separate root layout groups sharing
+ `src/components/Document.tsx`. Raw exported HTML has the correct document
+ language. Crossing locale roots may reload the document; appearance is retained.
+- `src/i18n/routes.ts` owns route identities, pairs and explicit heading
+ correspondence. Keep existing English heading IDs; add semantic EN/PL pairs
+ when adding translated headings. Unknown fragments reset to the page top.
+- `src/content/publicProfile.ts` owns shared facts/links and English wording;
+ `src/i18n/profile.ts` owns Polish wording. Stable contribution IDs, companies,
+ links, publication flags and numerical bar widths are shared. Case study
+ translations are `.pl.mdx`; metadata is paired in `studies.ts`.
+- The header language links work without JavaScript. With JavaScript they retain
+ the current equivalent fragment and resolved theme/skin. Below 640px the same
+ control occupies a second row; desktop navigation starts at 1100px.
+- Stylesheets are imported only by `Document.tsx`, with Terminal overrides last.
+ `src/i18n/localization.css` owns the shared language control and header offsets.
+- Blog preparation now writes routes to `src/app/(en)/blog/en//` and
+ `src/app/(pl)/blog/pl//`. Its manifest recognizes old generated leaf
+ directories for migration, never broad application directories. Keep generated
+ paths ignored. Normal preparation excludes private previews.
+- `global-not-found.tsx` provides the bilingual static 404 with `noindex`.
+ Locale metadata uses reciprocal alternatives and English `x-default`. Look
+ previews canonicalize to the corresponding homepage and stay out of the sitemap.
+- `npm test` checks locale pairs, headings, shared facts, commands and generator
+ migration. `e2e/localization.spec.ts` covers raw exports and rendered EN/PL UI.
+ Set `PLAYWRIGHT_PORT` when another project's preview occupies the default 4173.
+
+Run checks sequentially: lint, typecheck, Prettier, unit tests, production build,
+then Playwright. Generation hooks modify generated files; do not run checks
+concurrently with a private preview in the same checkout.
diff --git a/e2e/blog.spec.ts b/e2e/blog.spec.ts
index ef656aa..b9fdb75 100644
--- a/e2e/blog.spec.ts
+++ b/e2e/blog.spec.ts
@@ -24,7 +24,7 @@ test("blog navigation works separately from homepage section scrolling", async (
await page.getByRole("link", { name: "Blog", exact: true }).click();
await expect(page).toHaveURL(/\/blog\/$/);
await page.getByRole("link", { name: "Polski", exact: true }).first().click();
- await expect(page).toHaveURL(/\/blog\/pl\/$/);
+ await expect(page).toHaveURL(/\/blog\/pl\/(?:\?.*)?$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Artykuły");
});
for (const { path, locale, post } of articlePaths) {
diff --git a/e2e/localization.spec.ts b/e2e/localization.spec.ts
new file mode 100644
index 0000000..b80b077
--- /dev/null
+++ b/e2e/localization.spec.ts
@@ -0,0 +1,357 @@
+import { test, expect } from "@playwright/test";
+import AxeBuilder from "@axe-core/playwright";
+import { readFileSync, existsSync } from "node:fs";
+import { localizedPath, headingPairs } from "../src/i18n/routes";
+import { slugify } from "../src/lib/headings";
+import { visiblePosts } from "../src/content/blog/.generated/posts";
+const slug = visiblePosts[0].slug;
+const english = [
+ "/",
+ "/work/orange-cms/",
+ "/work/orange-e2e-testing/",
+ "/work/theeventa-mvp/",
+ "/colophon/",
+ "/blog/",
+ `/blog/en/${slug}/`,
+];
+const all = english.flatMap((p) => [p, localizedPath(p, "pl")]);
+
+test("exported documents have language, reciprocal metadata, sitemap and bilingual 404", async ({
+ request,
+}) => {
+ const sitemap = readFileSync("out/sitemap.xml", "utf8");
+ for (const path of all) {
+ const pl = path.startsWith("/pl/") || path.startsWith("/blog/pl/");
+ const html = readFileSync(`out${path}index.html`, "utf8");
+ expect(html).toContain(` {
+ const context = await browser.newContext({ javaScriptEnabled: false });
+ const page = await context.newPage();
+ for (const en of english) {
+ const pl = localizedPath(en, "pl");
+ await page.goto(pl);
+ await expect(page.locator("html")).toHaveAttribute("lang", "pl");
+ await expect(page.locator("h1")).toBeVisible();
+ const link = page
+ .locator(".language-switch")
+ .getByRole("link", { name: "English", exact: true });
+ await expect(link).toHaveAttribute("href", en);
+ await link.click();
+ await expect(page).toHaveURL(new RegExp(en + "$"));
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
+ }
+ await context.close();
+});
+for (const skin of ["standard", "terminal"])
+ for (const theme of ["light", "dark"])
+ for (const width of [1440, 768, 390, 320]) {
+ test(`localized layout ${skin} ${theme} ${width}`, async ({ page }) => {
+ test.setTimeout(180000);
+ await page.setViewportSize({ width, height: 900 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ // Every page at desktop/mobile; four representative pairs at other widths.
+ const paths =
+ width === 1440 || width === 390
+ ? all
+ : [
+ "/",
+ "/pl/",
+ "/work/orange-cms/",
+ "/pl/work/orange-cms/",
+ "/blog/",
+ "/blog/pl/",
+ `/blog/en/${slug}/`,
+ `/blog/pl/${slug}/`,
+ ];
+ for (const path of paths) {
+ await page.goto(`${path}?skin=${skin}&theme=${theme}`);
+ await page.locator(".language-switch a").first().waitFor();
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth
+ )
+ ).toBe(true);
+ const boxes = await page
+ .locator(
+ ".site-header .brand, .site-header button, .language-switch a, .site-header .site-nav a"
+ )
+ .evaluateAll((els) =>
+ els
+ .filter((el) => (el as HTMLElement).offsetParent !== null)
+ .map((el) => {
+ const r = el.getBoundingClientRect();
+ return {
+ name: el.textContent,
+ left: r.left,
+ right: r.right,
+ top: r.top,
+ bottom: r.bottom,
+ };
+ })
+ );
+ const header = await page.locator(".site-header").boundingBox();
+ for (const box of boxes) {
+ expect(box.top).toBeGreaterThanOrEqual(header!.y);
+ expect(box.bottom).toBeLessThanOrEqual(
+ header!.y + header!.height + 1
+ );
+ }
+ for (const [i, a] of boxes.entries())
+ for (const b of boxes.slice(i + 1))
+ expect(
+ a.left < b.right - 1 &&
+ b.left < a.right - 1 &&
+ a.top < b.bottom - 1 &&
+ b.top < a.bottom - 1,
+ `${path}: ${a.name} overlaps ${b.name}`
+ ).toBe(false);
+ const scan = await new AxeBuilder({ page })
+ .withTags(["wcag2a", "wcag2aa", "wcag21aa"])
+ .analyze();
+ expect(scan.violations).toEqual([]);
+ for (const a of await page.locator(".case-study-toc a").all()) {
+ const href = await a.getAttribute("href");
+ if (href?.startsWith("#"))
+ expect(await page.locator(href).count()).toBe(1);
+ }
+ await page.screenshot({
+ path: `test-results/localization/${skin}-${theme}-${width}-${path.replaceAll("/", "_")}.png`,
+ fullPage: true,
+ });
+ }
+ expect(errors).toEqual([]);
+ });
+ }
+for (const width of [639, 640, 1099, 1100])
+ for (const skin of ["standard", "terminal"]) {
+ test(`header boundary ${width} ${skin}`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto(`/pl/?skin=${skin}&theme=dark`);
+ const brand = await page.locator(".brand").boundingBox(),
+ switchBox = await page.locator(".language-switch").boundingBox();
+ if (width < 640)
+ expect(switchBox!.y).toBeGreaterThanOrEqual(
+ brand!.y + brand!.height - 1
+ );
+ else expect(Math.abs(switchBox!.y - brand!.y)).toBeLessThan(15);
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth
+ )
+ ).toBe(true);
+ const boxes = await page
+ .locator(
+ ".site-header .brand, .site-header button, .language-switch a, .site-header .site-nav a"
+ )
+ .evaluateAll((els) =>
+ els
+ .filter((el) => (el as HTMLElement).offsetParent !== null)
+ .map((el) => {
+ const r = el.getBoundingClientRect();
+ return {
+ name: el.textContent,
+ left: r.left,
+ right: r.right,
+ top: r.top,
+ bottom: r.bottom,
+ };
+ })
+ );
+ for (const [i, a] of boxes.entries())
+ for (const b of boxes.slice(i + 1))
+ expect(
+ a.left < b.right - 1 &&
+ b.left < a.right - 1 &&
+ a.top < b.bottom - 1 &&
+ b.top < a.bottom - 1,
+ `${a.name} overlaps ${b.name}`
+ ).toBe(false);
+ const menu = page.getByRole("button", { name: "Menu", exact: true });
+ if (width < 1100) {
+ await menu.click();
+ await expect(
+ page.getByRole("link", { name: "Realizacje", exact: true })
+ ).toBeVisible();
+ await page
+ .getByRole("link", { name: "Realizacje", exact: true })
+ .focus();
+ await page.keyboard.press("Escape");
+ await expect(menu).toBeFocused();
+ } else await expect(menu).toBeHidden();
+ });
+ }
+test("switch keeps headings, appearance and browser history even with blocked storage", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ for (const store of ["localStorage", "sessionStorage"])
+ Object.defineProperty(window, store, {
+ get() {
+ throw new Error("blocked");
+ },
+ });
+ });
+ await page.goto("/work/orange-cms/?skin=terminal&theme=dark#the-decision");
+ const pl = page
+ .locator(".language-switch")
+ .getByRole("link", { name: "Polski" });
+ await expect(pl).toHaveAttribute(
+ "href",
+ /\/pl\/work\/orange-cms\/\?skin=terminal&theme=dark#decyzja/
+ );
+ await pl.click();
+ await expect(page.locator("html")).toHaveAttribute("lang", "pl");
+ await expect(page.locator("html")).toHaveClass(/skin-terminal/);
+ await expect(page.locator("html")).toHaveClass(/dark/);
+ await expect(page).toHaveURL(/#decyzja$/);
+ await page.goBack();
+ await expect(page).toHaveURL(/#the-decision$/);
+ await page.goForward();
+ await expect(page).toHaveURL(/#decyzja$/);
+ await page.keyboard.press("Control+k");
+ await page
+ .getByRole("combobox", { name: "Szukaj poleceń" })
+ .fill("Realizacje");
+ await page.keyboard.press("Enter");
+ await expect(page).toHaveURL(/\/pl\/#work$/);
+});
+test("blog headings use explicit EN PL correspondence", async ({ page }) => {
+ const [en, pl] = Object.entries(headingPairs[`article/${slug}`])[0];
+ await page.goto(`/blog/en/${slug}/#${slugify(en)}`);
+ await page
+ .locator(".language-switch")
+ .getByRole("link", { name: "Polski" })
+ .click();
+ await expect(page).toHaveURL(new RegExp("#" + slugify(pl) + "$"));
+});
+test("English stays default regardless of browser language; modified click leaves current page", async ({
+ browser,
+}) => {
+ const context = await browser.newContext({ locale: "pl-PL" });
+ const page = await context.newPage();
+ await page.goto("/");
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
+ const popupPromise = context.waitForEvent("page");
+ await page
+ .locator(".language-switch")
+ .getByRole("link", { name: "Polski" })
+ .click({ modifiers: ["Meta"] });
+ const popup = await popupPromise;
+ await popup.waitForLoadState();
+ await expect(popup.locator("html")).toHaveAttribute("lang", "pl");
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
+ await context.close();
+});
+
+test("homepage scrolling updates language links and unknown fragment resets", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByRole("link", { name: "See the work", exact: true }).click();
+ await expect(
+ page.locator(".language-switch").getByRole("link", { name: "Polski" })
+ ).toHaveAttribute("href", /#work$/);
+ await page
+ .locator(".language-switch")
+ .getByRole("link", { name: "Polski" })
+ .click();
+ await expect(page).toHaveURL(/#work$/);
+ await expect(page.locator("html")).toHaveAttribute("lang", "pl");
+ await page.goto("/work/orange-cms/#missing-fragment");
+ await page
+ .locator(".language-switch")
+ .getByRole("link", { name: "Polski" })
+ .click();
+ expect(new URL(page.url()).hash).toBe("");
+});
+
+test("Polish look previews retain appearance and localized share cards on desktop and mobile", async ({
+ page,
+}) => {
+ test.setTimeout(90000);
+ for (const width of [1440, 390])
+ for (const skin of ["standard", "terminal"])
+ for (const theme of ["light", "dark"]) {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto(`/pl/look/${skin}-${theme}/`);
+ await expect(page.locator("html")).toHaveAttribute("lang", "pl");
+ await expect(page.locator("html")).toHaveClass(
+ new RegExp(`${theme}-theme`)
+ );
+ if (skin === "terminal")
+ await expect(page.locator("html")).toHaveClass(/skin-terminal/);
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth
+ )
+ ).toBe(true);
+ await expect(
+ page
+ .locator(".language-switch")
+ .getByRole("link", { name: "English" })
+ ).toHaveAttribute("href", new RegExp(`^/look/${skin}-${theme}/\\?`));
+ await page.screenshot({
+ path: `test-results/localization/viewport-pl-${skin}-${theme}-${width}.png`,
+ });
+ }
+});
+test("Polish palette is keyboard accessible, announces copying and passes axe", async ({
+ page,
+ context,
+}) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ for (const skin of ["standard", "terminal"]) {
+ await page.goto(`/pl/?skin=${skin}&theme=dark`);
+ await page.keyboard.press("Control+k");
+ await expect(
+ page.getByRole("dialog", { name: "Paleta poleceń" })
+ ).toBeVisible();
+ expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
+ await page
+ .getByRole("combobox", { name: "Szukaj poleceń" })
+ .fill("copy email");
+ await page.keyboard.press("Enter");
+ await expect(page.locator(".palette-toast")).toHaveText(
+ "Skopiowano adres e-mail"
+ );
+ await expect
+ .poll(() => page.evaluate(() => navigator.clipboard.readText()))
+ .toBe("thomas.dev666@gmail.com");
+ }
+});
diff --git a/e2e/site.spec.ts b/e2e/site.spec.ts
index c4ee6b5..a092617 100644
--- a/e2e/site.spec.ts
+++ b/e2e/site.spec.ts
@@ -5,7 +5,7 @@ import { visiblePosts } from "../src/content/blog/.generated/posts";
import { blogArticlePath } from "../src/content/blog/model";
import { carBrain } from "../src/content/carBrain";
-// Every exported page. Add new routes here as they ship.
+// Original routes; Polish counterparts have full coverage in localization.spec.ts.
const PAGES = [
"/",
"/colophon/",
@@ -69,7 +69,7 @@ for (const path of PAGES) {
}
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
"content",
- `https://devthomas.pl${path.startsWith("/blog/en/") || /^\/blog\/pl\/[^/]+\/$/.test(path) ? `${path}card.jpg` : "/og-share.png"}`
+ `https://devthomas.pl${path.startsWith("/blog/en/") || /^\/blog\/pl\/[^/]+\/$/.test(path) ? `${path}card.jpg` : path === "/blog/pl/" ? "/pl/look/standard-light/card.jpg" : "/og-share.png"}`
);
});
}
@@ -90,7 +90,10 @@ test("the home page is readable without JavaScript", async ({ browser }) => {
const page = await context.newPage();
await page.goto("/");
await expect(
- page.getByRole("heading", { level: 1, name: /frontend architecture/ })
+ page.getByRole("heading", {
+ level: 1,
+ name: /I turn complex requirements into working software/,
+ })
).toBeVisible();
for (const name of [
"Proven in production",
@@ -801,7 +804,10 @@ test.describe("look pages", () => {
await expect(html(page)).toHaveClass(/skin-terminal/);
await expect(html(page)).toHaveClass(/dark-theme/);
await expect(
- page.getByRole("heading", { level: 1, name: /frontend architecture/ })
+ page.getByRole("heading", {
+ level: 1,
+ name: /I turn complex requirements into working software/,
+ })
).toBeVisible();
});
@@ -853,16 +859,19 @@ test.describe("search basics", () => {
.map((match) => match[1])
.sort();
expect(locations).toEqual(
- PAGES.filter(
- (path) =>
- !visiblePosts.some(
- (post) =>
- post.preview &&
- (["en", "pl"] as const).some(
- (locale) => blogArticlePath(post.slug, locale) === path
- )
- )
+ PAGES.flatMap((path) =>
+ path.startsWith("/blog/") ? [path] : [path, `/pl${path}`]
)
+ .filter(
+ (path) =>
+ !visiblePosts.some(
+ (post) =>
+ post.preview &&
+ (["en", "pl"] as const).some(
+ (locale) => blogArticlePath(post.slug, locale) === path
+ )
+ )
+ )
.map((path) => `https://devthomas.pl${path}`)
.sort()
);
diff --git a/next.config.mjs b/next.config.mjs
index 226a529..539b462 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -5,6 +5,8 @@ const nextConfig = {
// Hostinger serves plain files from the build branch: export every page as
// HTML, and write /work/x/ as out/work/x/index.html so deep links resolve.
output: "export",
+ experimental: { globalNotFound: true },
+ turbopack: { root: process.cwd() },
trailingSlash: true,
// Static export has no image server.
images: { unoptimized: true },
diff --git a/playwright.config.ts b/playwright.config.ts
index 93e43ee..aca5c71 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,6 +1,6 @@
import { defineConfig, devices } from "@playwright/test";
-const PORT = 4173;
+const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 4173);
// Runs against the static export in out/, the same files Hostinger serves.
// Build first: npm run build && npm run test:e2e
diff --git a/scripts/prepare-blog.mjs b/scripts/prepare-blog.mjs
index d4d8663..2f6e479 100644
--- a/scripts/prepare-blog.mjs
+++ b/scripts/prepare-blog.mjs
@@ -57,7 +57,10 @@ try {
if (e.code !== "ENOENT") throw e;
}
for (const path of previous) {
- if (!/^src\/app\/blog\/(en|pl)\/[a-z0-9-]+$/.test(path))
+ const match = /^src\/app\/(?:\((en|pl)\)\/)?blog\/(en|pl)\/[a-z0-9-]+$/.exec(
+ path
+ );
+ if (!match || (match[1] && match[1] !== match[2]))
throw new Error("Invalid generated route manifest");
await rm(join(root, path), { recursive: true, force: true });
}
@@ -76,7 +79,7 @@ for (const { post, sources } of loaded) {
};
visible.push(entry);
for (const lang of blogLocales) {
- const route = `src/app/blog/${lang}/${post.slug}`;
+ const route = `src/app/(${lang})/blog/${lang}/${post.slug}`;
routes.push(route);
const bodyName = `${post.slug}.${lang}.mdx`;
await writeFile(join(generated, bodyName), sources[lang]);
diff --git a/src/app/blog/page.tsx b/src/app/(en)/blog/page.tsx
similarity index 100%
rename from src/app/blog/page.tsx
rename to src/app/(en)/blog/page.tsx
diff --git a/src/app/colophon/page.mdx b/src/app/(en)/colophon/page.mdx
similarity index 100%
rename from src/app/colophon/page.mdx
rename to src/app/(en)/colophon/page.mdx
diff --git a/src/app/(en)/layout.tsx b/src/app/(en)/layout.tsx
new file mode 100644
index 0000000..75e3a4f
--- /dev/null
+++ b/src/app/(en)/layout.tsx
@@ -0,0 +1,6 @@
+import type { ReactNode } from "react";
+import Document from "@/components/Document";
+export { metadata } from "@/components/Document";
+export default function Layout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/src/app/(en)/look/[look]/card.jpg/route.tsx b/src/app/(en)/look/[look]/card.jpg/route.tsx
new file mode 100644
index 0000000..57e8b8a
--- /dev/null
+++ b/src/app/(en)/look/[look]/card.jpg/route.tsx
@@ -0,0 +1,10 @@
+import { renderLookCard, generateStaticParams } from "@/components/LookCard";
+export { generateStaticParams };
+export const dynamic = "force-static";
+export const dynamicParams = false;
+export function GET(
+ request: Request,
+ context: { params: Promise<{ look: string }> }
+) {
+ return renderLookCard(request, context, "en");
+}
diff --git a/src/app/look/[look]/page.tsx b/src/app/(en)/look/[look]/page.tsx
similarity index 100%
rename from src/app/look/[look]/page.tsx
rename to src/app/(en)/look/[look]/page.tsx
diff --git a/src/app/page.tsx b/src/app/(en)/page.tsx
similarity index 100%
rename from src/app/page.tsx
rename to src/app/(en)/page.tsx
diff --git a/src/app/(en)/work/[slug]/page.tsx b/src/app/(en)/work/[slug]/page.tsx
new file mode 100644
index 0000000..b565da1
--- /dev/null
+++ b/src/app/(en)/work/[slug]/page.tsx
@@ -0,0 +1,13 @@
+import {
+ CaseStudyPage,
+ studyMetadata,
+ generateStaticParams,
+} from "@/components/CaseStudy/CaseStudyPage";
+export { generateStaticParams };
+export const dynamicParams = false;
+type Props = { params: Promise<{ slug: string }> };
+export const generateMetadata = (props: Props) =>
+ studyMetadata({ ...props, locale: "en" });
+export default function Page(props: Props) {
+ return ;
+}
diff --git a/src/app/blog/pl/page.tsx b/src/app/(pl)/blog/pl/page.tsx
similarity index 100%
rename from src/app/blog/pl/page.tsx
rename to src/app/(pl)/blog/pl/page.tsx
diff --git a/src/app/(pl)/layout.tsx b/src/app/(pl)/layout.tsx
new file mode 100644
index 0000000..8e54af8
--- /dev/null
+++ b/src/app/(pl)/layout.tsx
@@ -0,0 +1,6 @@
+import type { ReactNode } from "react";
+import Document from "@/components/Document";
+export { metadata } from "@/components/Document";
+export default function Layout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/src/app/(pl)/pl/colophon/page.mdx b/src/app/(pl)/pl/colophon/page.mdx
new file mode 100644
index 0000000..e71fed3
--- /dev/null
+++ b/src/app/(pl)/pl/colophon/page.mdx
@@ -0,0 +1,64 @@
+import { pageMetadata } from "@/lib/metadata";
+
+export const metadata = pageMetadata({
+ path: "/pl/colophon/",
+ title: "Jak powstała ta strona",
+ description: "Stack, dwa style, dostępność i testy stojące za devthomas.pl.",
+});
+
+# Jak powstała ta strona
+
+To niewielkie portfolio, ale rozwijam je jak projekt produkcyjny: typowany kod, testy przy każdej zmianie i powtarzalne kroki. Poniżej opisuję rozwiązania i powody ich wyboru. [Kod jest dostępny na GitHubie](https://github.com/Fremen1990/react-portfolio-devthomas).
+
+## Stack
+
+- **Next.js 16 ze statycznym eksportem.** Pracuję z Next.js na co dzień. Tutaj generuje każdą stronę jako zwykły plik HTML, który udostępnia hosting współdzielony. Każda strona ma własny URL i podgląd linku, bez utrzymywania serwera aplikacji.
+- **Server Components dla treści.** Sekcje są renderowane do HTML podczas budowania, więc da się je czytać przed uruchomieniem JavaScriptu lub bez niego. W przeglądarce działa tylko kilka małych elementów: nagłówek i paleta poleceń, klikalny diagram architektury, animacja pasków testowania oraz spis treści studiów przypadków.
+- **TypeScript w trybie strict, React 19.**
+- **Zwykły CSS z tokenami projektowymi.** Kolory, odstępy i fonty to właściwości niestandardowe w `:root`, nadpisywane dla ciemnego motywu. Bez frameworka UI.
+- **Wspólna rodzina wizualna z moim [CV](https://cv.devthomas.pl/).** Styl standardowy zachowuje paletę, typografię, ciepłe tło, turkusowy akcent i zaokrąglone przyciski CV, ale ma własny układ. Nagłówek, hero i sekcja podejścia mają ciemne tło w obu motywach. Realizacje leżą na powierzchni dostosowanej do motywu, a kontakt na turkusowym tle. W ciemnej sekcji tokeny przyjmują jej wartości, więc komponenty nie potrzebują dodatkowych reguł. CV jest dokumentem; portfolio pokazuje realizacje.
+- **Fonty hostowane lokalnie** dzięki `next/font`: Fraunces dla nagłówków, Geist dla tekstu i Geist Mono dla etykiet oraz dat. Przeglądarka nie wysyła zapytań do Google Fonts.
+
+## Dwa style, jeden kod
+
+Przycisk `>_` w nagłówku przełącza styl standardowy i Terminal. Oba używają tego samego HTML i treści.
+
+- Reguły Terminala znajdują się w `src/design/skin-terminal.css`, w zakresie `html.skin-terminal`, opakowanym w `:where()`. Nie zwiększa to specyficzności selektorów: reguły wygrywają dzięki kolejności ładowania. Wszystkie arkusze są importowane w jednym miejscu i ustalonej kolejności.
+- Mały skrypt w `` stosuje zapisany motyw i styl przed pierwszym renderowaniem. Dzięki temu nie pojawia się błysk innego wyglądu. Odczytuje też `?skin=` i `?theme=` z URL; link `?skin=terminal&theme=dark` otwiera dany wygląd na tę wizytę.
+- JetBrains Mono, font Terminala, nie jest wstępnie ładowany. Przeglądarka pobiera go dopiero po włączeniu tego stylu.
+- Paleta ⌘K jest wspólnym komponentem. Polecenia to dane z tytułem dla standardowego stylu i aliasem powłoki dla Terminala: `cd work`, `cat cv`, `theme dark`, `help`.
+
+## Dostępność
+
+- Linki sekcji przenoszą fokus na nagłówek, zachowują fragment URL i działają z przyciskiem Wstecz. Przy ograniczeniu animacji przewijanie jest natychmiastowe.
+- Escape zamyka menu mobilne i przywraca fokus na przycisk Menu. Zamknięte menu nie trafia do kolejności tabulacji.
+- Elementy diagramu to natywne przyciski z `aria-pressed`; zmiana opisu jest spokojnie ogłaszana przez czytnik. Bez JavaScriptu widoczny pozostaje pierwszy element i jego opis. Paleta używa natywnego ``, utrzymującego fokus wewnątrz i blokującego interakcję z tłem, oraz listy z ARIA combobox.
+- Animacje są subtelne: punkt na łączniku diagramu, jednorazowo rosnące paski testów i stany hover. Przy ograniczeniu animacji zatrzymują się, a paski mają pełną długość.
+- Automatyczny skan dostępności (axe, WCAG 2.1 AA) sprawdza strony w obu stylach i motywach oraz otwartą paletę. Naruszenie blokuje przejście kontroli i publikację.
+
+## Testy i wdrożenia
+
+- **Testy jednostkowe** z Vitest i Testing Library obejmują nawigację i fokus, przełączniki motywu i stylu, paletę i wyszukiwanie poleceń, diagram oraz spis treści.
+- **Testy end-to-end** z Playwright działają na statycznym eksporcie, czyli plikach serwowanych przez hosting. Sprawdzają tytuły, opisy, Open Graph, czytanie bez JavaScriptu, wygląd przed pierwszym renderowaniem i brak błędów konsoli. Kontrolują też brak przewijania poziomego przy 320 i 801 px oraz brak kolizji i obcięcia nagłówka od 320 do 1280 px.
+- **GitHub Actions** uruchamia lint, kontrolę typów, formatowania, testy jednostkowe, build i testy end-to-end dla każdego pull requestu. Scalenie do `main` uruchamia te same kontrole, a następnie publikuje eksport na gałęzi obsługiwanej przez hosting.
+- Zmiany trafiają przez małe, przeglądane [pull requesty](https://github.com/Fremen1990/react-portfolio-devthomas/pulls?q=is%3Apr), wraz z wynikami kontroli i opisem weryfikacji.
+
+## Wydajność
+
+Lighthouse, profil mobilny, mediana z trzech pomiarów strony głównej z 30 września 2026:
+
+| Metryka | Wynik |
+| ------------------------ | ------ |
+| Wydajność | 96 |
+| Dostępność | 100 |
+| Dobre praktyki | 100 |
+| SEO | 100 |
+| First contentful paint | 1,4 s |
+| Largest contentful paint | 2,8 s |
+| Total blocking time | 39 ms |
+| Cumulative layout shift | 0 |
+| Łączny transfer | 311 kB |
+| JavaScript (gzip) | 144 kB |
+| CSS (gzip) | 6,3 kB |
+
+Większość JavaScriptu stanowi runtime Next.js i React, ładowany już po pierwszym renderowaniu. Portret w hero to plik WebP o rozmiarze 16 kB.
diff --git a/src/app/(pl)/pl/look/[look]/card.jpg/route.tsx b/src/app/(pl)/pl/look/[look]/card.jpg/route.tsx
new file mode 100644
index 0000000..ce92e99
--- /dev/null
+++ b/src/app/(pl)/pl/look/[look]/card.jpg/route.tsx
@@ -0,0 +1,10 @@
+import { renderLookCard, generateStaticParams } from "@/components/LookCard";
+export { generateStaticParams };
+export const dynamic = "force-static";
+export const dynamicParams = false;
+export function GET(
+ request: Request,
+ context: { params: Promise<{ look: string }> }
+) {
+ return renderLookCard(request, context, "pl");
+}
diff --git a/src/app/(pl)/pl/look/[look]/page.tsx b/src/app/(pl)/pl/look/[look]/page.tsx
new file mode 100644
index 0000000..62671e8
--- /dev/null
+++ b/src/app/(pl)/pl/look/[look]/page.tsx
@@ -0,0 +1,48 @@
+import { notFound } from "next/navigation";
+import { HomePage } from "@/sections/HomePage";
+import { pageMetadata } from "@/lib/metadata";
+import { findLook, looks } from "@/content/looks";
+import { getProfile } from "@/i18n/profile";
+const profile = getProfile("pl");
+
+type Params = { look: string };
+
+// One copy of the home page per look, so each shared link has its own
+// preview card. The inline script in the layout applies the look.
+export const dynamicParams = false;
+
+export function generateStaticParams(): Params[] {
+ return looks.map(({ slug }) => ({ look: slug }));
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise;
+}) {
+ const look = findLook((await params).look);
+ if (!look) {
+ return {};
+ }
+ const path = `/pl/look/${look.slug}/`;
+ return pageMetadata({
+ path,
+ // Search engines should treat these as the home page.
+ canonical: "/pl/",
+ image: {
+ url: `${path}card.jpg`,
+ alt: `${profile.name}, ${profile.headline}. Portfolio: ${look.skin === "terminal" ? "styl Terminal" : "styl standardowy"}, ${look.theme === "dark" ? "ciemny motyw" : "jasny motyw"}.`,
+ },
+ });
+}
+
+export default async function LookPage({
+ params,
+}: {
+ params: Promise;
+}) {
+ if (!findLook((await params).look)) {
+ notFound();
+ }
+ return ;
+}
diff --git a/src/app/(pl)/pl/page.tsx b/src/app/(pl)/pl/page.tsx
new file mode 100644
index 0000000..1454326
--- /dev/null
+++ b/src/app/(pl)/pl/page.tsx
@@ -0,0 +1,6 @@
+import { HomePage } from "@/sections/HomePage";
+import { pageMetadata } from "@/lib/metadata";
+export const metadata = pageMetadata({ path: "/pl/" });
+export default function Page() {
+ return ;
+}
diff --git a/src/app/(pl)/pl/work/[slug]/page.tsx b/src/app/(pl)/pl/work/[slug]/page.tsx
new file mode 100644
index 0000000..95f8daa
--- /dev/null
+++ b/src/app/(pl)/pl/work/[slug]/page.tsx
@@ -0,0 +1,13 @@
+import {
+ CaseStudyPage,
+ studyMetadata,
+ generateStaticParams,
+} from "@/components/CaseStudy/CaseStudyPage";
+export { generateStaticParams };
+export const dynamicParams = false;
+type Props = { params: Promise<{ slug: string }> };
+export const generateMetadata = (props: Props) =>
+ studyMetadata({ ...props, locale: "pl" });
+export default function Page(props: Props) {
+ return ;
+}
diff --git a/src/app/global-not-found.tsx b/src/app/global-not-found.tsx
new file mode 100644
index 0000000..aa789b6
--- /dev/null
+++ b/src/app/global-not-found.tsx
@@ -0,0 +1,24 @@
+import Link from "next/link";
+import Document from "@/components/Document";
+export const metadata = {
+ title: "Page not found / Nie znaleziono strony",
+ robots: { index: false },
+};
+export default function NotFound() {
+ return (
+
+
+
+
Page not found
+
Nie znaleziono strony.
+
+ Go to the portfolio ·{" "}
+
+ Przejdź do portfolio
+
+
+
+
+
+ );
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
deleted file mode 100644
index b63c6ba..0000000
--- a/src/app/not-found.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { Metadata } from "next";
-import { ActionLink } from "@/components/ActionLink/ActionLink";
-import { profile } from "@/content/publicProfile";
-
-export const metadata: Metadata = {
- title: `Page not found · ${profile.name}`,
- robots: { index: false },
-};
-
-export default function NotFound() {
- return (
-
-
-
- Page not found
-
-
- There is nothing at this address. The portfolio, CV and contact
- details are one click away.
-
-
-
Go to the portfolio
-
- View CV
-
-
-
-
- );
-}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index aeae386..f1fc8aa 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,3 +1,4 @@
+import { localizedPath } from "@/i18n/routes";
import { visiblePosts } from "@/content/blog/.generated/posts";
import { blogArticlePath } from "@/content/blog/model";
import type { MetadataRoute } from "next";
@@ -9,7 +10,7 @@ import { caseStudyPath, publishedCaseStudies } from "@/content/work/studies";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
- return [
+ const entries: MetadataRoute.Sitemap = [
{ url: `${SITE_URL}/blog/` },
{ url: `${SITE_URL}/blog/pl/` },
...visiblePosts
@@ -38,4 +39,21 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.4,
},
];
+ return entries.flatMap((entry) => {
+ const path = entry.url.slice(SITE_URL.length);
+ const paths = path.startsWith("/blog/")
+ ? [path]
+ : [localizedPath(path, "en"), localizedPath(path, "pl")];
+ return paths.map((p) => ({
+ ...entry,
+ url: SITE_URL + p,
+ alternates: {
+ languages: {
+ en: SITE_URL + localizedPath(p, "en"),
+ pl: SITE_URL + localizedPath(p, "pl"),
+ "x-default": SITE_URL + localizedPath(p, "en"),
+ },
+ },
+ }));
+ });
}
diff --git a/src/commands.ts b/src/commands.ts
index 81a2781..26ac806 100644
--- a/src/commands.ts
+++ b/src/commands.ts
@@ -1,3 +1,7 @@
+import type { Locale } from "@/i18n/locales";
+import { localizedPath, localeForPath } from "@/i18n/routes";
+import { getSections } from "@/content/navigation";
+import { getCaseStudies } from "@/content/work/studies";
import { visiblePosts } from "./content/blog/.generated/posts";
import { blogArticlePath } from "./content/blog/model";
import { profile } from "./content/publicProfile";
@@ -209,7 +213,7 @@ export const commands: Command[] = [
*/
export const buildShareUrl = (path: string, { theme, skin }: CommandState) => {
if (isHomePath(path)) {
- return `${SITE_URL}${lookPath(skin, theme)}`;
+ return `${SITE_URL}${lookPath(skin, theme, localeForPath(path))}`;
}
const params = new URLSearchParams({
skin: skin === "terminal" ? "terminal" : "standard",
@@ -265,3 +269,72 @@ export const filterCommands = (
.sort((a, b) => a.score - b.score || a.index - b.index)
.map((entry) => entry.command);
};
+
+const polishCommandTitles: Record = {
+ blog: "Czytaj blog",
+ top: "Przejdź na górę",
+ colophon: "Jak powstała ta strona",
+ cv: "Otwórz CV (EN)",
+ linkedin: "Otwórz LinkedIn",
+ github: "Otwórz GitHub",
+ source: "Zobacz kod tej strony",
+ email: "Kopiuj adres e-mail",
+ share: "Kopiuj link z tym wyglądem",
+ "theme-dark": "Włącz ciemny motyw",
+ "theme-light": "Włącz jasny motyw",
+ "skin-terminal": "Włącz styl Terminal",
+ "skin-standard": "Włącz styl standardowy",
+ "car-brain-app-store": "Otwórz Car Brain w App Store",
+ "car-brain-site": "Otwórz car-brain.com",
+};
+export function createCommands(locale: Locale = "en"): Command[] {
+ return commands.map((command) => {
+ const section = getSections(locale).find(
+ (s) => command.id === `section-${s.id}`
+ );
+ const study = getCaseStudies(locale).find(
+ (s) => command.id === `case-study-${s.slug}`
+ );
+ const title =
+ locale === "en"
+ ? command.title
+ : section
+ ? `Przejdź: ${section.title}`
+ : study
+ ? `Studium przypadku: ${study.title}`
+ : (polishCommandTitles[command.id] ??
+ (command.id.startsWith("blog-") ? command.title : undefined));
+ if (!title) throw new Error(`Missing command translation: ${command.id}`);
+ const polishSection = getSections("pl").find(
+ (s) => command.id === `section-${s.id}`
+ );
+ const polishStudy = getCaseStudies("pl").find(
+ (s) => command.id === `case-study-${s.slug}`
+ );
+ return {
+ ...command,
+ title,
+ keywords: [
+ ...command.keywords,
+ command.title,
+ polishCommandTitles[command.id] ?? "",
+ polishSection?.title ?? "",
+ polishSection?.label ?? "",
+ polishStudy?.title ?? "",
+ "polecenie",
+ ],
+ run(context) {
+ command.run({
+ ...context,
+ goToPage(path) {
+ context.goToPage(
+ command.id.startsWith("blog-")
+ ? path
+ : localizedPath(path, locale)
+ );
+ },
+ });
+ },
+ };
+ });
+}
diff --git a/src/components/Blog/BlogArticle.tsx b/src/components/Blog/BlogArticle.tsx
index c964935..bef5135 100644
--- a/src/components/Blog/BlogArticle.tsx
+++ b/src/components/Blog/BlogArticle.tsx
@@ -14,12 +14,12 @@ import {
import type { BlogLocale } from "@/content/blog/types";
import { blogLabels } from "@/content/blog/labels";
import { profile } from "@/content/publicProfile";
-import { publishedCaseStudies, caseStudyPath } from "@/content/work/studies";
+import { getCaseStudies, caseStudyPath } from "@/content/work/studies";
import { pageMetadata, SITE_URL } from "@/lib/metadata";
import { headingsFromMdx } from "@/lib/headings";
import { JsonLd } from "@/components/JsonLd/JsonLd";
import { CaseStudyToc } from "@/components/CaseStudy/CaseStudyToc";
-import { BlogLanguages } from "./BlogList";
+
const find = (slug: string) => visiblePosts.find((p) => p.slug === slug);
export function articleMetadata(slug: string, locale: BlogLocale) {
const post = find(slug);
@@ -62,7 +62,7 @@ export async function BlogArticle({
);
const headings = headingsFromMdx(source);
const url = SITE_URL + blogArticlePath(slug, locale);
- const related = publishedCaseStudies.filter((s) =>
+ const related = getCaseStudies(locale).filter((s) =>
post.relatedWork?.includes(s.slug)
);
return (
@@ -108,7 +108,6 @@ export async function BlogArticle({
)}
-
@@ -133,7 +132,7 @@ export async function BlogArticle({
{related.map((s) => (
- {s.title}
+ {s.title}
))}
diff --git a/src/components/Blog/BlogIndex.tsx b/src/components/Blog/BlogIndex.tsx
index cb82296..3a49711 100644
--- a/src/components/Blog/BlogIndex.tsx
+++ b/src/components/Blog/BlogIndex.tsx
@@ -4,7 +4,7 @@ import { blogLabels } from "@/content/blog/labels";
import { blogIndexPath } from "@/content/blog/model";
import type { BlogLocale } from "@/content/blog/types";
import { pageMetadata } from "@/lib/metadata";
-import { BlogLanguages, BlogList } from "./BlogList";
+import { BlogList } from "./BlogList";
export function blogIndexMetadata(locale: BlogLocale) {
const t = blogLabels[locale];
return pageMetadata({
@@ -28,7 +28,6 @@ export function BlogIndex({ locale }: { locale: BlogLocale }) {
Blog · Tomasz Stanisz
{t.title}
{t.description}
-
{visiblePosts.some((p) => p.preview) && (
{t.preview}
@@ -38,7 +37,9 @@ export function BlogIndex({ locale }: { locale: BlogLocale }) {
) : (
{t.empty}
-
{t.work} →
+
+ {t.work} →
+
)}
diff --git a/src/components/Blog/BlogList.tsx b/src/components/Blog/BlogList.tsx
index c5fb6c2..8f8a0de 100644
--- a/src/components/Blog/BlogList.tsx
+++ b/src/components/Blog/BlogList.tsx
@@ -77,15 +77,23 @@ export function BlogList({
);
}
-export function LatestWriting() {
+export function LatestWriting({ locale = "en" }: { locale?: BlogLocale }) {
if (!visiblePosts.length) return null;
return (
All articles →}
+ title={locale === "pl" ? "Najnowsze artykuły" : "Latest writing"}
+ aside={
+
+ {locale === "pl" ? "Wszystkie artykuły →" : "All articles →"}
+
+ }
>
-
+
);
}
diff --git a/src/components/Blog/blog.css b/src/components/Blog/blog.css
index 3f185d7..d826b97 100644
--- a/src/components/Blog/blog.css
+++ b/src/components/Blog/blog.css
@@ -138,36 +138,3 @@
padding-top: 24px;
border-top: 1px solid var(--separator);
}
-/* One extra nav destination needs the compact menu earlier. */
-@media (min-width: 801px) and (max-width: 1099px) {
- .nav-toggle {
- display: inline-flex;
- }
- .nav-cv-menu {
- display: block;
- }
- .site-nav-links {
- position: absolute;
- top: calc(100% + 8px);
- right: 16px;
- display: flex;
- flex-direction: column;
- align-items: stretch;
- gap: 4px;
- width: min(300px, calc(100vw - 32px));
- padding: 16px;
- border: 1px solid var(--separator);
- border-radius: var(--radius-surface);
- background: var(--surface);
- box-shadow: 0 10px 30px #0002;
- }
- .site-nav-links a {
- padding: 10px 12px;
- }
- .nav-skin-menu {
- display: none;
- }
-}
-.site-nav-links[hidden] {
- display: none;
-}
diff --git a/src/app/work/[slug]/page.tsx b/src/components/CaseStudy/CaseStudyPage.tsx
similarity index 57%
rename from src/app/work/[slug]/page.tsx
rename to src/components/CaseStudy/CaseStudyPage.tsx
index a430a83..7fde1bd 100644
--- a/src/app/work/[slug]/page.tsx
+++ b/src/components/CaseStudy/CaseStudyPage.tsx
@@ -1,3 +1,5 @@
+import type { Locale } from "@/i18n/locales";
+import { homePath } from "@/i18n/routes";
import type { ReactNode } from "react";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@@ -8,8 +10,9 @@ import {
caseStudyPath,
nextCaseStudy,
publishedCaseStudies,
+ getCaseStudies,
} from "@/content/work/studies";
-import { caseStudyBodies } from "@/content/work/bodies";
+import { caseStudyBodies, polishCaseStudyBodies } from "@/content/work/bodies";
import { JsonLd } from "@/components/JsonLd/JsonLd";
import { CaseStudyToc } from "@/components/CaseStudy/CaseStudyToc";
import { caseStudyStructuredData } from "@/lib/structuredData";
@@ -24,20 +27,22 @@ export function generateStaticParams(): Params[] {
return publishedCaseStudies.map(({ slug }) => ({ slug }));
}
-const findStudy = (slug: string) =>
- publishedCaseStudies.find((study) => study.slug === slug);
+const findStudy = (slug: string, locale: Locale) =>
+ getCaseStudies(locale).find((study) => study.slug === slug);
-export async function generateMetadata({
+export async function studyMetadata({
params,
+ locale = "en",
}: {
params: Promise;
+ locale?: Locale;
}) {
- const study = findStudy((await params).slug);
+ const study = findStudy((await params).slug, locale);
if (!study) {
return {};
}
return pageMetadata({
- path: caseStudyPath(study.slug),
+ path: caseStudyPath(study.slug, locale),
title: study.title,
description: study.description,
});
@@ -48,49 +53,60 @@ const Unwrapped = ({ children }: { children?: ReactNode }) => <>{children}>;
// "On this page" lists the study's h2s. They are read from the MDX source at
// build time; the MDX h2 component gives each the matching id.
-const readHeadings = async (slug: string) =>
+const readHeadings = async (slug: string, locale: Locale) =>
headingsFromMdx(
await readFile(
- join(process.cwd(), "src/content/work", `${slug}.mdx`),
+ join(
+ process.cwd(),
+ "src/content/work",
+ `${slug}${locale === "pl" ? ".pl" : ""}.mdx`
+ ),
"utf8"
)
);
-export default async function CaseStudyPage({
+export async function CaseStudyPage({
params,
+ locale = "en",
}: {
params: Promise;
+ locale?: Locale;
}) {
const { slug } = await params;
- const study = findStudy(slug);
- const Body = caseStudyBodies[slug];
+ const study = findStudy(slug, locale);
+ const Body = (locale === "pl" ? polishCaseStudyBodies : caseStudyBodies)[
+ slug
+ ];
if (!study || !Body) {
notFound();
}
- const headings = await readHeadings(slug);
- const next = nextCaseStudy(slug);
+ const headings = await readHeadings(slug, locale);
+ const next = nextCaseStudy(slug, locale);
const facts = [
- ["My role", study.role],
- ["When", study.period],
- ["Team", study.team],
+ [locale === "pl" ? "Moja rola" : "My role", study.role],
+ [locale === "pl" ? "Kiedy" : "When", study.period],
+ [locale === "pl" ? "Zespół" : "Team", study.team],
["Stack", study.stack],
];
return (
-
+
-
+
-
-
+
+
- ← All work
+ ←
+ {locale === "pl" ? "Wszystkie realizacje" : "All work"}
+
+
+ {locale === "pl"
+ ? "Wróć na stronę główną"
+ : "Back to the home page"}
- Back to the home page
{next && (
- Next case study →
+ {locale === "pl"
+ ? "Kolejne studium przypadku"
+ : "Next case study"}
+ →
{next.title}
diff --git a/src/components/CaseStudy/CaseStudyParts.tsx b/src/components/CaseStudy/CaseStudyParts.tsx
index e3b507c..fbdc580 100644
--- a/src/components/CaseStudy/CaseStudyParts.tsx
+++ b/src/components/CaseStudy/CaseStudyParts.tsx
@@ -12,15 +12,23 @@ export const Options = ({ children }: { children?: ReactNode }) => (
export const Option = ({
title,
chosen = false,
+ locale = "en",
children,
}: {
title: string;
chosen?: boolean;
+ locale?: "en" | "pl";
children?: ReactNode;
}) => (
- {chosen ? "Chosen" : "Rejected"}
+ {locale === "pl"
+ ? chosen
+ ? "Wybrano"
+ : "Odrzucono"
+ : chosen
+ ? "Chosen"
+ : "Rejected"}
{title}
{children}
diff --git a/src/components/CommandPalette/CommandPalette.tsx b/src/components/CommandPalette/CommandPalette.tsx
index 7d5211a..99525ad 100644
--- a/src/components/CommandPalette/CommandPalette.tsx
+++ b/src/components/CommandPalette/CommandPalette.tsx
@@ -14,7 +14,7 @@ import {
CLOSE_INPUTS,
SHOW_ALL,
buildShareUrl,
- commands,
+ createCommands,
filterCommands,
type Command,
type CommandContext,
@@ -30,6 +30,7 @@ type CommandPaletteProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isHome: boolean;
+ locale?: "en" | "pl";
};
// How long a message stays up. A failed copy shows the text to copy by hand,
@@ -77,7 +78,9 @@ export const CommandPalette = ({
open,
onOpenChange,
isHome,
+ locale = "en",
}: CommandPaletteProps) => {
+ const t = (en: string, pl: string) => (locale === "pl" ? pl : en);
const router = useRouter();
const pathname = usePathname();
const { theme, skin } = usePreferences();
@@ -94,8 +97,8 @@ export const CommandPalette = ({
const isClient = useIsClient();
const results = useMemo(
- () => filterCommands(commands, query, { theme, skin }),
- [query, theme, skin]
+ () => filterCommands(createCommands(locale), query, { theme, skin }),
+ [query, theme, skin, locale]
);
const activeIndex = Math.min(active, results.length - 1);
const activeCommand = results[activeIndex];
@@ -175,7 +178,10 @@ export const CommandPalette = ({
if (await copyToClipboard(text)) {
showToast(copied);
} else {
- showToast(`Couldn't copy. ${fallback} ${text}`, { failed: true });
+ showToast(
+ `${t("Couldn't copy.", "Nie udało się skopiować.")} ${fallback} ${text}`,
+ { failed: true }
+ );
}
};
@@ -184,7 +190,9 @@ export const CommandPalette = ({
if (isHome) {
scrollToSection(null, `#${id}`);
} else {
- router.push(id === "home" ? "/" : `/#${id}`);
+ router.push(
+ `${locale === "pl" ? "/pl/" : "/"}${id === "home" ? "" : `#${id}`}`
+ );
}
},
goToPage: (path) => router.push(path),
@@ -194,14 +202,14 @@ export const CommandPalette = ({
copyEmail: () =>
copyText(
profile.links.emailLabel,
- "Email address copied",
- "The address is"
+ t("Email address copied", "Skopiowano adres e-mail"),
+ t("The address is", "Adres:")
),
copyShareLink: () =>
copyText(
buildShareUrl(pathname, { theme, skin }),
- "Link copied",
- "The link is"
+ t("Link copied", "Skopiowano link"),
+ t("The link is", "Link:")
),
setTheme: (next) => applyTheme(next, { persist: true }),
setSkin: (next) => applySkin(next),
@@ -238,15 +246,17 @@ export const CommandPalette = ({
const trimmed = query.trim();
const emptyMessage = terminal
- ? `command not found: ${trimmed} — type help`
- : `No command matches “${trimmed}”. Type help to see them all.`;
+ ? `${t("command not found", "nie znaleziono polecenia")}: ${trimmed} — ${t("type help", "wpisz help")}`
+ : locale === "pl"
+ ? `Brak polecenia „${trimmed}”. Wpisz help, by zobaczyć wszystkie.`
+ : `No command matches “${trimmed}”. Type help to see them all.`;
return (
<>
{
if (open) {
close({ restoreFocus: true });
@@ -270,7 +280,7 @@ export const CommandPalette = ({
className="palette-input"
type="text"
role="combobox"
- aria-label="Search commands"
+ aria-label={t("Search commands", "Szukaj poleceń")}
aria-expanded={results.length > 0}
aria-controls="palette-list"
aria-autocomplete="list"
@@ -281,7 +291,12 @@ export const CommandPalette = ({
autoCapitalize="off"
spellCheck={false}
placeholder={
- terminal ? "type help" : "Go to a section, open a link…"
+ terminal
+ ? t("type help", "wpisz help")
+ : t(
+ "Go to a section, open a link…",
+ "Przejdź do sekcji, otwórz link…"
+ )
}
value={query}
onChange={(event) => {
@@ -295,7 +310,7 @@ export const CommandPalette = ({
id="palette-list"
className="palette-list"
role="listbox"
- aria-label="Commands"
+ aria-label={t("Commands", "Polecenia")}
hidden={results.length === 0}
>
{results.map((command, index) => (
@@ -310,7 +325,17 @@ export const CommandPalette = ({
>
{command.alias}
{command.title}
- {command.group}
+
+ {locale === "pl"
+ ? {
+ Section: "Sekcja",
+ Page: "Strona",
+ Link: "Link",
+ Setting: "Ustawienie",
+ Action: "Akcja",
+ }[command.group]
+ : command.group}
+
))}
@@ -318,13 +343,14 @@ export const CommandPalette = ({
{emptyMessage}
)}
- ↑ ↓ to choose · Enter to run ·{" "}
- Esc to close
+ ↑ ↓ {t("to choose", "wybór")} ·{" "}
+ Enter {t("to run", "uruchom")} · Esc {" "}
+ {t("to close", "zamknij")}
{open
? results.length
- ? `${results.length} commands`
+ ? `${results.length} ${t("commands", "poleceń")}`
: emptyMessage
: ""}
diff --git a/src/app/layout.tsx b/src/components/Document.tsx
similarity index 92%
rename from src/app/layout.tsx
rename to src/components/Document.tsx
index 8dc0a3f..c9381d3 100644
--- a/src/app/layout.tsx
+++ b/src/components/Document.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @next/next/no-head-element -- Shared App Router root document, not a Pages Router component. */
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { Fraunces, Geist, Geist_Mono, JetBrains_Mono } from "next/font/google";
@@ -24,6 +25,7 @@ import "@/components/CommandPalette/palette.css";
import "@/components/Prose/prose.css";
import "@/components/CaseStudy/case-study.css";
import "@/components/Blog/blog.css";
+import "@/i18n/localization.css";
import "@/design/skin-terminal.css";
// The standard skin shares the CV's type system (cv.devthomas.pl): Fraunces
@@ -97,7 +99,7 @@ const applySavedPreferences = `(function () {
var look = null;
if (params) {
look = window.location.pathname.match(
- new RegExp("^/look/(terminal|standard)-(dark|light)/?$")
+ new RegExp("^/(?:pl/)?look/(terminal|standard)-(dark|light)/?$")
);
}
var linkSkin =
@@ -144,10 +146,16 @@ const applySavedPreferences = `(function () {
} catch (error) {}
})();`;
-export default function RootLayout({ children }: { children: ReactNode }) {
+export default function Document({
+ children,
+ locale = "en",
+}: {
+ children: ReactNode;
+ locale?: "en" | "pl";
+}) {
return (
diff --git a/src/components/FooterPanel/FooterPanel.tsx b/src/components/FooterPanel/FooterPanel.tsx
index 81dabfe..cfdb1bf 100644
--- a/src/components/FooterPanel/FooterPanel.tsx
+++ b/src/components/FooterPanel/FooterPanel.tsx
@@ -2,14 +2,20 @@ import React from "react";
import Link from "next/link";
import { profile } from "../../content/publicProfile";
-const FooterPanel = () => {
+const FooterPanel = ({ locale = "en" }: { locale?: "en" | "pl" }) => {
return (
© Tomasz Stanisz {new Date().getFullYear()}
- Two skins, one codebase ·{" "}
- How this site is built
+ {locale === "pl"
+ ? "Dwa style, jeden kod · "
+ : "Two skins, one codebase · "}
+
+ {locale === "pl"
+ ? "Jak powstała ta strona"
+ : "How this site is built"}
+
{
// "See the work" has no click handler here: this renders on the server, and
// NavBar routes in-page section links through scrollToSection.
-export const Hero = () => {
+export const Hero = ({ locale = "en" }: { locale?: Locale }) => {
+ const profile = getProfile(locale);
const { hero } = profile;
const headline = splitHeadline(hero.headline, hero.headlineAccent);
@@ -33,7 +35,11 @@ export const Hero = () => {
src="/portrait.webp"
width={720}
height={480}
- alt="Portrait of Tomasz Stanisz"
+ alt={
+ locale === "pl"
+ ? "Portret Tomasza Stanisza"
+ : "Portrait of Tomasz Stanisz"
+ }
preload
/>
diff --git a/src/app/look/[look]/card.jpg/route.tsx b/src/components/LookCard.tsx
similarity index 88%
rename from src/app/look/[look]/card.jpg/route.tsx
rename to src/components/LookCard.tsx
index bfd8757..76ff9f0 100644
--- a/src/app/look/[look]/card.jpg/route.tsx
+++ b/src/components/LookCard.tsx
@@ -3,7 +3,8 @@ import { join } from "node:path";
import { ImageResponse } from "next/og";
import sharp from "sharp";
import { findLook, looks, type Look } from "@/content/looks";
-import { profile } from "@/content/publicProfile";
+import { getProfile } from "@/i18n/profile";
+import type { Locale } from "@/i18n/locales";
// The 1200×630 link-preview card for each look, rendered once at build time
// and exported as /look/
/card.jpg. next/og draws a PNG; it is converted
@@ -20,31 +21,34 @@ export function generateStaticParams() {
const WIDTH = 1200;
const HEIGHT = 630;
-const { hero } = profile;
-const headlineAt = hero.headline.lastIndexOf(hero.headlineAccent);
-const HEADLINE = {
- before: hero.headline.slice(0, headlineAt),
- accent: hero.headlineAccent,
- after: hero.headline.slice(headlineAt + hero.headlineAccent.length),
-};
-
// next/og spaced wrapped inline text unevenly, so the headline is laid out
// one word per flex item, with the accent word and its punctuation together.
// An array, not a fragment: next/og treats a fragment as one item.
-const headlineWords = (accent: string, gap: number) => [
- ...HEADLINE.before
- .trim()
- .split(/\s+/)
- .map((word, index) => (
-
- {word}
-
- )),
-
- {HEADLINE.accent}
- {HEADLINE.after}
- ,
-];
+const headlineWords = (accent: string, gap: number, locale: Locale) => {
+ const { hero } = getProfile(locale);
+
+ const headlineAt = hero.headline.lastIndexOf(hero.headlineAccent);
+ const HEADLINE = {
+ before: hero.headline.slice(0, headlineAt),
+ accent: hero.headlineAccent,
+ after: hero.headline.slice(headlineAt + hero.headlineAccent.length),
+ };
+
+ return [
+ ...HEADLINE.before
+ .trim()
+ .split(/\s+/)
+ .map((word, index) => (
+
+ {word}
+
+ )),
+
+ {HEADLINE.accent}
+ {HEADLINE.after}
+ ,
+ ];
+};
// The standard skin's palette (src/index.css): a dark brand band in both
// themes, over a strip in the page colour.
@@ -106,7 +110,17 @@ const portraitPath = join(process.cwd(), "public/portrait.jpg");
// Matches the standard hero: the dark band with the wordmark, the round
// portrait, name and role, and the Geist headline with its accent word,
// over a strip in the page colour that opens the work section.
-const StandardCard = ({ look, portrait }: { look: Look; portrait: string }) => {
+const StandardCard = ({
+ look,
+ portrait,
+ locale,
+}: {
+ look: Look;
+ portrait: string;
+ locale: Locale;
+}) => {
+ const profile = getProfile(locale);
+ const { hero } = profile;
const c = STANDARD[look.theme];
return (
{
color: BAND.strong,
}}
>
- {headlineWords(BAND.accent, 14)}
+ {headlineWords(BAND.accent, 14, locale)}
{
{profile.work.eyebrow.toUpperCase()}
- Proven in production
+ {locale === "pl"
+ ? "Sprawdzone na produkcji"
+ : "Proven in production"}
{
fontWeight: 500,
}}
>
- See the work
+ {profile.hero.primaryAction}
@@ -244,7 +260,17 @@ const StandardCard = ({ look, portrait }: { look: Look; portrait: string }) => {
// Matches the site's Terminal hero: text on the left, the photo inside the
// window on the right, tinted like the site (green in dark, greyscale in light).
-const TerminalCard = ({ look, portrait }: { look: Look; portrait: string }) => {
+const TerminalCard = ({
+ look,
+ portrait,
+ locale,
+}: {
+ look: Look;
+ portrait: string;
+ locale: Locale;
+}) => {
+ const profile = getProfile(locale);
+ const { hero } = profile;
const c = TERMINAL[look.theme];
return (
{
color: c.text,
}}
>
- {headlineWords(c.accent, 15)}
+ {headlineWords(c.accent, 15, locale)}
{/* next/og renders plain only; next/image does not apply here. */}
@@ -384,9 +410,10 @@ const terminalPortrait = async (source: Buffer, look: Look) => {
return `data:image/png;base64,${tinted.toString("base64")}`;
};
-export async function GET(
+export async function renderLookCard(
_request: Request,
- { params }: { params: Promise<{ look: string }> }
+ { params }: { params: Promise<{ look: string }> },
+ locale: Locale = "en"
) {
const look = findLook((await params).look);
if (!look) {
@@ -417,11 +444,13 @@ export async function GET(
const png = new ImageResponse(
look.skin === "terminal" ? (
) : (
diff --git a/src/components/NavBar/LanguageSwitch.tsx b/src/components/NavBar/LanguageSwitch.tsx
new file mode 100644
index 0000000..a7ab959
--- /dev/null
+++ b/src/components/NavBar/LanguageSwitch.tsx
@@ -0,0 +1,56 @@
+"use client";
+import { usePathname } from "next/navigation";
+import { useEffect, useState } from "react";
+import type { Locale } from "@/i18n/locales";
+import { localizedPath, translatedFragment } from "@/i18n/routes";
+import { usePreferences } from "@/utils/preferences";
+
+/** Plain links work in exported HTML. Hydration adds the current fragment and
+ * resolved appearance, including when storage is blocked. */
+export function LanguageSwitch({ locale }: { locale: Locale }) {
+ const pathname = usePathname();
+ const { theme, skin } = usePreferences();
+ const [hash, setHash] = useState("");
+ const [enhanced, setEnhanced] = useState(false);
+ useEffect(() => {
+ const update = () => {
+ setHash(window.location.hash);
+ setEnhanced(true);
+ };
+ update();
+ window.addEventListener("hashchange", update);
+ window.addEventListener("portfolio:fragment", update);
+ window.addEventListener("popstate", update);
+ return () => {
+ window.removeEventListener("hashchange", update);
+ window.removeEventListener("portfolio:fragment", update);
+ window.removeEventListener("popstate", update);
+ };
+ }, [pathname]);
+ return (
+
+ {(["en", "pl"] as const).map((target) => {
+ const path = localizedPath(pathname, target);
+ const fragment = translatedFragment(pathname, hash, target);
+ const params = enhanced
+ ? `?${new URLSearchParams({ skin: skin === "default" ? "standard" : skin, theme })}`
+ : "";
+ return (
+
+ {target.toUpperCase()}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/NavBar/NavBar.tsx b/src/components/NavBar/NavBar.tsx
index 77ce820..fd8e470 100644
--- a/src/components/NavBar/NavBar.tsx
+++ b/src/components/NavBar/NavBar.tsx
@@ -1,4 +1,7 @@
"use client";
+import type { Locale } from "@/i18n/locales";
+import { homePath, localizedPath } from "@/i18n/routes";
+import { LanguageSwitch } from "@/components/NavBar/LanguageSwitch";
import React, {
useEffect,
@@ -10,7 +13,7 @@ import React, {
import Link from "next/link";
import { usePathname } from "next/navigation";
import { profile } from "../../content/publicProfile";
-import { sections } from "../../content/navigation";
+import { sections, getSections } from "../../content/navigation";
import { isHomePath } from "../../content/looks";
import { CommandPalette } from "../CommandPalette/CommandPalette";
import { scrollToSection } from "../../utils/scrollToSection";
@@ -25,8 +28,9 @@ import { useMediaQuery, usePreferences } from "../../utils/preferences";
import { greetDevelopers } from "../../utils/consoleGreeting";
const sectionIds = sections.map((section) => section.id);
-const headerSections = sections.filter((section) => section.header);
-const isCaseStudyPath = (pathname: string) => pathname.startsWith("/work/");
+
+const isCaseStudyPath = (pathname: string) =>
+ /^\/(?:pl\/)?work\//.test(pathname);
const DESKTOP_NAV_QUERY = "(min-width: 1100px)";
const isApplePlatform = () =>
@@ -105,7 +109,11 @@ const ThemeIcon = ({ name }: { name: "sun" | "moon" }) => {
);
};
-const NavBar = () => {
+const NavBar = ({ locale = "en" }: { locale?: Locale }) => {
+ const headerSections = getSections(locale).filter(
+ (section) => section.header
+ );
+ const t = (en: string, pl: string) => (locale === "pl" ? pl : en);
const pathname = usePathname();
const isHome = isHomePath(pathname);
const isCaseStudy = isCaseStudyPath(pathname);
@@ -284,7 +292,11 @@ const NavBar = () => {
{profile.name}
) : (
-
+
TS
@@ -303,10 +315,11 @@ const NavBar = () => {
{isCaseStudy && (
- ← All work
+ ←
+ {t("All work", "Wszystkie realizacje")}
)}
{isBlogArticle && (
@@ -326,7 +339,7 @@ const NavBar = () => {
return (
{link.label}
@@ -352,7 +365,7 @@ const NavBar = () => {
})}
{!isBlogArticle && (
{
rel="noopener noreferrer"
onClick={closeOnPlainClick}
>
- View CV
+ {t("View CV", "Zobacz CV (EN)")}
{
close();
}}
>
- {terminal ? "Use standard style" : "Use terminal style"}
+ {terminal
+ ? t("Use standard style", "Włącz styl standardowy")
+ : t("Use terminal style", "Włącz styl Terminal")}
{
rel="noopener noreferrer"
onClick={closeOnPlainClick}
>
- View CV
+ {t("View CV", "Zobacz CV (EN)")}
+
setPaletteOpen(true)}
@@ -405,9 +421,15 @@ const NavBar = () => {
className="skin-toggle"
aria-pressed={terminal}
aria-label={
- terminal ? "Switch to standard style" : "Switch to terminal style"
+ terminal
+ ? t("Switch to standard style", "Przełącz na styl standardowy")
+ : t("Switch to terminal style", "Przełącz na styl Terminal")
+ }
+ title={
+ terminal
+ ? t("Standard style", "Styl standardowy")
+ : t("Terminal style", "Styl Terminal")
}
- title={terminal ? "Standard style" : "Terminal style"}
onClick={toggleSkin}
>
{terminal ? "Aa" : ">_"}
@@ -416,7 +438,11 @@ const NavBar = () => {
type="button"
className="theme-toggle"
aria-pressed={dark}
- aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
+ aria-label={
+ dark
+ ? t("Switch to light theme", "Włącz jasny motyw")
+ : t("Switch to dark theme", "Włącz ciemny motyw")
+ }
onClick={toggleTheme}
>
@@ -436,6 +462,7 @@ const NavBar = () => {
open={paletteOpen}
onOpenChange={setPaletteOpen}
isHome={isHome}
+ locale={locale}
/>
);
diff --git a/src/components/NavBar/navbar.css b/src/components/NavBar/navbar.css
index e27eddb..9fdc076 100644
--- a/src/components/NavBar/navbar.css
+++ b/src/components/NavBar/navbar.css
@@ -214,7 +214,7 @@
}
}
-@media (max-width: 800px) {
+@media (max-width: 1099px) {
.site-nav {
position: absolute;
top: 0;
@@ -300,7 +300,7 @@
}
}
-@media (min-width: 801px) {
+@media (min-width: 1100px) {
.skin-toggle {
margin-left: 4px;
}
diff --git a/src/content/blog/generation.test.ts b/src/content/blog/generation.test.ts
index 9430717..9f09e38 100644
--- a/src/content/blog/generation.test.ts
+++ b/src/content/blog/generation.test.ts
@@ -62,23 +62,42 @@ test("private preview routes are removed by the next production preparation", ()
env: { ...process.env, BLOG_PREVIEW_DIR: preview, CI: ci },
stdio: "pipe",
});
+ // Migrate only previously generated, recognized leaf directories.
+ const generated = join(root, "src/content/blog/.generated");
+ mkdirSync(generated, { recursive: true });
+ const legacy = "src/app/blog/en/legacy-draft";
+ mkdirSync(join(root, legacy), { recursive: true });
+ writeFileSync(join(root, legacy, "page.tsx"), "old generated route");
+ const protectedPage = join(root, "src/app/blog/pl/page.tsx");
+ mkdirSync(dirname(protectedPage), { recursive: true });
+ writeFileSync(protectedPage, "owned index");
+ writeFileSync(join(generated, "routes.json"), JSON.stringify([legacy]));
run(drafts);
- expect(existsSync(join(root, "src/app/blog/en/review-only/page.tsx"))).toBe(
- true
- );
+ expect(existsSync(join(root, legacy))).toBe(false);
+ expect(readFileSync(protectedPage, "utf8")).toBe("owned index");
+ expect(
+ existsSync(join(root, "src/app/(en)/blog/en/review-only/page.tsx"))
+ ).toBe(true);
expect(
- existsSync(join(root, "src/app/blog/pl/review-only/card.jpg/route.tsx"))
+ existsSync(
+ join(root, "src/app/(pl)/blog/pl/review-only/card.jpg/route.tsx")
+ )
).toBe(true);
expect(
readFileSync(join(root, "src/content/blog/.generated/posts.ts"), "utf8")
).toContain('"preview": true');
expect(() => run(drafts, "true")).toThrow();
run("");
- expect(existsSync(join(root, "src/app/blog/en/review-only"))).toBe(false);
- expect(existsSync(join(root, "src/app/blog/pl/review-only"))).toBe(false);
+ expect(existsSync(join(root, "src/app/(en)/blog/en/review-only"))).toBe(
+ false
+ );
+ expect(existsSync(join(root, "src/app/(pl)/blog/pl/review-only"))).toBe(
+ false
+ );
expect(
readFileSync(join(root, "src/content/blog/.generated/posts.ts"), "utf8")
).not.toContain("review-only");
+ run(""); // Repeated production preparation is idempotent.
// A real publication builds both editions through the same pipeline.
const articles = join(root, "src/content/blog/articles");
mkdirSync(articles);
@@ -92,12 +111,12 @@ test("private preview routes are removed by the next production preparation", ()
expect(
readFileSync(join(root, "src/content/blog/.generated/posts.ts"), "utf8")
).toContain('"preview": false');
- expect(existsSync(join(root, "src/app/blog/en/review-only/page.tsx"))).toBe(
- true
- );
- expect(existsSync(join(root, "src/app/blog/pl/review-only/page.tsx"))).toBe(
- true
- );
+ expect(
+ existsSync(join(root, "src/app/(en)/blog/en/review-only/page.tsx"))
+ ).toBe(true);
+ expect(
+ existsSync(join(root, "src/app/(pl)/blog/pl/review-only/page.tsx"))
+ ).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
diff --git a/src/content/looks.ts b/src/content/looks.ts
index b44230f..583a355 100644
--- a/src/content/looks.ts
+++ b/src/content/looks.ts
@@ -44,14 +44,20 @@ export const looks: Look[] = [
export const lookSlug = (skin: Skin, theme: Theme) =>
`${skin === "terminal" ? "terminal" : "standard"}-${theme}`;
-export const lookPath = (skin: Skin, theme: Theme) =>
- `/look/${lookSlug(skin, theme)}/`;
+export const lookPath = (
+ skin: Skin,
+ theme: Theme,
+ locale: "en" | "pl" = "en"
+) => `${locale === "pl" ? "/pl" : ""}/look/${lookSlug(skin, theme)}/`;
export const findLook = (slug: string) =>
looks.find((look) => look.slug === slug);
-const LOOK_PATH = /^\/look\/[a-z]+-[a-z]+\/?$/;
+const LOOK_PATH = /^\/(?:pl\/)?look\/[a-z]+-[a-z]+\/?$/;
/** The home page and its /look/ copies share the same sections. */
export const isHomePath = (pathname: string) =>
- pathname === "/" || LOOK_PATH.test(pathname);
+ pathname === "/" ||
+ pathname === "/pl/" ||
+ pathname === "/pl" ||
+ LOOK_PATH.test(pathname);
diff --git a/src/content/navigation.ts b/src/content/navigation.ts
index 291b4dd..02e3503 100644
--- a/src/content/navigation.ts
+++ b/src/content/navigation.ts
@@ -21,5 +21,22 @@ export const sections = [
export type SectionId = (typeof sections)[number]["id"];
-export const sectionTitle = (id: SectionId) =>
- sections.find((section) => section.id === id)?.title ?? "";
+export const sectionTitle = (id: SectionId, locale: "en" | "pl" = "en") =>
+ getSections(locale).find((section) => section.id === id)?.title ?? "";
+
+export const getSections = (locale: "en" | "pl" = "en") =>
+ sections.map((section) => ({
+ ...section,
+ ...(locale === "pl"
+ ? {
+ work: { label: "Realizacje", title: "Sprawdzone na produkcji" },
+ now: { label: "Teraz", title: "W budowie i wdrożone" },
+ approach: {
+ label: "Podejście",
+ title: "Jak pracuję — i gdzie to widać",
+ },
+ about: { label: "Doświadczenie", title: "Doświadczenie" },
+ contact: { label: "Kontakt", title: "Kontakt" },
+ }[section.id]
+ : {}),
+ }));
diff --git a/src/content/publicProfile.ts b/src/content/publicProfile.ts
index 8885b71..d9df69b 100644
--- a/src/content/publicProfile.ts
+++ b/src/content/publicProfile.ts
@@ -104,9 +104,8 @@ export const profile: Profile = {
introduction:
"I design and build web and mobile applications, from user interfaces to backend services. I combine hands-on engineering with architecture, testing and technical leadership.",
hero: {
- headline:
- "I design frontend architecture teams can extend, and the testing that keeps it shipping.",
- headlineAccent: "shipping",
+ headline: "I turn complex requirements into working software.",
+ headlineAccent: "software",
subline:
"Web and mobile applications, from user interfaces to backend services, with the architecture, testing and technical leadership around them. Regular overlap with US teams.",
location: "Poland · Remote",
diff --git a/src/content/work/bodies.ts b/src/content/work/bodies.ts
index 24816df..641d666 100644
--- a/src/content/work/bodies.ts
+++ b/src/content/work/bodies.ts
@@ -10,3 +10,12 @@ export const caseStudyBodies: Record = {
"orange-e2e-testing": OrangeE2eTesting,
"theeventa-mvp": TheeventaMvp,
};
+
+import OrangeCmsPl from "./orange-cms.pl.mdx";
+import OrangeE2ePl from "./orange-e2e-testing.pl.mdx";
+import TheeventaPl from "./theeventa-mvp.pl.mdx";
+export const polishCaseStudyBodies: Record = {
+ "orange-cms": OrangeCmsPl,
+ "orange-e2e-testing": OrangeE2ePl,
+ "theeventa-mvp": TheeventaPl,
+};
diff --git a/src/content/work/orange-cms.pl.mdx b/src/content/work/orange-cms.pl.mdx
new file mode 100644
index 0000000..9025668
--- /dev/null
+++ b/src/content/work/orange-cms.pl.mdx
@@ -0,0 +1,80 @@
+import { Flow } from "@/components/Flow/Flow";
+
+## Sytuacja
+
+Orange Polska potrzebowało jednego wewnętrznego CMS-a dla rosnącej liczby systemów i usług legacy, zbudowanych na różne sposoby. System miał pozostać stabilny i łatwy w utrzymaniu przez lata. Nie było zamkniętej specyfikacji: nowe wymagania, funkcje i integracje miały pojawiać się przez cały czas trwania projektu.
+
+## Ograniczenia
+
+- **Mały zespół**, który na początku znał czysty JavaScript, ale nie znał frameworka frontendowego.
+- **Systemy legacy z własnymi regułami** i duży zakres oczekiwanych dostosowań.
+- **Stack bez zamykania drogi rozwoju.** Musiał być dojrzały, elastyczny i na tyle przystępny, by nowe osoby mogły sprawnie dołączyć do projektu.
+
+## Rozważane opcje
+
+
+
+ który zespół już znał. Odradzałem tę drogę. W systemie na lata zależało mi
+ na silnym typowaniu, które ułatwia utrzymanie i szukanie błędów.
+
+
+ który rekomendowałem. React był już używany w innych projektach Orange, w
+ tym w Orange TV GO, przy którym również pracowałem. Programiści czasem
+ zmieniają projekty, a wspólny stack ułatwia takie przejścia.
+
+
+ Wybraliśmy MUI: sprawdzone komponenty, które można dostosować wizualnie,
+ zamiast budować i utrzymywać wszystko samodzielnie.
+
+
+ Wybraliśmy RJSF, który renderuje formularze bezpośrednio z JSON Schema, i
+ rozszerzyliśmy go o własne pola oraz widgety.
+
+
+
+## Decyzja
+
+Najważniejsza decyzja dotyczyła zakresu kontroli backendu. Pozwoliliśmy mu opisywać frontend: nawigacja, adresy URL, widoki i formularze wynikają z konfiguracji backendu i JSON Schema. Aplikacja React jest uniwersalnym rendererem otrzymanych danych.
+
+Kosztem jest trudniejsze debugowanie. Przyczyna błędu może znajdować się w konfiguracji, daleko od ekranu, na którym widać problem. Odpowiedzieliśmy na to przez:
+
+- **Logowanie błędów** dopasowane do tej architektury.
+- **Ścisłą kontrolę kontraktu frontend/backend.** Porównuje dane z backendu z oczekiwaniami frontendu i zapisuje szczegółowe błędy w konsoli dla programistów.
+- **Czytelne komunikaty dla użytkowników:** krótkie powiadomienie o problemie zamiast niedziałającego ekranu.
+
+## Jak to działa
+
+
+
+## Rezultat
+
+Frontend nie blokuje rozwoju. Nowy zasób jest zwykle w całości konfigurowany przez backend i działa bez zmian we frontendzie. **Dziesiątki nowych zasobów trafiły na produkcję bez pracy nad frontendem.**
+
+
+
+## Co zrobiłbym inaczej
+
+Byłbym bardziej stanowczy w kwestii dostosowań. Część przebudowanych funkcji działała już w MUI lub RJSF. Na przykład biznes poprosił o zmianę domyślnego zachowania po najechaniu kursorem i obsługi klawiatury. Każde takie nadpisanie to kod do utrzymania, a zmiana standardowej obsługi klawiatury utrudnia korzystanie z interfejsu osobom używającym klawiatury lub czytnika ekranu. Wcześniej kwestionowałbym takie wymagania.
+
+
diff --git a/src/content/work/orange-e2e-testing.pl.mdx b/src/content/work/orange-e2e-testing.pl.mdx
new file mode 100644
index 0000000..94bd4e6
--- /dev/null
+++ b/src/content/work/orange-e2e-testing.pl.mdx
@@ -0,0 +1,80 @@
+import { Flow } from "@/components/Flow/Flow";
+
+## Sytuacja
+
+Dołączyłem do Orange Polska, by pracować nad platformą streamingową Orange TV GO. Wtedy testerzy manualni sprawdzali ręcznie każdy scenariusz end-to-end. Pełna regresja obejmowała ponad 100 scenariuszy i zajmowała od jednego do trzech dni.
+
+## Początki
+
+Uczyłem się wtedy Cypressa i chciałem wykorzystać go w praktyce. Podczas obiadu opowiedziałem przełożonemu o automatyzacji E2E i swoim pomyśle. Zamiast pisać propozycję, zbudowałem osobne repozytorium z pełną konfiguracją, TypeScriptem, lintowaniem i pierwszymi działającymi testami. Gdy przełożony zobaczył je w akcji, zaczął szukać testera automatyzującego.
+
+## Rozważane opcje
+
+
+
+ zbyt skomplikowane i wymagające zbyt dużo konfiguracji dla naszego zespołu.
+
+
+ obiecujący, ale moim zdaniem jeszcze wtedy niedostatecznie dojrzały.
+
+
+ nowoczesny, popularny i dobrze dopasowany do testowania aplikacji webowej.
+ Wybrałem go.
+
+
+ Testy pisali i utrzymywali testerzy, a nie programiści aplikacji, dlatego
+ miały własny projekt.
+
+
+
+## Budowanie praktyki testowania
+
+- **Szkolenie:** każdy zaczynał od samodzielnych podstaw Cypressa. Następnie wspólnie pisaliśmy prawdziwe testy na mojej konfiguracji, a przez pierwszy rok przeglądałem każdą zmianę. Zespół tworzył nowo zatrudniony tester oraz tester manualny przechodzący do automatyzacji.
+- **Page Object Model:** interakcje z poszczególnymi częściami aplikacji są zamknięte w obiektach wielokrotnego użytku. Zmianę interfejsu poprawiamy w jednym miejscu.
+- **Wspólne funkcje pomocnicze:** powtarzalne kroki zapisujemy raz i wykorzystujemy ponownie (DRY).
+- **Uruchomienia równoległe:** testy działają obok siebie. Wymaga to ich niezależności, dlatego każdy test po zakończeniu usuwa własne dane przez API.
+
+
+
+## Uruchamianie testów
+
+- **Orange TV GO:** zestaw działa w CI/CD przy każdym merge requeście funkcji.
+- **Wewnętrzny CMS:** przed upływem roku przeszedłem do projektu CMS, który otrzymał tę samą konfigurację wewnątrz repozytorium frontendu, z równoległym uruchamianiem testów.
+- **Środowiska:** w obu projektach testy działają również na środowiskach deweloperskim, testowym i przedprodukcyjnym.
+
+## Rezultat
+
+| | Wcześniej (ręcznie) | Teraz (automatycznie) |
+| ------------------------------------ | ------------------- | --------------------- |
+| Orange TV GO, ponad 100 scenariuszy | 1–3 dni | około 1 godziny |
+| Wewnętrzny CMS, około 80 scenariuszy | 1–3 dni | około 30 minut |
+
+Dane testowe nie sprawiają problemów: testy są odizolowane na potrzeby równoległych uruchomień i każdy sprząta po sobie.
+
+
+
+## Co zrobiłbym inaczej
+
+Dziś wybrałbym Playwright z TypeScriptem. Dostęp do narzędzi deweloperskich przeglądarki i zakres możliwości ułatwiają obsługę nietypowych scenariuszy w porównaniu z Cypressem. Miejsce testów powinno wynikać z tego, kto je pisze. Zostawiłbym je w repozytorium aplikacji tylko wtedy, gdy jej programiści sami tworzą testy E2E.
+
+
diff --git a/src/content/work/studies.ts b/src/content/work/studies.ts
index 2f62ad6..63e3f15 100644
--- a/src/content/work/studies.ts
+++ b/src/content/work/studies.ts
@@ -1,3 +1,4 @@
+import type { Locale } from "@/i18n/locales";
// Case studies: the facts shown on each /work// page and used by the
// home page and the command palette. The text lives in .mdx, mapped in
// src/content/work/bodies.ts. Keep this file free of MDX imports so the
@@ -81,16 +82,72 @@ export const publishedCaseStudies = caseStudies.filter(
(study) => study.published
);
-export const caseStudyPath = (slug: string) => `/work/${slug}/`;
+export const caseStudyPath = (slug: string, locale: Locale = "en") =>
+ `${locale === "pl" ? "/pl" : ""}/work/${slug}/`;
-export const caseStudyFor = (contributionId: string) =>
- publishedCaseStudies.find((study) => study.contributionId === contributionId);
+export const caseStudyFor = (contributionId: string, locale: Locale = "en") =>
+ getCaseStudies(locale).find(
+ (study) => study.contributionId === contributionId
+ );
/** The study after `slug` in page order, wrapping round to the first. */
-export const nextCaseStudy = (slug: string) => {
+export const nextCaseStudy = (slug: string, locale: Locale = "en") => {
+ const publishedCaseStudies = getCaseStudies(locale);
const index = publishedCaseStudies.findIndex((study) => study.slug === slug);
if (index === -1 || publishedCaseStudies.length < 2) {
return undefined;
}
return publishedCaseStudies[(index + 1) % publishedCaseStudies.length];
};
+
+const polish: Record<
+ string,
+ Pick<
+ CaseStudy,
+ "title" | "description" | "role" | "period" | "team" | "outcome"
+ >
+> = {
+ "orange-cms": {
+ title: "CMS, który backend rozszerza bez zmian frontendu",
+ description:
+ "Jak zaprojektowałem CMS oparty na schematach w Orange Polska dla rosnącej liczby systemów legacy — korzyści i koszty tej decyzji.",
+ role: "Wybrałem stack i zaprojektowałem architekturę frontendu",
+ period: "Od końca 2022",
+ team: "5 programistów (w tym ja), 3 testerów, 1 product manager",
+ outcome: {
+ value: "Dziesiątki",
+ label: "nowych zasobów na produkcji bez pracy nad frontendem",
+ },
+ },
+ "orange-e2e-testing": {
+ title: "Od dni ręcznej regresji do mniej niż godziny",
+ description:
+ "Jak wprowadziłem automatyczne testy end-to-end w Orange Polska, przeszkoliłem dwie osoby i skróciłem regresję z dni do mniej niż godziny.",
+ role: "Wprowadziłem automatyzację E2E, zbudowałem architekturę testów i przeszkoliłem dwie osoby",
+ period: "Od 2022",
+ team: "Ja, nowo zatrudniony tester automatyzujący i tester manualny przechodzący do automatyzacji",
+ outcome: {
+ value: "~1 h",
+ label: "regresja Orange TV GO, wcześniej 1–3 dni",
+ },
+ },
+ "theeventa-mvp": {
+ title: "Jeden Tech Lead, agenci AI i MVP zbudowane niemal od zera",
+ description:
+ "Jak zaproponowałem mniejszy zespół TheEventa i zbudowałem większość MVP z agentami AI, zachowując osobistą kontrolę jakości.",
+ role: "Tech Lead: architektura, rozwój full stack i proces dostarczania z pomocą AI",
+ period: "Od września 2025, równolegle z pracą w Orange Polska",
+ team: "Ja, CEO i współpracownik DevOps odpowiedzialny za infrastrukturę",
+ outcome: {
+ value: "MVP v1",
+ label: "w budowie: większość MVP powstała z małym zespołem i agentami AI",
+ },
+ },
+};
+export const getCaseStudies = (locale: Locale = "en"): CaseStudy[] =>
+ publishedCaseStudies.map((study) => {
+ if (locale === "en") return study;
+ if (!polish[study.slug])
+ throw new Error(`Missing Polish case study: ${study.slug}`);
+ return { ...study, ...polish[study.slug] };
+ });
diff --git a/src/content/work/theeventa-mvp.pl.mdx b/src/content/work/theeventa-mvp.pl.mdx
new file mode 100644
index 0000000..613240c
--- /dev/null
+++ b/src/content/work/theeventa-mvp.pl.mdx
@@ -0,0 +1,73 @@
+import { Flow } from "@/components/Flow/Flow";
+
+## Sytuacja
+
+Gdy dołączyłem do TheEventa, platformy rezerwacji wydarzeń, produkt niemal startował od zera: istniało tylko uwierzytelnianie, które nie było ukończone. Skład zespołu zmieniał się wielokrotnie, a każda zmiana oznaczała przekazywanie pracy i utratę kontekstu.
+
+## Ograniczenia
+
+- **Ograniczony czas.** Pracuję nad TheEventa w weekendy i niektóre wieczory, równolegle z pracą na pełen etat.
+- **MVP do dostarczenia**, gdy produkt wciąż nabiera kształtu.
+- **Komunikacja i koordynacja** zajmowały więcej czasu niż sama praca.
+
+## Decyzja
+
+Zaproponowałem zmniejszenie zespołu. Wspólnie z CEO zdecydowaliśmy się wykonać pozostałą pracę bez angażowania kolejnych osób: jeden doświadczony Tech Lead odpowiada za architekturę i rozwój full stack z agentami AI, a projekt powstaje bezpośrednio z CEO. Mały zespół z AI wymaga znacznie mniej koordynacji niż większy.
+
+## Jak powstaje funkcja
+
+
+
+Korzystam z **Cursor, Codex i Claude** — te narzędzia się uzupełniają. Używam ich do planowania, przeglądu kodu, implementacji, architektury, testów oraz sprawdzania pomysłów przed podjęciem decyzji.
+
+## Utrzymanie jakości
+
+Jakość jest celem całego procesu. Praca agentów ma jasno określone ramy:
+
+- dokumentacja, reguły frameworków i opisy architektury,
+- zasady stylu, rekomendacje i umiejętności agentów,
+- testy jednostkowe, integracyjne i end-to-end, z osobnym repozytorium Playwright.
+
+Niektórych zadań nigdy nie pozostawiam agentowi. Sam wysyłam końcowy kod i sam testuję każdą zaimplementowaną funkcję. Agenci nie mają dostępu do serwerów ani produkcji. Infrastrukturą, monitoringiem i administracją serwerów zajmuje się osobny współpracownik DevOps.
+
+## Projektowanie bez projektantów
+
+Brand book, design system i Storybook znacznie ułatwiły projektowanie. Wszystkie trzy są dostępne dla narzędzi AI, dzięki czemu nowe ekrany pozostają spójne z kodem. Nawet CEO może projektować nowe funkcje pasujące do istniejącej aplikacji.
+
+## Architektura
+
+Backend to osobna usługa NestJS, a nie część aplikacji Next.js. Za wyborem NestJS stała ocena jego efektywności przy większej skali oraz plan przejścia na mikroserwisy wraz z rozwojem TheEventa — podejście trudniejsze do zrealizowania wewnątrz Next.js.
+
+## Dotychczasowy rezultat
+
+Duża część MVP jest zbudowana:
+
+- uwierzytelnianie i SSO,
+- organizacje,
+- obiekty wydarzeń i ich przestrzenie,
+- ceny przeliczane po zmianie opcji,
+- kalendarz i rezerwacje.
+
+
+
+## Co zrobiłbym inaczej
+
+Od początku utrzymałbym możliwie mały zespół i unikał zmieniania rzeczy, które już działają.
+
+
diff --git a/src/design/skin-terminal.css b/src/design/skin-terminal.css
index 535cf5e..ea277ca 100644
--- a/src/design/skin-terminal.css
+++ b/src/design/skin-terminal.css
@@ -1650,3 +1650,13 @@ html.skin-terminal.dark-theme .hero-portrait {
background: var(--surface);
border: 1px solid var(--accent);
}
+
+/* Keep both language/header rows inside the sticky Terminal surface. */
+@media (max-width: 639px) {
+ html.skin-terminal {
+ --header-height: 116px;
+ }
+ :where(html.skin-terminal) .language-switch a {
+ border-radius: 0;
+ }
+}
diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts
new file mode 100644
index 0000000..5ccc49b
--- /dev/null
+++ b/src/i18n/locales.ts
@@ -0,0 +1,2 @@
+export const locales = ["en", "pl"] as const;
+export type Locale = (typeof locales)[number];
diff --git a/src/i18n/localization.css b/src/i18n/localization.css
new file mode 100644
index 0000000..a0fd7a2
--- /dev/null
+++ b/src/i18n/localization.css
@@ -0,0 +1,69 @@
+.language-switch {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ gap: 2px;
+}
+.language-switch a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 36px;
+ min-height: 44px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ border-radius: 6px;
+}
+.language-switch a[aria-current="page"] {
+ color: var(--accent);
+ text-decoration: underline;
+ text-underline-offset: 6px;
+}
+.language-switch a:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+@media (max-width: 639px) {
+ :root {
+ --header-height: 112px;
+ }
+ .site-header-inner {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
+ grid-template-rows: minmax(44px, 1fr) 44px;
+ column-gap: 8px;
+ position: relative;
+ padding-top: 8px;
+ padding-bottom: 4px;
+ row-gap: 0;
+ }
+ .language-switch {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ justify-content: flex-end;
+ height: 44px;
+ }
+}
+@media (min-width: 640px) and (max-width: 1099px) {
+ .language-switch {
+ margin-left: auto;
+ }
+}
+@media (min-width: 1100px) and (max-width: 1279px) {
+ .palette-toggle {
+ display: none;
+ }
+ .site-header-inner {
+ gap: 8px;
+ }
+ .site-nav {
+ gap: 14px;
+ }
+}
+
+@media (max-width: 379px) {
+ .site-header-inner {
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ }
+}
diff --git a/src/i18n/localization.test.ts b/src/i18n/localization.test.ts
new file mode 100644
index 0000000..a4e2c55
--- /dev/null
+++ b/src/i18n/localization.test.ts
@@ -0,0 +1,145 @@
+import { readFileSync } from "node:fs";
+import { expect, test } from "vitest";
+import {
+ headingPairs,
+ localizedPath,
+ translatedFragment,
+ pageIdentity,
+ routePath,
+} from "./routes";
+import { getProfile } from "./profile";
+import { getCaseStudies } from "@/content/work/studies";
+import { createCommands, buildShareUrl, type CommandContext } from "@/commands";
+import { headingsFromMdx, slugify } from "@/lib/headings";
+import { pageMetadata } from "@/lib/metadata";
+import {
+ homeStructuredData,
+ caseStudyStructuredData,
+} from "@/lib/structuredData";
+import { isHomePath } from "@/content/looks";
+const pairs = [
+ ["/", "/pl/"],
+ ["/colophon/", "/pl/colophon/"],
+ ["/work/orange-cms/", "/pl/work/orange-cms/"],
+ ["/look/terminal-dark/", "/pl/look/terminal-dark/"],
+ ["/blog/", "/blog/pl/"],
+ ["/blog/en/example/", "/blog/pl/example/"],
+];
+test.each(pairs)("round trips %s and %s", (en, pl) => {
+ expect(localizedPath(en, "pl")).toBe(pl);
+ expect(localizedPath(pl, "en")).toBe(en);
+ expect(routePath(pageIdentity(pl)!, "en")).toBe(en);
+ const m = pageMetadata({ path: pl });
+ expect(m.alternates).toMatchObject({
+ canonical: pl,
+ languages: { en, pl, "x-default": en },
+ });
+ expect(m.openGraph).toMatchObject({ locale: "pl_PL" });
+});
+test("explicit fragments and appearance settings survive, unknown fragments reset", () => {
+ expect(localizedPath("/work/orange-cms/?theme=dark#the-decision", "pl")).toBe(
+ "/pl/work/orange-cms/?theme=dark#decyzja"
+ );
+ expect(translatedFragment("/pl/work/orange-cms/", "#decyzja", "en")).toBe(
+ "#the-decision"
+ );
+ expect(translatedFragment("/work/orange-cms/", "#unmapped", "pl")).toBe("");
+ expect(translatedFragment("/", "#experience", "pl")).toBe("#experience");
+ expect(isHomePath("/pl/look/terminal-dark/")).toBe(true);
+ expect(buildShareUrl("/pl/", { theme: "dark", skin: "terminal" })).toBe(
+ "https://devthomas.pl/pl/look/terminal-dark/"
+ );
+});
+test("every translated body covers all headings with semantic correspondence", () => {
+ for (const [key, map] of Object.entries(headingPairs)) {
+ const [kind, slug] = key.split("/");
+ const paths =
+ kind === "work"
+ ? [`src/content/work/${slug}.mdx`, `src/content/work/${slug}.pl.mdx`]
+ : kind === "article"
+ ? [
+ `src/content/blog/articles/${slug}/en.mdx`,
+ `src/content/blog/articles/${slug}/pl.mdx`,
+ ]
+ : [
+ "src/app/(en)/colophon/page.mdx",
+ "src/app/(pl)/pl/colophon/page.mdx",
+ ];
+ const [en, pl] = paths.map((p) =>
+ headingsFromMdx(readFileSync(p, "utf8")).map((h) => h.id)
+ );
+ for (const h of en) {
+ expect(Object.keys(map).map(slugify)).toContain(h);
+ }
+ for (const h of pl) {
+ expect(Object.values(map).map(slugify)).toContain(h);
+ }
+ }
+});
+test("required profile wording is nonempty; identities, links and numeric evidence stay shared", () => {
+ const en = getProfile("en"),
+ pl = getProfile("pl");
+ expect(pl.name).toBe(en.name);
+ expect(pl.links).toBe(en.links);
+ const validate = (v: unknown): void => {
+ if (typeof v === "string") expect(v.trim()).not.toBe("");
+ else if (v && typeof v === "object") Object.values(v).forEach(validate);
+ };
+ validate(pl);
+ expect(
+ pl.contributions.map((c) => [c.id, c.number, c.bars?.map((b) => b.width)])
+ ).toEqual(
+ en.contributions.map((c) => [c.id, c.number, c.bars?.map((b) => b.width)])
+ );
+ expect(pl.timeline.slice(1).map((t) => [t.name, t.current])).toEqual(
+ en.timeline.slice(1).map((t) => [t.name, t.current])
+ );
+ expect(pl.approach[3].text).toContain(
+ "ATOM (Akademia Tworzenia Oprogramowania)"
+ );
+ for (const [i, study] of getCaseStudies("pl").entries()) {
+ const original = getCaseStudies("en")[i];
+ expect([
+ study.slug,
+ study.company,
+ study.stack,
+ study.published,
+ study.contributionId,
+ ]).toEqual([
+ original.slug,
+ original.company,
+ original.stack,
+ original.published,
+ original.contributionId,
+ ]);
+ expect(caseStudyStructuredData(study, "pl").inLanguage).toBe("pl");
+ }
+ expect(homeStructuredData("pl")["@graph"][1].inLanguage).toBe("pl");
+});
+test("command IDs and aliases stable; localized destinations and bilingual keywords", () => {
+ const en = createCommands("en"),
+ pl = createCommands("pl");
+ expect(pl.map((c) => [c.id, c.alias])).toEqual(
+ en.map((c) => [c.id, c.alias])
+ );
+ const paths: string[] = [];
+ const noop = () => {};
+ const ctx: CommandContext = {
+ goToPage: (path: string) => {
+ paths.push(path);
+ },
+ goToSection: noop,
+ openExternal: noop,
+ copyEmail: noop,
+ copyShareLink: noop,
+ setTheme: noop,
+ setSkin: noop,
+ };
+ pl.find((c) => c.id === "blog")!.run(ctx);
+ pl.find((c) => c.id === "case-study-orange-cms")!.run(ctx);
+ pl.find((c) => c.id === "colophon")!.run(ctx);
+ expect(paths).toEqual(["/blog/pl/", "/pl/work/orange-cms/", "/pl/colophon/"]);
+ expect(en.find((c) => c.id === "section-work")!.keywords).toContain(
+ "Realizacje"
+ );
+});
diff --git a/src/i18n/profile.ts b/src/i18n/profile.ts
new file mode 100644
index 0000000..3c0e2f4
--- /dev/null
+++ b/src/i18n/profile.ts
@@ -0,0 +1,190 @@
+import { profile, type Profile } from "@/content/publicProfile";
+import type { Locale } from "./locales";
+
+const polish: Profile = {
+ ...profile,
+ headline: "Inżynier oprogramowania i Tech Lead",
+ introduction:
+ "Projektuję i tworzę aplikacje webowe i mobilne — od interfejsów po usługi backendowe. Łączę pracę nad kodem z architekturą, testowaniem i przywództwem technicznym.",
+ hero: {
+ headline: "Złożone wymagania przekuwam w działające aplikacje.",
+ headlineAccent: "aplikacje",
+ subline:
+ "Aplikacje webowe i mobilne — od interfejsów po usługi backendowe, wraz z architekturą, testowaniem i przywództwem technicznym. Regularnie pracuję w godzinach wspólnych z zespołami w USA.",
+ location: "Polska · Zdalnie",
+ primaryAction: "Zobacz realizacje",
+ secondaryAction: "Porozmawiajmy",
+ },
+ architecture: {
+ title: "Orange Polska · wewnętrzny CMS · wybierz element",
+ parts: profile.architecture.parts.map((part) => ({
+ ...part,
+ ...(
+ {
+ backend: {
+ label: "Backend",
+ short: "Konfiguracja i JSON Schema",
+ caption:
+ "Backend opisuje frontend: nawigacja, adresy URL, widoki i formularze przychodzą jako konfiguracja i schematy JSON.",
+ },
+ shell: {
+ label: "Szkielet CMS",
+ short: "Nawigacja, adresy, widoki",
+ caption:
+ "Aplikacja React jest uniwersalnym rendererem. Szkielet CMS buduje nawigację, adresy i widoki na podstawie danych z backendu.",
+ },
+ contract: {
+ label: "Kontrola kontraktu",
+ short: "Backend a frontend",
+ caption:
+ "Ścisła kontrola kontraktu porównuje dane z backendu z oczekiwaniami frontendu i zapisuje szczegółowe błędy dla programistów.",
+ },
+ forms: {
+ label: "Formularze",
+ short: "RJSF i własne widgety",
+ caption:
+ "RJSF renderuje schematy jako formularze, rozszerzone o nasze pola i widgety oparte na MUI.",
+ },
+ grids: {
+ label: "Tabele danych",
+ short: "MUI X, operacje zbiorcze",
+ caption:
+ "MUI X Data Grid wyświetla dane i skonfigurowane akcje, w tym operacje zbiorcze.",
+ },
+ } satisfies Record<
+ string,
+ Pick<
+ Profile["architecture"]["parts"][number],
+ "label" | "short" | "caption"
+ >
+ >
+ )[part.id],
+ })),
+ footer: {
+ ...profile.architecture.footer,
+ label: "Nowe zasoby wdrożone bez zmian frontendu",
+ value: "Dziesiątki",
+ },
+ },
+ work: {
+ eyebrow: "Wybrane realizacje · Orange Polska",
+ linkLabel: "Przeczytaj studium przypadku",
+ },
+ contributions: [
+ {
+ ...profile.contributions[0],
+ topic: "Architektura frontendu",
+ period: "Od końca 2022",
+ title: "CMS, który backend rozszerza bez zmian frontendu",
+ summary:
+ "Jeden wewnętrzny CMS dla wielu systemów legacy, zaprojektowany na lata. Wybrałem stack i pozwoliłem backendowi opisywać nawigację, widoki i formularze.",
+ outcome:
+ "Dziesiątki nowych zasobów trafiły na produkcję bez pracy nad frontendem.",
+ decision:
+ "React + TypeScript, MUI i RJSF zamiast czystego JS i własnych komponentów",
+ tradeOff:
+ "Trudniejsze debugowanie, wsparte kontrolą kontraktu i dedykowanymi logami",
+ },
+ {
+ ...profile.contributions[1],
+ topic: "Jakość i mentoring",
+ period: "Od 2022",
+ title: "Od dni ręcznej regresji do mniej niż godziny",
+ summary:
+ "Zamiast pisać propozycję, zbudowałem działający zestaw testów Cypress. Mój przełożony zatrudnił testera automatyzującego; przeszkoliłem dwie osoby i przez pierwszy rok przeglądałem każdą zmianę.",
+ outcome: "1–3 dni → około godziny w Orange TV GO.",
+ bars: profile.contributions[1].bars!.map((bar, i) => ({
+ ...bar,
+ label: [
+ "Ręczna regresja, ponad 100 scenariuszy",
+ "Cypress, Orange TV GO",
+ "Cypress, wewnętrzny CMS (~80)",
+ ][i],
+ value: i === 0 ? "1–3 dni" : bar.value,
+ })),
+ tags: profile.contributions[1].tags.map((t) =>
+ t === "Parallel runs" ? "Równoległe uruchomienia" : t
+ ),
+ },
+ ],
+ building: {
+ ...profile.building,
+ eyebrow: "Teraz tworzę",
+ status: "MVP v1 w budowie",
+ title:
+ "TheEventa: prowadzę rozwój MVP rezerwacji wydarzeń z małym zespołem i agentami AI",
+ text: "Tech Lead od września 2025, równolegle z pracą w Orange. Next.js, NestJS, panel administracyjny i projekt E2E w Playwright — każda zmiana przechodzi moją weryfikację.",
+ steps: [
+ "Projekt",
+ "Ocena biznesowa",
+ "AI + ocena biznesowa",
+ "Implementacja",
+ "Przegląd techniczny",
+ "Moja akceptacja",
+ ],
+ linkLabel: "Jak prowadzę rozwój",
+ },
+ approach: [
+ {
+ label: "Architektura",
+ text: "Od interfejsów konfigurowanych przez backend po produkty full stack.",
+ proof: { href: "/pl/work/orange-cms/", label: "Przykład: CMS" },
+ },
+ {
+ label: "Jakość",
+ text: "Przegląd kodu i funkcji, weryfikacja ręczna oraz testy automatyczne.",
+ proof: {
+ href: "/pl/work/orange-e2e-testing/",
+ label: "Przykład: 1–3 dni → ~1 h",
+ },
+ },
+ {
+ label: "Wsparcie techniczne",
+ text: "Mentoring programistów i testerów poprzez praktyczny przegląd ich pracy.",
+ proof: {
+ href: "/pl/work/orange-e2e-testing/",
+ label: "Przykład: szkolenie dwóch testerów",
+ },
+ },
+ {
+ label: "Społeczność",
+ text: "Prowadzę ATOM (Akademia Tworzenia Oprogramowania).",
+ note: "900 członków",
+ },
+ ],
+ timeline: profile.timeline.map((entry, i) => ({
+ ...entry,
+ period: [
+ "Wcześniejsza kariera",
+ "maj–cze 2022",
+ "paź 2022–obecnie",
+ "wrz 2025–obecnie",
+ ][i],
+ name: i === 0 ? "Finanse i księgowość" : entry.name,
+ detail: [
+ entry.detail,
+ "Junior Full Stack Developer",
+ "Inżynieria oprogramowania i przywództwo techniczne",
+ "Tech Lead · rola równoległa",
+ ][i],
+ })),
+ cvLinkLabel: "Pełne CV i PDF (EN)",
+ contact: {
+ heading: "Otwarty na role Tech Leada z pracą nad kodem",
+ line: "Pracuję z Polski, regularnie w godzinach wspólnych z zespołami w USA.",
+ },
+};
+// A changed source shape requires an explicit translation update, never a silent
+// English fallback. Required copy fields are also checked by localization tests.
+if (
+ profile.architecture.parts.some(
+ (part) =>
+ !["backend", "shell", "contract", "forms", "grids"].includes(part.id)
+ ) ||
+ profile.contributions.length !== 2 ||
+ profile.timeline.length !== 4
+) {
+ throw new Error("Profile shape changed: update the complete Polish edition");
+}
+export const getProfile = (locale: Locale = "en"): Profile =>
+ locale === "pl" ? polish : profile;
diff --git a/src/i18n/routes.ts b/src/i18n/routes.ts
new file mode 100644
index 0000000..799cd41
--- /dev/null
+++ b/src/i18n/routes.ts
@@ -0,0 +1,124 @@
+import type { Locale } from "./locales";
+import { slugify } from "@/lib/headings";
+export const localeForPath = (path: string): Locale =>
+ /^\/(?:pl(?:\/|$)|blog\/pl(?:\/|$))/.test(path) ? "pl" : "en";
+export const homePath = (locale: Locale) => (locale === "pl" ? "/pl/" : "/");
+export type PageIdentity =
+ | { kind: "home" }
+ | { kind: "colophon" }
+ | { kind: "blog" }
+ | { kind: "work" | "look" | "article"; slug: string };
+export function pageIdentity(path: string): PageIdentity | undefined {
+ const base = path.split(/[?#]/)[0].replace(/\/$/, "") || "/";
+ const plain = base.replace(/^\/pl(?=\/|$)/, "") || "/";
+ if (plain === "/") return { kind: "home" };
+ if (plain === "/colophon") return { kind: "colophon" };
+ if (base === "/blog" || base === "/blog/pl") return { kind: "blog" };
+ const article = /^\/blog\/(?:en|pl)\/([^/]+)$/.exec(base);
+ if (article) return { kind: "article", slug: article[1] };
+ const detail = /^\/(work|look)\/([^/]+)$/.exec(plain);
+ if (detail) return { kind: detail[1] as "work" | "look", slug: detail[2] };
+}
+export function routePath(page: PageIdentity, locale: Locale): string {
+ if (page.kind === "blog") return locale === "pl" ? "/blog/pl/" : "/blog/";
+ if (page.kind === "article") return `/blog/${locale}/${page.slug}/`;
+ const prefix = locale === "pl" ? "/pl" : "";
+ if (page.kind === "home") return `${prefix}/`;
+ if (page.kind === "colophon") return `${prefix}/colophon/`;
+ return `${prefix}/${page.kind}/${page.slug}/`;
+}
+export function localizedPath(path: string, locale: Locale): string {
+ const identity = pageIdentity(path);
+ if (!identity) return path;
+ const query = path.split("?")[1]?.split("#")[0];
+ const hash = path.includes("#") ? `#${path.split("#")[1]}` : "";
+ return (
+ routePath(identity, locale) +
+ (query ? `?${query}` : "") +
+ translatedFragment(path, hash, locale)
+ );
+}
+// Explicit semantic correspondence. No positional pairing: edits/reordering do
+// not silently redirect an existing fragment to an unrelated heading.
+const common = {
+ "The situation": "Sytuacja",
+ Constraints: "Ograniczenia",
+ "Options I considered": "Rozważane opcje",
+ "The decision": "Decyzja",
+ "The outcome": "Rezultat",
+ "What I'd do differently": "Co zrobiłbym inaczej",
+};
+export const headingPairs: Record> = {
+ "work/orange-cms": { ...common, "How it works": "Jak to działa" },
+ "work/orange-e2e-testing": {
+ ...common,
+ "How it started": "Początki",
+ "Building the practice": "Budowanie praktyki testowania",
+ "How it runs": "Uruchamianie testów",
+ },
+ "work/theeventa-mvp": {
+ ...common,
+ "How a feature gets built": "Jak powstaje funkcja",
+ "Keeping quality high": "Utrzymanie jakości",
+ "Design without designers": "Projektowanie bez projektantów",
+ Architecture: "Architektura",
+ "The outcome so far": "Dotychczasowy rezultat",
+ },
+ colophon: {
+ Stack: "Stack",
+ "Two skins, one codebase": "Dwa style, jeden kod",
+ Accessibility: "Dostępność",
+ "Tests and delivery": "Testy i wdrożenia",
+ Performance: "Wydajność",
+ },
+ "article/building-mobile-and-web-clients-around-one-appwrite-backend": {
+ "The system boundary: shared services, separate clients":
+ "Granica systemu: wspólne usługi, osobne klienty",
+ "The session boundary: a server layer earns its keep":
+ "Granica sesji: serwer musi na siebie zapracować",
+ "The state boundary: one database, several versions of “now”":
+ "Granica stanu: jedna baza, kilka wersji „teraz”",
+ "The reuse boundary: duplication with an expiry question":
+ "Granica współdzielenia: duplikacja z pytaniem o termin ważności",
+ "What would turn this diagram into evidence?":
+ "Co zamieni ten schemat w dowód?",
+ },
+};
+const homeFragments = new Set([
+ "home",
+ "work",
+ "now",
+ "approach",
+ "about",
+ "experience",
+ "contact",
+ "writing",
+ "orange-engineering",
+ "orange-e2e",
+ "theeventa",
+ "car-brain",
+]);
+export function translatedFragment(
+ path: string,
+ hash: string,
+ target: Locale
+): string {
+ if (!hash) return "";
+ if (localeForPath(path) === target) return hash;
+ const page = pageIdentity(path);
+ if (!page) return "";
+ let fragment: string;
+ try {
+ fragment = decodeURIComponent(hash.replace(/^#/, ""));
+ } catch {
+ return "";
+ }
+ if (page.kind === "home" || page.kind === "look")
+ return homeFragments.has(fragment.replace(/-heading$/, "")) ? hash : "";
+ const key = "slug" in page ? `${page.kind}/${page.slug}` : page.kind;
+ for (const [en, pl] of Object.entries(headingPairs[key] ?? {})) {
+ if (fragment === slugify(localeForPath(path) === "en" ? en : pl))
+ return `#${slugify(target === "en" ? en : pl)}`;
+ }
+ return "";
+}
diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts
index 5d31248..6c880b9 100644
--- a/src/lib/metadata.ts
+++ b/src/lib/metadata.ts
@@ -1,3 +1,5 @@
+import { getProfile } from "@/i18n/profile";
+import { localeForPath, localizedPath } from "@/i18n/routes";
import type { Metadata } from "next";
import { profile } from "@/content/publicProfile";
@@ -31,20 +33,37 @@ type PageMetadataInput = {
export const pageMetadata = ({
path,
title,
- description = profile.introduction,
+ description,
canonical = path,
image,
languages,
noIndex,
article,
}: PageMetadataInput): Metadata => {
- const fullTitle = title ? `${title} · ${profile.name}` : SITE_TITLE;
- const shareImage = image ? { ...SHARE_IMAGE, ...image } : SHARE_IMAGE;
+ const locale = localeForPath(path);
+ const localProfile = getProfile(locale);
+ description ??= localProfile.introduction;
+ const fullTitle = title
+ ? `${title} · ${profile.name}`
+ : `${profile.name}, ${localProfile.headline}`;
+ const shareImage = image
+ ? { ...SHARE_IMAGE, ...image }
+ : locale === "pl"
+ ? {
+ ...SHARE_IMAGE,
+ url: "/pl/look/standard-light/card.jpg",
+ alt: "Portfolio Tomasza Stanisza — inżynier oprogramowania i Tech Lead",
+ }
+ : SHARE_IMAGE;
+ const pairs = languages ?? {
+ en: localizedPath(canonical, "en"),
+ pl: localizedPath(canonical, "pl"),
+ };
return {
title: fullTitle,
description,
- alternates: { canonical, ...(languages ? { languages } : {}) },
+ alternates: { canonical, languages: { ...pairs, "x-default": pairs.en } },
...(noIndex ? { robots: { index: false, follow: false } } : {}),
openGraph: {
...(article
@@ -56,7 +75,11 @@ export const pageMetadata = ({
modifiedTime: article.updatedAt,
authors: [profile.name],
}
- : { type: "website" as const }),
+ : {
+ type: "website" as const,
+ locale: locale === "pl" ? "pl_PL" : "en_GB",
+ alternateLocale: locale === "pl" ? "en_GB" : "pl_PL",
+ }),
url: path,
title: fullTitle,
description,
diff --git a/src/lib/structuredData.ts b/src/lib/structuredData.ts
index 74c67d3..0ff58f8 100644
--- a/src/lib/structuredData.ts
+++ b/src/lib/structuredData.ts
@@ -1,3 +1,6 @@
+import { getProfile } from "@/i18n/profile";
+import type { Locale } from "@/i18n/locales";
+import { homePath } from "@/i18n/routes";
import { profile } from "@/content/publicProfile";
import { SITE_URL } from "@/lib/metadata";
import { caseStudyPath, type CaseStudy } from "@/content/work/studies";
@@ -11,15 +14,15 @@ const WEBSITE_ID = `${SITE_URL}/#website`;
const organization = (name: string) => ({ "@type": "Organization", name });
-export const homeStructuredData = () => ({
+export const homeStructuredData = (locale: Locale = "en") => ({
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": PERSON_ID,
name: profile.name,
- jobTitle: profile.headline,
- description: profile.introduction,
+ jobTitle: getProfile(locale).headline,
+ description: getProfile(locale).introduction,
url: `${SITE_URL}/`,
image: `${SITE_URL}/portrait.jpg`,
sameAs: [profile.links.linkedin, profile.links.github, profile.links.cv],
@@ -46,16 +49,19 @@ export const homeStructuredData = () => ({
{
"@type": "WebSite",
"@id": WEBSITE_ID,
- url: `${SITE_URL}/`,
- name: `${profile.name}, ${profile.headline}`,
- inLanguage: "en",
+ url: `${SITE_URL}${homePath(locale)}`,
+ name: `${profile.name}, ${getProfile(locale).headline}`,
+ inLanguage: locale,
publisher: { "@id": PERSON_ID },
},
],
});
-export const caseStudyStructuredData = (study: CaseStudy) => {
- const url = `${SITE_URL}${caseStudyPath(study.slug)}`;
+export const caseStudyStructuredData = (
+ study: CaseStudy,
+ locale: Locale = "en"
+) => {
+ const url = `${SITE_URL}${caseStudyPath(study.slug, locale)}`;
return {
"@context": "https://schema.org",
"@type": "Article",
@@ -63,7 +69,7 @@ export const caseStudyStructuredData = (study: CaseStudy) => {
description: study.description,
url,
mainEntityOfPage: url,
- inLanguage: "en",
+ inLanguage: locale,
image: `${SITE_URL}/og-share.png`,
about: organization(study.company),
author: { "@type": "Person", "@id": PERSON_ID, name: profile.name },
diff --git a/src/sections/Approach/Approach.tsx b/src/sections/Approach/Approach.tsx
index a27791e..6e8e335 100644
--- a/src/sections/Approach/Approach.tsx
+++ b/src/sections/Approach/Approach.tsx
@@ -1,21 +1,25 @@
import React from "react";
import Link from "next/link";
-import { profile } from "../../content/publicProfile";
+import { getProfile } from "@/i18n/profile";
+import type { Locale } from "@/i18n/locales";
// How I work, each principle with a link to where it shows.
-export const Approach = () => (
-
- {profile.approach.map((item) => (
-
- {item.label}
- {item.text}
- {item.proof && (
-
- {item.proof.label} →
-
- )}
- {item.note && {item.note}
}
-
- ))}
-
-);
+export const Approach = ({ locale = "en" }: { locale?: Locale }) => {
+ const profile = getProfile(locale);
+ return (
+
+ {profile.approach.map((item) => (
+
+ {item.label}
+ {item.text}
+ {item.proof && (
+
+ {item.proof.label} →
+
+ )}
+ {item.note && {item.note}
}
+
+ ))}
+
+ );
+};
diff --git a/src/sections/Background/Background.tsx b/src/sections/Background/Background.tsx
index 84712fa..c514f95 100644
--- a/src/sections/Background/Background.tsx
+++ b/src/sections/Background/Background.tsx
@@ -1,32 +1,39 @@
import React from "react";
-import { profile } from "../../content/publicProfile";
+import { getProfile } from "@/i18n/profile";
+import type { Locale } from "@/i18n/locales";
// A short timeline; the CV holds the full history.
-export const Background = () => (
-
- {profile.timeline.map((entry) => (
-
-
- {entry.period}
- {entry.name}
- {entry.detail}
-
- ))}
-
-);
+export const Background = ({ locale = "en" }: { locale?: Locale }) => {
+ const profile = getProfile(locale);
+ return (
+
+ {profile.timeline.map((entry) => (
+
+
+ {entry.period}
+ {entry.name}
+ {entry.detail}
+
+ ))}
+
+ );
+};
-export const CvLink = () => (
-
- {profile.cvLinkLabel} →
-
-);
+export const CvLink = ({ locale = "en" }: { locale?: Locale }) => {
+ const profile = getProfile(locale);
+ return (
+
+ {profile.cvLinkLabel} →
+
+ );
+};
diff --git a/src/sections/Contact/Contact.tsx b/src/sections/Contact/Contact.tsx
index 1f4384c..ada7609 100644
--- a/src/sections/Contact/Contact.tsx
+++ b/src/sections/Contact/Contact.tsx
@@ -1,22 +1,26 @@
import React from "react";
-import { profile } from "../../content/publicProfile";
+import { getProfile } from "@/i18n/profile";
+import type { Locale } from "@/i18n/locales";
import { ActionLink } from "../../components/ActionLink/ActionLink";
-const Contact = () => (
-
-
{profile.contact.line}
-
-
- {profile.links.emailLabel}
-
-
- LinkedIn
-
-
- GitHub
-
+const Contact = ({ locale = "en" }: { locale?: Locale }) => {
+ const profile = getProfile(locale);
+ return (
+
+
{profile.contact.line}
+
+
+ {profile.links.emailLabel}
+
+
+ LinkedIn
+
+
+ GitHub
+
+
-
-);
+ );
+};
export default Contact;
diff --git a/src/sections/HomePage.tsx b/src/sections/HomePage.tsx
index a777157..509224b 100644
--- a/src/sections/HomePage.tsx
+++ b/src/sections/HomePage.tsx
@@ -8,34 +8,46 @@ import { Background, CvLink } from "@/sections/Background/Background";
import Contact from "@/sections/Contact/Contact";
import { JsonLd } from "@/components/JsonLd/JsonLd";
import { homeStructuredData } from "@/lib/structuredData";
-import { profile } from "@/content/publicProfile";
+import { getProfile } from "@/i18n/profile";
+import type { Locale } from "@/i18n/locales";
import { sectionTitle } from "@/content/navigation";
// The home page's sections, shared by / and its /look/
/ copies. Their
// order and titles come from src/content/navigation.ts.
-export const HomePage = () => (
- <>
-
-
-
-
-
-
-
-
-
-
-
- }>
-
-
-
-
-
-
- >
-);
+export const HomePage = ({ locale = "en" }: { locale?: Locale }) => {
+ const profile = getProfile(locale);
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/sections/Now/NowSection.tsx b/src/sections/Now/NowSection.tsx
index f59305e..1104a92 100644
--- a/src/sections/Now/NowSection.tsx
+++ b/src/sections/Now/NowSection.tsx
@@ -1,6 +1,7 @@
import React from "react";
import Link from "next/link";
-import { profile } from "../../content/publicProfile";
+import { getProfile } from "@/i18n/profile";
+import type { Locale } from "@/i18n/locales";
import {
carBrain as carBrainContent,
type CarBrain,
@@ -8,9 +9,10 @@ import {
import { caseStudyFor, caseStudyPath } from "../../content/work/studies";
import { CarBrainShots } from "./CarBrainShots";
-const Building = () => {
+const Building = ({ locale = "en" }: { locale?: Locale }) => {
+ const profile = getProfile(locale);
const { building } = profile;
- const caseStudy = caseStudyFor(building.id);
+ const caseStudy = caseStudyFor(building.id, locale);
const headingId = `${building.id}-heading`;
return (
@@ -30,7 +32,12 @@ const Building = () => {
{building.title}
{building.text}
-
+
{building.steps.map((step, index) => (
{
{caseStudy && (
-
+
{building.linkLabel}
- at TheEventa {" "}
+
+ {locale === "pl" ? " w TheEventa" : " at TheEventa"}
+ {" "}
→
@@ -57,7 +66,13 @@ const Building = () => {
);
};
-const Shipped = ({ carBrain }: { carBrain: CarBrain }) => (
+const Shipped = ({
+ carBrain,
+ locale = "en",
+}: {
+ carBrain: CarBrain;
+ locale?: Locale;
+}) => (
(
{carBrain.title}
{carBrain.text}
-