diff --git a/e2e/blog.spec.ts b/e2e/blog.spec.ts index 7b2fd08..26c7dae 100644 --- a/e2e/blog.spec.ts +++ b/e2e/blog.spec.ts @@ -98,22 +98,25 @@ for (const skin of ["standard", "terminal"] as const) [] ); if (path.includes(introSlug)) { - await expect(page.locator(".product-screens-hero img")).toHaveCount( - 1 - ); + await expect(page.locator(".product-screens-hero")).toHaveCount(0); await expect(page.locator(".product-screens-row img")).toHaveCount( - 2 + 3 ); await expect(page.locator(".product-screens-row")).toHaveCSS( "overflow-x", "auto" ); const stack = page.locator(".mobile-stack"); - await expect(stack.getByRole("img")).toBeVisible(); - await expect(stack.getByRole("region")).toHaveCSS( - "overflow-x", - "auto" - ); + const appwrite = stack.getByText("Appwrite", { exact: true }); + await appwrite.scrollIntoViewIfNeeded(); + await expect(appwrite).toBeVisible(); + if (width < 700) { + const box = await appwrite.boundingBox(); + expect(box?.x).toBeGreaterThanOrEqual(-1); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual( + width + 1 + ); + } } if (path.includes(architectureSlug)) { // Diagram geometry must survive missing component CSS. diff --git a/src/components/Blog/Blog.test.tsx b/src/components/Blog/Blog.test.tsx index 584ef23..4df2f87 100644 --- a/src/components/Blog/Blog.test.tsx +++ b/src/components/Blog/Blog.test.tsx @@ -69,18 +69,16 @@ test("mobile stack and sample screens stay in the article", () => { const { container } = render( <> - ); expect( - screen.getByRole("img", { name: /Car Brain mobile stack/ }) + screen.getByRole("figure", { name: /Car Brain mobile stack/ }) ).toBeVisible(); - expect(container.querySelectorAll(".product-screens-hero img")).toHaveLength( - 1 - ); + expect(screen.getByText("Appwrite")).toBeVisible(); + expect(container.querySelector(".product-screens-hero")).toBeNull(); expect(container.querySelectorAll(".product-screens-row img")).toHaveLength( - 2 + 3 ); }); for (const locale of ["en", "pl"] as const) { diff --git a/src/components/Blog/MobileStack.tsx b/src/components/Blog/MobileStack.tsx index e29bd74..d960d6d 100644 --- a/src/components/Blog/MobileStack.tsx +++ b/src/components/Blog/MobileStack.tsx @@ -1,140 +1,56 @@ -import { useId } from "react"; import type { BlogLocale } from "@/content/blog/types"; -const ink = "var(--text, #1c282c)"; -const muted = "var(--text-secondary, #52646a)"; -const accent = "var(--accent, #0f6b75)"; -const surface = "var(--surface, #ffffff)"; -const canvas = "var(--canvas, #f7f5f0)"; -const soft = "var(--accent-soft, #e2efee)"; -const border = "var(--separator, #d4deda)"; - export function MobileStack({ locale = "en" }: { locale?: BlogLocale }) { const pl = locale === "pl"; - const id = useId(); const title = pl ? "Stos mobilny Car Brain" : "Car Brain mobile stack"; - const description = pl - ? "Aplikacja Expo na iOS i Androida rozmawia z Appwrite: logowanie, tabele, pliki i funkcje. W oryginalnej aplikacji nie ma osobnego serwera API." - : "The Expo app on iOS and Android talks to Appwrite for auth, tables, files and functions. There is no separate API server in the original app."; return ( -
-
- - {title} - {description} - - - - - - - - +
+
+
+

{pl ? "Aplikacja Expo" : "Expo app"} - - - iOS {pl ? "i" : "and"} Android - - - - SDK - - - +

iOS {pl ? "i" : "and"} Android

+
+ +
+

Appwrite

+

{pl ? "Logowanie · tabele · pliki · funkcje" : "Auth · tables · files · functions"} - - +

+

{pl ? "Bez mojego serwera API" : "No API server of my own"} - - +

+
-
+
{pl - ? "Telefon rozmawia z Appwrite bezpośrednio. Na małym ekranie przewiń schemat w poziomie." - : "The phone talks to Appwrite directly. On a narrow screen, scroll the diagram horizontally."} + ? "Telefon rozmawia z Appwrite bezpośrednio." + : "The phone talks to Appwrite directly."}
); diff --git a/src/components/Blog/ProductScreens.tsx b/src/components/Blog/ProductScreens.tsx index bbf8fc3..9d8cbf3 100644 --- a/src/components/Blog/ProductScreens.tsx +++ b/src/components/Blog/ProductScreens.tsx @@ -6,23 +6,13 @@ type Shot = { caption: string; }; -const hero = { - en: { - src: "/blog/why-i-built-car-brain/dashboard.jpg", - alt: "Car Brain home screen for the current car", - caption: - "Home: the current car, fuel use, and what is due soon. The numbers and the car are sample data. On this capture the last-service time is clipped by the add button.", - }, - pl: { - src: "/blog/why-i-built-car-brain/dashboard.jpg", - alt: "Ekran główny Car Brain dla aktualnego auta", - caption: - "Ekran główny: aktualne auto, spalanie i to, co wkrótce do zrobienia. Liczby i auto to dane przykładowe. Na tym kadrze godzina ostatniego serwisu jest ucięta przez przycisk dodawania.", - }, -} as const satisfies Record; - -const row = { +const shots = { en: [ + { + src: "/blog/why-i-built-car-brain/dashboard.jpg", + alt: "Car Brain home screen for the current car", + caption: "Home: the current car, fuel use, and what is due soon.", + }, { src: "/blog/why-i-built-car-brain/refuels.jpg", alt: "Refuel records in Car Brain", @@ -36,6 +26,12 @@ const row = { }, ], pl: [ + { + src: "/blog/why-i-built-car-brain/dashboard.jpg", + alt: "Ekran główny Car Brain dla aktualnego auta", + caption: + "Ekran główny: aktualne auto, spalanie i to, co wkrótce do zrobienia.", + }, { src: "/blog/why-i-built-car-brain/refuels.jpg", alt: "Tankowania w Car Brain", @@ -50,47 +46,7 @@ const row = { ], } as const satisfies Record; -function PhoneShot({ - shot, - loading = "lazy", -}: { - shot: Shot; - loading?: "eager" | "lazy"; -}) { - return ( -
-
- {/* A plain img: the site is a static export with no image optimiser. */} - {/* eslint-disable-next-line @next/next/no-img-element */} - {shot.alt} -
-

{shot.caption}

-
- ); -} - -export function ProductScreens({ - locale = "en", - variant = "row", -}: { - locale?: BlogLocale; - variant?: "hero" | "row"; -}) { - if (variant === "hero") { - return ( -
- -
- ); - } +export function ProductScreens({ locale = "en" }: { locale?: BlogLocale }) { const pl = locale === "pl"; return (
@@ -104,8 +60,23 @@ export function ProductScreens({ : "App screens. On a narrow screen, scroll sideways." } > - {row[locale].map((shot) => ( - + {shots[locale].map((shot, index) => ( +
+
+ {/* A plain img: the site is a static export with no image optimiser. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {shot.alt} +
+

{shot.caption}

+
))}
diff --git a/src/components/Blog/blog.css b/src/components/Blog/blog.css index 357bc1c..57c40fc 100644 --- a/src/components/Blog/blog.css +++ b/src/components/Blog/blog.css @@ -114,12 +114,9 @@ margin: 32px 0; min-width: 0; } -.product-screens-hero { - display: flex; - justify-content: center; -} .product-screens-row { display: flex; + justify-content: safe center; gap: 12px; overflow-x: auto; min-width: 0; @@ -143,6 +140,83 @@ font-size: 14px; line-height: 1.45; } +.mobile-stack { + margin: 32px 0; + min-width: 0; +} +.mobile-stack-layout { + display: grid; + grid-template-columns: minmax(160px, 230px) auto minmax(0, 1fr); + align-items: stretch; + gap: 16px; + padding: 28px; + border: 1px solid var(--separator); + border-radius: var(--radius-surface, 16px); + background: var(--surface); +} +.mobile-stack-node { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + margin: 0; + padding: 24px 16px; + border-radius: 12px; + text-align: center; +} +.mobile-stack-node p { + margin: 0; + font-size: 15px; + line-height: 1.4; +} +.mobile-stack-title { + font-size: 18px; + font-weight: 650; +} +.mobile-stack-expo { + background: var(--canvas); + border: 1.5px solid var(--text-secondary); + color: var(--text); +} +.mobile-stack-appwrite { + background: var(--accent-soft); + border: 2px solid var(--accent); + color: var(--accent); +} +/* Terminal light mixes a 10% green tint. Accent text on that tint is just + under the 4.5:1 minimum, so the words use the text color. The border + still marks the box. */ +html.skin-terminal:not(.dark-theme) .mobile-stack-appwrite { + color: var(--text); +} +.mobile-stack-muted { + color: var(--text-secondary); +} +.mobile-stack-arrow { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; +} +.mobile-stack-sdk { + color: var(--text-secondary); + font-size: 13px; + line-height: 1; +} +.mobile-stack-arrow-icon { + display: block; + color: var(--accent); +} +@media (max-width: 700px) { + .mobile-stack-layout { + grid-template-columns: 1fr; + } + .mobile-stack-arrow-icon { + transform: rotate(90deg); + } +} @media (prefers-reduced-motion: reduce) { .blog-list-item { transition: none; diff --git a/src/content/blog/articles/why-i-built-car-brain/en.mdx b/src/content/blog/articles/why-i-built-car-brain/en.mdx index 0365b4c..8d441de 100644 --- a/src/content/blog/articles/why-i-built-car-brain/en.mdx +++ b/src/content/blog/articles/why-i-built-car-brain/en.mdx @@ -5,7 +5,9 @@ import { ProductScreens } from "@/components/Blog/ProductScreens"; Fuel, service, costs and receipts were scattered. A spreadsheet is a poor filing cabinet for photos. I also wanted to learn mobile by shipping a whole product, not by finishing a tutorial. Car Brain is that product: React Native for iOS and Android, built in 2024–2025, which I am bringing back now. - + + +The numbers and the car on these screens are sample data. ## What I chose, and the bill @@ -61,10 +63,6 @@ The mobile source covers several vehicles on one account, photos, VIN auto-fill, The public page is [car-brain.com/en](https://car-brain.com/en). That is the landing page. It is not a store listing. - - -The numbers and the car on these screens are sample data. - Not in this product: subscriptions, a shared family account, push notifications, calendar sync, or OCR on receipts. **Development snapshot — 6 October 2026:** the feature list comes from the repository and its decision records. These screens are existing design captures. I did not re-run the app while writing this. Car Brain is coming back to the stores. This article does not claim the listing is live. diff --git a/src/content/blog/articles/why-i-built-car-brain/pl.mdx b/src/content/blog/articles/why-i-built-car-brain/pl.mdx index 456556f..76a0a51 100644 --- a/src/content/blog/articles/why-i-built-car-brain/pl.mdx +++ b/src/content/blog/articles/why-i-built-car-brain/pl.mdx @@ -5,7 +5,9 @@ import { ProductScreens } from "@/components/Blog/ProductScreens"; Paliwo, serwis, koszty i paragony miałem porozrzucane. Arkusz kalkulacyjny słabo nadaje się do trzymania zdjęć. Chciałem też nauczyć się aplikacji mobilnych na prawdziwym produkcie, a nie na kolejnym tutorialu. Car Brain jest właśnie tą aplikacją: React Native na iOS i Androida. Zbudowałem ją w latach 2024–2025 i teraz do niej wracam. - + + +Liczby i auto na tych ekranach to dane przykładowe. ## Co wybrałem i ile mnie to kosztuje @@ -61,10 +63,6 @@ W aplikacji jest kilka aut na jednym koncie, zdjęcia, uzupełnianie VIN, daty u Publiczna strona po polsku jest na [car-brain.com/pl](https://car-brain.com/pl). To landing page, nie ogłoszenie, że aplikacja jest w sklepie. - - -Liczby i auto na tych ekranach to dane przykładowe. - Nie ma tu subskrypcji, wspólnego konta rodzinnego, powiadomień push, synchronizacji z kalendarzem ani OCR paragonów. **Stan na 6 października 2026:** lista funkcji pochodzi z repozytorium i zapisanych decyzji. Te ekrany to zdjęcia zrobione wcześniej do portfolio. Dane na nich są przykładowe. Przy pisaniu nie uruchamiałem aplikacji. Car Brain wraca do sklepów. Nie piszę, że jest już w sklepie.