Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ All of these must pass before a change is done. CI (`.github/actions/check/`) ru
- **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`).
- **Dark bands.** Inside `.band` (header row, hero, approach, Now building card) the generic tokens already hold band values. Use `--text`, `--surface`, `--accent` and so on; don't hard-code band colours, or the Terminal skin can't reset them.
- **Car Brain gate.** `CAR_BRAIN_PUBLISHED` in `src/content/carBrainLinks.ts` stays `false` until Tomasz says the App Store listing is live. Keep the store links behind it, and never import `src/content/carBrain.ts` into client code, or the links reach the build while unpublished.
- **Car Brain gate.** `CAR_BRAIN_PUBLISHED` in `src/content/carBrainLinks.ts` stays `false` until Tomasz says the App Store listing is live. The independent-product card is visible during development; keep the store links behind the gate, and never import `src/content/carBrain.ts` into client code, or the links reach the build while unpublished.
- **Accessibility.** Keyboard access, visible focus, reduced motion, and no sideways scroll at 320px are all required. The axe scans must stay at zero violations.

## Content
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ GitHub Actions runs all of these on Node 24.21.0, through the shared steps in `.
- `src/mdx-components.tsx` — how MDX pages render: one article in the prose style (`src/components/Prose/prose.css`), an id on every `h2`, tables that scroll on their own, external links in a new tab, and the case-study components
- `src/lib/metadata.ts` — title, description, canonical and Open Graph tags for each page
- `src/content/publicProfile.ts` — the public wording and links: hero, architecture panel, work cards, the Now building card, approach, timeline and contact
- `src/content/carBrain.ts` and `src/content/carBrainLinks.ts` — the Car Brain card and its publication gate. While `CAR_BRAIN_PUBLISHED` is `false`, the card is not rendered and the build contains no store link. The links sit behind the gate, so the build drops them; client code imports only `carBrainLinks.ts`, never the card content.
- `src/content/carBrain.ts` and `src/content/carBrainLinks.ts` — the Car Brain card and its publication gate. The independent-product card is visible during development in EN/PL. While `CAR_BRAIN_PUBLISHED` is `false`, the build contains no store link. The links sit behind the gate, so the build drops them; client code imports only `carBrainLinks.ts`, never the card content.
- `src/components/NavBar/` — section navigation ("← All work" on case studies), mobile menu, and the theme and style controls
- `src/components/Hero/` and `src/components/ArchitecturePanel/` — the hero band and its clickable CMS diagram
- Client components are kept small: NavBar with the command palette, `ArchitecturePanel`, `src/sections/Now/CarBrainShots.tsx`, `src/components/RevealOnView/` (the E2E bars' entrance) and `CaseStudyToc`. Everything else is a Server Component.
Expand Down Expand Up @@ -102,7 +102,7 @@ Releases are tagged on `main` and listed on the repository's Releases page. `pac

The Vitest suite checks focus after Escape and after choosing a section, hash updates, Back, reduced motion, closed-menu removal from keyboard navigation, desktop links after a resize, the saved theme and skin, the hero's section link, and links off the home page. The Playwright suite covers the built site in a real browser. Neither replaces a manual browser pass.

Look at 1440, 768, 390, and 320px, and at both sides of the 801px navigation switch. Check both skins and both themes, zoom, the long email, the architecture panel and the case-study "On this page" list, and that the page does not scroll sideways. Flip `CAR_BRAIN_PUBLISHED` locally to check the Car Brain card too.
Look at 1440, 768, 390, and 320px, and at both sides of the 801px navigation switch. Check both skins and both themes, zoom, the long email, the architecture panel and the case-study "On this page" list, and that the page does not scroll sideways. Check the Car Brain product card and screenshot controls without changing `CAR_BRAIN_PUBLISHED`; store availability remains a separate gate.

On 2026-09-29, `https://3d-portfolio.devthomas.pl/` did not resolve (DNS `NXDOMAIN`). The footer does not link to it. GitHub, this portfolio, and the CV remain linked.

Expand Down Expand Up @@ -135,7 +135,7 @@ env -u BLOG_PREVIEW_DIR npm run build
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.
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. Article publication does not enable App Store links; the independent product card describes ongoing development.

## English / Polish localization

Expand Down
6 changes: 3 additions & 3 deletions e2e/site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,16 +464,16 @@ test("the E2E bars grow in once they scroll into view", async ({ page }) => {
await expect(bars).toHaveAttribute("data-reveal", "revealed");
});

test("Car Brain and its store link appear only once it is published", async ({
test("Car Brain is visible while its store link remains gated", async ({
page,
}) => {
for (const path of ["/", "/look/standard-light/"]) {
await page.goto(path);
const html = await page.content();
expect(html.includes("apps.apple.com")).toBe(carBrain.published);
await expect(
page.getByRole("heading", { level: 3, name: carBrain.title })
).toHaveCount(carBrain.published ? 1 : 0);
page.getByRole("heading", { level: 3, name: carBrain.title, exact: true })
).toHaveCount(1);
}
});

Expand Down
85 changes: 64 additions & 21 deletions src/content/carBrain.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { CAR_BRAIN_PUBLISHED, carBrainLinks } from "./carBrainLinks";

// Car Brain, the app I built on my own. While it is unpublished (see
// ./carBrainLinks.ts), the card is not rendered and no store link appears
// anywhere: not in the page, the structured data or the command palette.
// The product card is visible during development; store links remain gated.

export type CarBrainShot = {
id: string;
Expand All @@ -14,10 +12,17 @@ export type CarBrainShot = {
export type CarBrain = {
published: boolean;
eyebrow: string;
years: string;
title: string;
text: string;
tags: string[];
role: string;
architecture: string;
features: string;
automation: string;
status: string;
articleLabel: string;
screensLabel: string;
sampleNote: string;
appStoreLabel: string;
appStoreUrl: string;
siteLabel: string;
Expand All @@ -30,30 +35,28 @@ export type CarBrain = {

export const carBrain: CarBrain = {
published: CAR_BRAIN_PUBLISHED,
eyebrow: "Shipped on my own",
years: "2024–2025",
title: "Car Brain: a vehicle-management app, built end to end",
text: "Fuel, maintenance and expenses for one or many cars, with an analytics dashboard, service reminders, AI-powered insights and a night-driving theme. I built all of it: the React Native app for iOS and Android, the Appwrite backend and cloud functions, and the landing page.",
tags: [
"React Native",
"iOS",
"Android",
"Appwrite",
"Google & Apple sign-in",
],
eyebrow: "Independent product",
title: "Car Brain",
role: "Creator & Developer",
text: "A vehicle-management app I’m building for iOS, Android and the web around a shared Appwrite backend.",
architecture:
"React Native powers the mobile app and Next.js the web client under development. Both use TypeScript. Cloud functions handle AI insights and VIN decoding.",
features:
"Vehicle management · Maintenance, fuel & costs · Reports & analytics",
automation:
"AI-powered vehicle insights · VIN decoding with automatic form filling",
status: "Mobile app reactivation and web development in progress.",
articleLabel: "Why I built Car Brain",
screensLabel: "Car Brain screens",
sampleNote: "App screens with sample data.",
tags: ["React Native", "Next.js", "TypeScript", "Appwrite"],
appStoreLabel: "Download on the App Store",
appStoreUrl: carBrainLinks?.appStoreUrl ?? "",
siteLabel: "car-brain.com",
siteUrl: carBrainLinks?.siteUrl ?? "",
shotWidth: 392,
shotHeight: 822,
shots: [
{
id: "analytics",
label: "Analytics",
src: "/work/car-brain/cb-analytics.webp",
alt: "Car Brain analytics screen with fuel-efficiency chart",
},
{
id: "refuels",
label: "Refuels",
Expand All @@ -74,3 +77,43 @@ export const carBrain: CarBrain = {
},
],
};

export const carBrainFor = (locale: "en" | "pl"): CarBrain => {
if (locale === "en") return carBrain;
const shots = {
refuels: {
label: "Tankowania",
alt: "Car Brain: historia tankowań, kosztów i zużycia paliwa",
},
services: {
label: "Serwis",
alt: "Car Brain: historia serwisowa według kategorii",
},
themes: {
label: "Motywy",
alt: "Car Brain: ustawienia motywów, w tym trybu nocnej jazdy",
},
};
return {
...carBrain,
eyebrow: "Własny produkt",
role: "Twórca i programista",
text: "Rozwijam aplikację do zarządzania pojazdami na iOS, Androida i przeglądarkę, ze wspólnym backendem Appwrite.",
architecture:
"Aplikacja mobilna korzysta z React Native, a powstająca wersja webowa z Next.js. Obie używają TypeScriptu. Funkcje chmurowe obsługują analizę AI i dekodowanie VIN.",
features:
"Zarządzanie pojazdami · Serwis, paliwo i koszty · Raporty i analityka",
automation:
"Analiza danych pojazdu z AI · Dekodowanie VIN i automatyczne uzupełnianie formularza",
status:
"Trwają prace nad przywróceniem aplikacji mobilnej i rozwojem wersji webowej.",
articleLabel: "Dlaczego tworzę Car Brain",
screensLabel: "Ekrany Car Brain",
sampleNote: "Ekrany aplikacji z przykładowymi danymi.",
appStoreLabel: "Pobierz z App Store",
shots: carBrain.shots.map((shot) => ({
...shot,
...shots[shot.id as keyof typeof shots],
})),
};
};
13 changes: 11 additions & 2 deletions src/sections/Now/CarBrainShots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ import type { CarBrainShot } from "../../content/carBrain";

type CarBrainShotsProps = {
shots: CarBrainShot[];
label: string;
caption: string;
width: number;
height: number;
};

// A phone screenshot with buttons that swap the screen. The image keeps its
// width and height attributes, so swapping never shifts the layout.
export const CarBrainShots = ({ shots, width, height }: CarBrainShotsProps) => {
export const CarBrainShots = ({
shots,
width,
height,
label,
caption,
}: CarBrainShotsProps) => {
const [currentId, setCurrentId] = useState(shots[0]?.id);
const current = shots.find((shot) => shot.id === currentId) ?? shots[0];

Expand All @@ -37,7 +45,7 @@ export const CarBrainShots = ({ shots, width, height }: CarBrainShotsProps) => {
/>
</div>
</div>
<div className="shot-tabs" role="group" aria-label="Car Brain screens">
<div className="shot-tabs" role="group" aria-label={label}>
{shots.map((shot) => (
<button
key={shot.id}
Expand All @@ -50,6 +58,7 @@ export const CarBrainShots = ({ shots, width, height }: CarBrainShotsProps) => {
</button>
))}
</div>
<figcaption className="now-shot-caption">{caption}</figcaption>
</figure>
);
};
22 changes: 19 additions & 3 deletions src/sections/Now/NowSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ describe("while Car Brain is unpublished", () => {
}
});

test("only TheEventa renders, across the full row", () => {
test("both development cards render before store availability", () => {
const { container } = render(<NowSection carBrain={hidden} />);
expect(
screen.getByRole("article", { name: profile.building.title })
).toBeInTheDocument();
expect(
screen.queryByRole("article", { name: carBrain.title })
).not.toBeInTheDocument();
expect(container.firstChild).toHaveClass("now-grid--single");
).toBeInTheDocument();
expect(container.firstChild).not.toHaveClass("now-grid--single");
});

test("no store or site link reaches the page", () => {
Expand Down Expand Up @@ -82,3 +82,19 @@ test("TheEventa links to its case study", () => {
screen.getByRole("link", { name: /How I run delivery/ })
).toHaveAttribute("href", caseStudyPath("theeventa-mvp").replace(/\/$/, ""));
});

test("Polish scope and article link are localized without store claims", () => {
render(<NowSection locale="pl" />);
expect(
screen.getByText(/Dekodowanie VIN i automatyczne/)
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Dlaczego tworzę Car Brain/ })
).toHaveAttribute("href", "/blog/pl/why-i-built-car-brain");
expect(
screen.getByRole("group", { name: "Ekrany Car Brain" })
).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /Pobierz/ })
).not.toBeInTheDocument();
});
82 changes: 45 additions & 37 deletions src/sections/Now/NowSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import React from "react";
import Link from "next/link";
import { getProfile } from "@/i18n/profile";
import type { Locale } from "@/i18n/locales";
import {
carBrain as carBrainContent,
type CarBrain,
} from "../../content/carBrain";
import { carBrainFor, type CarBrain } from "../../content/carBrain";
import { caseStudyFor, caseStudyPath } from "../../content/work/studies";
import { CarBrainShots } from "./CarBrainShots";

Expand Down Expand Up @@ -66,7 +63,7 @@ const Building = ({ locale = "en" }: { locale?: Locale }) => {
);
};

const Shipped = ({
const IndependentProduct = ({
carBrain,
locale = "en",
}: {
Expand All @@ -81,62 +78,73 @@ const Shipped = ({
<div className="now-shipped-copy">
<p className="now-meta">
<span className="eyebrow now-eyebrow">{carBrain.eyebrow}</span>
<span className="now-years">{carBrain.years}</span>
<span className="now-years">{carBrain.role}</span>
</p>
<h3 id="car-brain-heading" className="now-title">
{carBrain.title}
</h3>
<p className="now-text">{carBrain.text}</p>
<ul
className="tags"
aria-label={locale === "pl" ? "Technologie" : "Technologies"}
>
{carBrain.tags.map((tag) => (
<li key={tag} className="tag">
{tag}
</li>
))}
</ul>
<dl className="now-details">
<div>
<dt>Stack</dt>
<dd>{carBrain.tags.join(" · ")}</dd>
</div>
<div>
<dt>{locale === "pl" ? "Główne funkcje" : "Key features"}</dt>
<dd>{carBrain.features}</dd>
</div>
<div>
<dt>{locale === "pl" ? "AI i automatyzacja" : "AI & automation"}</dt>
<dd>{carBrain.automation}</dd>
</div>
</dl>
<p className="now-text">{carBrain.architecture}</p>
<p className="now-text">
<strong>Status: </strong>
{carBrain.status}
</p>
<p className="now-actions">
<a
className="store-link"
href={carBrain.appStoreUrl}
target="_blank"
rel="noopener noreferrer"
>
{carBrain.appStoreLabel}
</a>
<a
<Link
className="now-site-link"
href={carBrain.siteUrl}
target="_blank"
rel="noopener noreferrer"
href={`/blog/${locale}/why-i-built-car-brain/`}
>
{carBrain.siteLabel} <span aria-hidden="true">→</span>
</a>
{carBrain.articleLabel} <span aria-hidden="true">→</span>
</Link>
{carBrain.published && carBrain.appStoreUrl && (
<a
className="store-link"
href={carBrain.appStoreUrl}
target="_blank"
rel="noopener noreferrer"
>
{carBrain.appStoreLabel}
</a>
)}
</p>
</div>
<CarBrainShots
label={carBrain.screensLabel}
caption={carBrain.sampleNote}
shots={carBrain.shots}
width={carBrain.shotWidth}
height={carBrain.shotHeight}
/>
</article>
);

// TheEventa, which is still an MVP, and Car Brain, which ships only once its
// publication gate is open. Without Car Brain, TheEventa spans the row.
// Product visibility is separate from App Store availability.
export const NowSection = ({
carBrain = carBrainContent,
carBrain,
locale = "en",
}: {
carBrain?: CarBrain;
locale?: Locale;
}) => (
<div
className={carBrain.published ? "now-grid" : "now-grid now-grid--single"}
>
<div className="now-grid">
<Building locale={locale} />
{carBrain.published && <Shipped carBrain={carBrain} locale={locale} />}
<IndependentProduct
carBrain={carBrain ?? carBrainFor(locale)}
locale={locale}
/>
</div>
);
Loading
Loading