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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ Port 3210 rather than 3000, so it never fights another dev server for the port.
A standard Next.js app, Vercel, Netlify and Cloudflare Pages all work with no configuration.

- `/` is the app
- `/landing` is the public page describing it
- `/landing` is the public page describing it, in English, with `/landing/de`,
`/landing/sl` and `/landing/pl` alongside it
- `/about` is the in-app about page

| Branch | URL | Purpose |
Expand Down
5 changes: 4 additions & 1 deletion document/01-product.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ Each of these was considered and rejected. Do not add them without asking.
## Surfaces

- `/` is the app itself
- `/landing` is the public marketing page, rendered outside the app shell
- `/landing` is the public marketing page, rendered outside the app shell, with
`/landing/de`, `/landing/sl` and `/landing/pl` beside it. Routed rather than
translated in the browser, because it renders on the server. See
[05-decisions.md](05-decisions.md).
- `/about` is an in-app tab with the version and changelog
- Android ships the same code inside a Capacitor WebView
- iOS is served by the PWA; there is no native iOS app
66 changes: 41 additions & 25 deletions document/05-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,31 +319,47 @@ the fold. That happened in testing. See [06-traps.md](06-traps.md).
to no file. This is why `ButtonLink` exists: `window.location.href = "/plan"`
silently does nothing inside the APK.

## The public page stays in English

Every screen inside the app is translated. `/landing` is not, and that is a
decision rather than an omission.

The language is chosen in the app and kept in a store in the browser. Every
translated screen is a client component that reads it. `/landing` is a server
component: it exports `metadata`, fetches its own statistics on the server, and
ships no state, which is what lets it render instantly on a phone that has
never opened the app before. A server render happens before any browser has
said which language it wants, so there is nothing for `useLang` to read.

Making it a client component would translate it and cost the thing it is for.
The alternative that actually works is routed locales, `/landing/de` and the
rest, each rendered on the server for a language known from the URL, with
`hreflang` so a search engine indexes all four. That is a piece of work with a
routing decision, a metadata decision and a canonical-URL decision in it. It
belongs on its own, not appended to the end of a run of wiring PRs.

Until then the public page is English, and the language picker in the app is
what a person who wants Slovenian will find on their first screen after it.

The same applies to the `metadata` export in `src/app/layout.tsx`, the page
title and description a search engine and a browser tab show. It is computed on
the server for the same reason and belongs to the same piece of work.
## The public page is routed, the app is not

`/landing` is English and `/landing/de`, `/landing/sl` and `/landing/pl` are the
rest. All four are prerendered, name each other in `hreflang`, and carry a
canonical pointing at themselves; `x-default` points at English.

English keeps the bare path rather than moving to `/landing/en`. Every link to
this page that exists in the world points at `/landing`, and a second URL
holding the same page is a duplicate a search engine has to be told to ignore.

The page reads its strings with `translate(lang, key)` rather than `useLang`,
because it renders on the server before any browser has said which language it
wants. That is the same fact that made this a piece of work rather than a
translation pass, and the reasoning it replaces is below.

`lang` sits on `<main>` rather than on `<html>`. The root layout owns `<html>`
and is shared by the whole app, so varying it would mean two root layouts and a
route group around every other page. A screen reader switches voice at the
element that carries `lang`, which is what the attribute is for.

**The app's own `metadata` in `src/app/layout.tsx` is still English**, and that
is not an oversight. It is the title a browser tab and a search engine show for
`/`, `/plan`, `/stock` and the rest, which are client screens whose language
lives in a store in the browser. Routing those would mean `/sl/plan` and a
locale segment through the entire app, for a title nobody links to. The public
page was worth routing because it is the one page a stranger arrives at.

### What this replaced, and why the wait was right

For four cycles the answer was that `/landing` stays English. The reasoning
then: it is a server component, a server render happens before any browser has
said which language it wants, and making it a client component would translate
it and cost the thing it is for, which is arriving instantly on a phone that has
never opened the app.

That was correct and the conclusion drawn from it was wrong only in timing. The
answer was never "client component"; it was routed locales, and that is a piece
of work with a routing decision, a metadata decision and a canonical-URL
decision in it. It did not belong appended to the end of a run of wiring PRs,
and it is better done as one thing that builds, exports and is checked in the
emitted HTML than as a rushed sixth item.

## A name stays, a description translates

Expand Down
Loading
Loading