From 7c6a748d44daa8c0fd8d654f533907ccc64f12bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wr=C3=B3blewski?= Date: Sun, 13 Sep 2026 00:11:48 +0200 Subject: [PATCH] The interface as it stands, written down for the redesign (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ui-specification.md describes every view in every mode as it stands on main at ae55b6c: each control with its label, action, visibility, states, input and accessibility, the top bars, the design decisions behind them (D-*) and the inconsistencies found (F-*). It is written for the agent that will implement the new interface; its screenshots of dev live on a private page it links, outside the repository. Of the findings, only one is corrected here: SPEC §9 said works are ordered by created_at, while the code has ordered them by position since #66. The document is left out of Prettier: aligning its tables would grow it by nearly 30%, all of it spaces an agent reads as tokens. Co-Authored-By: Claude Opus 5 --- .prettierignore | 4 + SPEC.md | 8 +- docs/ui-specification.md | 5651 ++++++++++++++++++++++++++++++++++++++ tasks/plan.md | 6 + 4 files changed, 5668 insertions(+), 1 deletion(-) create mode 100644 docs/ui-specification.md diff --git a/.prettierignore b/.prettierignore index b5ab8bf..6895aad 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,7 @@ coverage/ playwright-report/ test-results/ next-env.d.ts + +# Read by an agent, not a person (#185): aligning its tables would grow the +# file by nearly 30%, all of it spaces the agent reads as tokens. +docs/ui-specification.md diff --git a/SPEC.md b/SPEC.md index 1ac865a..a72b934 100644 --- a/SPEC.md +++ b/SPEC.md @@ -11,6 +11,11 @@ Source documents with the full rationale behind the decisions (in Polish): - File storage and transfer costs (14 providers): https://claude.ai/code/artifact/931d4f6c-1676-44dc-8076-023883402d74 - Transactional e-mail costs (8 providers): https://claude.ai/code/artifact/70cbb3dd-5d94-42a4-90b1-2bc7f34b637a +The interface as it stands on 12.09.2026, written for the agent that implements the redesign +(#185): [docs/ui-specification.md](docs/ui-specification.md) — every view in every mode, its +controls, the design decisions behind them and the inconsistencies found; its screenshots are +on https://claude.ai/code/artifact/37a17f91-9bd7-4641-8108-448f20987ac3. + Lineage: the MVP corresponds to the `foundation → identity → profiles` modules of the approved capability map in the `platform` repository (CAPABILITY-MAP.md). Deliberately merged here into a single application and a single spec — that is the essence of "lite". @@ -411,7 +416,8 @@ export function ownerKey( - `works` (#72): `id`, `user_id` (cascade — a work is profile content), `name`, `investor`, `developer`, timestamps. At most 10 per user, counted by the application under the per-user advisory lock the quota uses; a `CHECK` cannot count - rows. Order on the page = `created_at`. Since #68 (A13) also `r360_set_id` — the prefix + rows. Order on the page = `position`, which the owner sets by dragging (#66); `created_at` + and `id` only break ties. Since #68 (A13) also `r360_set_id` — the prefix of the frame set — and `r360_params` (`jsonb`: frame count, direction, frames per picture width, start frame, ring flattening, and since #107 the optional cue points — up to 12 `{ frame, label }`, one a frame, a label of 1–40 characters), Zod and `CHECK` pinned to diff --git a/docs/ui-specification.md b/docs/ui-specification.md new file mode 100644 index 0000000..fe7f880 --- /dev/null +++ b/docs/ui-specification.md @@ -0,0 +1,5651 @@ +# UI specification — the interface as it stands + +Tracked in #185. Describes `main` at `ae55b6c` (12.09.2026). + +## 0. Read this first + +- **Reader.** This document is written for the agent that will implement the new UI. The + redesign may change layout, structure and look completely. It must not lose a function, + rule, state, message or accessibility guarantee by accident: every one of them is listed + here under an ID. +- **Snapshot.** Where the code has moved since `ae55b6c`, the code wins. Before relying on a + detail, run `git log --oneline ae55b6c..HEAD -- src/app src/components messages` and + re-read what changed. +- **Rules live in SPEC.md** (A1–A13, §8–§10). This document says where and how the UI + enforces a rule; it does not restate the rule. +- **Three kinds of statement.** + - _Behaviour_ — plain text. Keep it, unless the redesign drops it on purpose. + - _Decision_ `D-AREA-n` — a deliberate design choice with its source. Keep it, or overturn + it knowingly and record why (a SPEC.md change or an issue). + - _Finding_ `F-AREA-n` — an inconsistency or gap in today's UI. The redesign should resolve + it. Nothing listed was fixed, except where the entry says so (`F-WORKS-1`: a sentence + in SPEC.md). +- **IDs.** Views `V-NAME`, shared components `C-NAME`, elements `VIEW.region.element` + (e.g. `PROFILE-EDIT.cover.remove`). Grep for an ID to find its block. +- **Copy.** Every visible string is a dictionary key (A8), quoted as `Namespace.key` — + pl „…” · en "…". Keep using keys; a new string goes into both `messages/pl.json` and + `messages/en.json`. +- **Tests.** Element blocks name the e2e selectors that depend on them. A new UI that changes + a role, an accessible name or a test id updates those tests in the same change. +- **Sources.** A file path and a symbol name, never a line number. +- **Screenshots.** Taken on dev (`https://dev.architektow3d.pl`) on 12.09.2026 with the + profile `dawidwroblewski-a3d-1` (owner views signed in as its owner; e-mail addresses + masked). The profile changed during the day: the owner views show two works, the later + visitor views three. They are kept outside the repository, on a private page: + https://claude.ai/code/artifact/37a17f91-9bd7-4641-8108-448f20987ac3. To look at one, call the Artifact tool with `action: "read_file"`, + `url` = that page and `path: "screens/.jpg"`, then Read the file it saves. Appendix A + lists every ``. A screenshot taller than 2000 px is shrunk when read and its text blurs: + read its parts instead, `screens/--part-.jpg` (1600 px each, overlapping), counted in + Appendix A and listed in `screens/manifest.json`. They show the look being replaced — use them to understand today's + arrangement, not as the target. To see a state no screenshot shows, run the app (SPEC.md + §3) and look. + +## 1. Modes + +What a view shows depends on these dimensions. The **Shown** line of every element block uses +exactly these words. + +| Dimension | Values | Where the code decides | +| ------------ | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Viewer | `signed-out` · `no-handle` (signed in, no handle yet) · `owner` (own profile) · `other` (someone else's profile) · `signed-in` (any) | On the server, per request. `(app)/layout.tsx` (session gate), `isOwnerViewing` in `(public)/[handle]/page.tsx`, `signedInDestination` in `src/lib/signed-in-destination.ts`. Every read fails closed: a session that cannot be verified counts as signed out. | +| Profile mode | `view` · `edit` | Client state `editing` in `OwnerProfileView` (`owner-profile-view.tsx`). Not in the URL: a reload returns to `view`. | +| Screen | `desktop` (≥ 40rem) · `phone` (< 40rem) · `phone:` | Tailwind's `sm` breakpoint (40rem). `phone:` is the custom variant in `src/app/globals.css`: `(width < 40rem), (pointer: coarse) and (height < 32rem)` — a narrow screen, or a touch screen held sideways. Other breakpoints are named where used (`md` = 48rem). | +| Locale | `pl` · `en` | next-intl middleware in `src/proxy.ts` with `localePrefix: "as-needed"` (`src/i18n/routing.ts`): the path prefix first, then the `NEXT_LOCALE` cookie, then `Accept-Language`. So an unprefixed address is Polish only for a visitor whose cookie and browser do not say English — after one visit to `/en`, `/login` redirects to `/en/login` (observed on dev). | +| Motion | `reduced-motion` | `prefers-reduced-motion: reduce`. | +| Input | `mouse` · `touch` · `keyboard` · `screen reader` | — | + +### Index of views and components + +| ID | What | Section | +| --- | --- | --- | +| `C-TOPBAR` | top bar | §3 | +| `C-ACCOUNT-MENU` | account menu | §3 | +| `C-MOBILE-MENU` | hamburger panel | §3 | +| `C-LANGUAGE-CHIP` | language switcher | §3 | +| `C-DISMISSABLE` | popover dismissal | §3 | +| `C-LOGO` | brand mark and wordmark | §3 | +| `C-FOOTER` | page footer | §3 | +| `C-PLAQUE` | street-sign graphic | §3 | +| `V-HOME` | homepage | §4 | +| `V-404` | not found | §4 | +| `C-AUTH-SHELL` | the frame of the seven auth screens | §5 | +| `V-LOGIN` | log in | §5 | +| `V-REGISTER` | create an account | §5 | +| `V-REGISTER-VERIFIED` | verification link landing | §5 | +| `V-RESET-REQUEST` | ask for a password-reset link | §5 | +| `V-RESET-NEW` | set a new password from the link | §5 | +| `V-TWO-FACTOR` | second-factor challenge after the password | §5 | +| `V-EMAIL-CHANGED` | e-mail-change link landing | §5 | +| `V-ONBOARDING` | name, then profile address | §6 | +| `C-HANDLE-FORM` | profile address picker | §6 | +| `V-SETTINGS-ACCOUNT` | account settings | §6 | +| `V-SETTINGS-PROFILE-REDIRECT` | former profile settings | §6 | +| `V-PROFILE` | public profile (visitor) | §7 | +| `V-PROFILE-OWNER` | the owner's profile, not editing | §7 | +| `V-PROFILE-EDIT` | the owner editing in place | §7 | +| `C-LEAVE-GUARD` | asking before the owner leaves mid-edit | §7 | +| `V-WORKS-LIST` | works gallery and the work card | §8 | +| `V-LIGHTBOX` | enlarged picture overlay | §8 | +| `C-ORBIT` | the R360 viewer (viewer, ring, cue buttons) | §8 | +| `C-REVEAL` | two-channel reveal slider | §8 | +| `V-WORK-FORM` | the work form (a new work / an existing work) | §8 | +| `C-R360-PARAMS` | orbit preview, parameters and cue points in the work form | §8 | +| `C-UPLOAD-PROGRESS` | upload progress bar with cancel | §8 | + +## 2. Site map + +- **Locales (A8):** `pl` (default, unprefixed) and `en` (prefix `/en`) — `src/i18n/routing.ts` `routing` (`localePrefix: "as-needed"`). Every page lives under `src/app/[locale]`; `[locale]/layout.tsx` `LocaleLayout` is the root layout (``, `NextIntlClientProvider`, `globals.css`). There is no `src/app/layout.tsx`, no `error.tsx`, `global-error.tsx`, `loading.tsx`, catch-all route or `global-not-found`. +- **Navigation helpers:** `src/i18n/navigation.ts` exports next-intl `Link`, `redirect`, `usePathname`, `useRouter`, `getPathname`. Every in-app href is written unprefixed (`/login`) and receives the current locale's form (`/en/login`). +- **Request config:** `src/i18n/request.ts` — locale from the `[locale]` segment (or an explicit one); unsupported value → `notFound()`; loads `messages/.json`; `timeZone: "Europe/Warsaw"`. +- **Requests the middleware never sees** (`/api`, Better Auth callbacks, e-mail language): `src/i18n/request-locale.ts` `localeFromRequest` — `NEXT_LOCALE` cookie → `Accept-Language` (base language, highest q, q=0 ignored) → `pl`. +- **Default ``:** `LocaleLayout` `generateMetadata` — title `Metadata.title` pl „Architektów 3d” · en "Architektów 3d"; description `Metadata.description` pl „Publiczne profile dla studiów architektury i artystów 3D.” · en "Public profiles for architecture studios and 3D artists."; `metadataBase` = `APP_URL` when parsable. No title template: a page's own title replaces the default. + +#### Pages + +| Page | URL pl · en | Source | Reached by | Everyone else gets | `` | +| --- | --- | --- | --- | --- | --- | +| V-HOME | `/` · `/en` | `(public)/page.tsx` `HomePage` | signed-out (incl. a session that cannot be verified) | signed-in with handle → 307 `/{handle}`; no-handle → 307 `/onboarding` | default | +| V-PROFILE (visitor view) | `/{handle}` · `/en/{handle}` | `(public)/[handle]/page.tsx` `PublicProfilePage` | signed-out, no-handle, other | pipeline step 8 | `{displayName} · Architektów 3d` | +| V-PROFILE-OWNER, V-PROFILE-EDIT (owner) | same | `(public)/[handle]/owner-profile-view.tsx` `OwnerProfileView` | owner | — | same | +| Login | `/login` · `/en/login` | `(auth)/login/page.tsx` | anyone (no session check) | — | `Login.title` „Logowanie” · "Log in" | +| Register | `/register` · `/en/register` | `(auth)/register/page.tsx` | anyone | — | `Register.title` „Rejestracja” · "Sign up" | +| Verification landing | `/register/verified` · `/en/register/verified` | `(auth)/register/verified/page.tsx` | anyone; Better Auth lands the A1 link here (`?error=` on a rejected token) | — | default (no `generateMetadata`) | +| Reset request | `/reset-password` · `/en/reset-password` | `(auth)/reset-password/page.tsx` | anyone | — | `ResetPassword.title` „Reset hasła” · "Password reset" | +| New password | `/reset-password/new` · `/en/reset-password/new` | `(auth)/reset-password/new/page.tsx` | anyone; the A3 link lands with `?token=` (without it: invalid-link state) | — | `ResetPassword.title` | +| Two-factor challenge | `/two-factor` · `/en/two-factor` | `(auth)/two-factor/page.tsx` | anyone; login pushes here with `?methods=` | — | `TwoFactor.title` „Weryfikacja dwuskładnikowa” · "Two-factor verification" | +| E-mail change landing | `/email-changed` · `/en/email-changed` | `(auth)/email-changed/page.tsx` | anyone; both A10 links land here (`?status=done`, `?error=`) | — | `EmailChanged.title` „Zmiana adresu” · "Address change" | +| Onboarding | `/onboarding` · `/en/onboarding` | `(app)/onboarding/page.tsx` | no-handle | signed-out → 307 `/login`; with handle → 307 `/{handle}` | `Onboarding.title` „Adres profilu” · "Profile address" | +| Account settings | `/settings/account` · `/en/settings/account` | `(app)/settings/account/page.tsx` | signed-in (with or without handle) | signed-out → 307 `/login` | `Settings.account.title` „Ustawienia konta” · "Account settings" | +| Former profile settings | `/settings/profile` · `/en/settings/profile` | `(app)/settings/profile/page.tsx` | nobody (redirect only) | signed-out → 307 `/login`; signed-in → 307 `/settings/account` | — | +| V-404 | the requested URL of any `[locale]` page that calls `notFound()` | `[locale]/not-found.tsx` | anyone | status 404 | `NotFound.title` „Nie znaleziono strony” · "Page not found" | + +Every redirect answers in the request's locale form (`/en/...` for English). "The seven auth screens" = the rows Login … E-mail change landing. + +#### Request pipeline (redirects and statuses, in order) + +1. **Proxy matcher** — `src/proxy.ts` `config.matcher`: runs for every path except `/api`, `/_next`, `/_vercel`, `/icon`, `/apple-icon`, `/opengraph-image` and `/xx/opengraph-image` (each as a whole segment) and any path containing a dot. Excluded paths get no locale handling and no noindex header. A handle such as `apiary` still passes. +2. **Old handle → 301** (A6, §9) — `proxy` → `oldAddressRedirect`. Only a path of exactly one segment after an optional `pl`/`en` prefix that passes `checkHandle` (A5 pattern, not reserved) is looked up (`handleCandidate`, `resolveHandle`). A redirect row whose target still has a handle → `301` to `/{current}` (for `/{old}` and `/pl/{old}`) or `/en/{current}` (for `/en/{old}`), query string kept, `Cache-Control: no-store`. Lookup bound 1.5 s (`LOOKUP_TIMEOUT_MS`); timeout, error or missing `DATABASE_URL` → falls through to step 3 (logged, except the missing variable). The 301 bypasses the locale middleware (no cookie write, no `Link` header). A path with capitals never passes `checkHandle` (lowercase pattern), so its old address is answered by step 8. +3. **Locale middleware** — next-intl `createMiddleware(routing)` (`node_modules/next-intl` `middleware.js`, `resolveLocale.js`, `syncCookie.js`, `routing/config.js`). Locale = path prefix → `NEXT_LOCALE` cookie → `Accept-Language` best match → `pl`. + - `/pl` or `/pl/...` → `307` to the unprefixed path, query kept. + - unprefixed path resolved to `en` → `307` to `/en/...`, query kept. + - unprefixed path resolved to `pl` → internal rewrite; URL unchanged. + - document requests only: sets `NEXT_LOCALE=<resolved>` when the cookie differs, or when absent and the resolved locale differs from the `Accept-Language` match. Attributes `SameSite=Lax`, no `Max-Age` (session cookie). + - non-redirect responses carry a `Link` header with `hreflang` alternates. +4. **noindex** (A7, §8) — `proxy`: unless `APP_ENV` (trimmed) is exactly `production` (`isProduction`), every response of steps 2–3 gets `X-Robots-Tag: noindex`. `/api` and matcher-excluded paths never get it. +5. **Locale layout 404** — `LocaleLayout`: a `[locale]` value other than `pl`/`en` → `notFound()`. Reachable only by paths the middleware skipped (dotted paths such as `/robots.txt`). Observed on dev (12.09.2026): `/robots.txt` answers 404 with an empty body — no page renders. +6. **Session gate** — `(app)/layout.tsx` `AppLayout`: `getAuth().api.getSession`; `null` or a thrown error → `307` `/login` (fail closed). Covers `/onboarding`, `/settings/account`, `/settings/profile`; the two real pages repeat the check. +7. **Page redirects** (`307`, Next `redirect` via next-intl): + - `/settings/profile` → `/settings/account` (`ProfileSettingsRedirect`, #58). + - `/onboarding` for a user with a handle → `/{handle}` (`OnboardingPage`). + - `/` for a signed-in viewer → `signedInDestination()` (`src/lib/signed-in-destination.ts`): handle → `/{handle}`, none → `/onboarding`; the session read and the handle read share one try/catch — any failure = signed out. +8. **Profile address** — `PublicProfilePage` + `loadPublicProfile` (`src/lib/public-profile.ts`) on the trimmed, lowercased input; `resolveHandle` order profile → redirect → not found: + - invalid shape or reserved word → 404 (no query). + - profile row and input already canonical → render: owner view when the session's user id equals the profile's (`isOwnerViewing`; any session error = not owner), else visitor view. + - profile row, input differs (capitals, surrounding whitespace) → `308` (`permanentRedirect`) to the lowercase address, query kept. + - redirect row (proxy failed open or timed out, or the path had capitals) → `308` to the current handle, query kept. + - redirect row whose target has no handle, no row, profile deleted between reads, or no `DATABASE_URL` → 404 body. + - any other database or storage error → thrown (500; no error page — F-SHELL-3). + - `force-dynamic`; `generateMetadata` and the render share one lookup (`cache`). +9. **404 body** — `[locale]/not-found.tsx` renders for `notFound()` thrown by a page under `[locale]`, HTTP 404 (e2e `profile.spec.ts`). An address matching no route at all never reaches it: observed on dev (12.09.2026), `/foo/bar` and `/en/settings/x` answer 404 with Next's default, unlocalized page (`<title>404: This page could not be found.`) — F-SHELL-3. + +Example chain: signed-out visitor with `NEXT_LOCALE=en` opens `/settings/account` → 307 `/en/settings/account` → 307 `/en/login`. + +#### Non-page touchpoints + +- `GET /api/og/{handle}` — `src/app/api/og/[handle]/route.tsx` `GET`: 512×512 PNG of the profile's initials (`initialsFrom`) in the stone monogram colours; og:image of a profile without an avatar (`monogramImagePath`, used by `profileMetadata`); `Cache-Control: public, max-age=86400, s-maxage=86400`; anything but a live profile (unknown, reserved, old handle) → 404 plain text "Not found"; no `DATABASE_URL` → 404 "Not configured". +- `/icon` — `src/app/icon.tsx` `Icon`: 32×32 PNG browser-tab icon, the A3D mark. +- `/apple-icon` — `src/app/apple-icon.tsx` `AppleIcon`: 180×180 PNG home-screen icon, the same mark. +- `src/app/brand-mark.tsx` — `BrandMark` (A3D mark for `next/og`: navy `#0f4eb1`, red band 12 % of the height, "A3D" in Figtree Bold at 34 %, corner radius 4/30 of the side) and `brandFont` (reads `public/figtree-bold.ttf` once per process); used by the two icons only. +- `src/app/[locale]/opengraph-image.jpg` + `opengraph-image.alt.txt` — static share picture of every `[locale]` page that declares no image of its own (homepage, auth screens, settings, onboarding, 404); alt „ul. Architektów 3d, Stara Ochota” for both locales. +- `public/hero-facade-plaque.webp` — the photo of V-HOME and V-404. + +#### Decisions + +- `D-SHELL-1` — Polish unprefixed, English under `/en`; locale from the path, then the `NEXT_LOCALE` cookie, then `Accept-Language`; one time zone `Europe/Warsaw`. Source: A8; `routing.ts` comment; `proxy.ts` header comment ("pathname prefix, then the NEXT_LOCALE cookie, then the Accept-Language header"); §8, `request.ts` comment. +- `D-SHELL-2` — An old handle answers 301 from the proxy with `Cache-Control: no-store`, query kept, failing open within 1.5 s; the page adds 308s for case variants and for old addresses the proxy missed. Source: A6, §9; `proxy.ts` comments ("a page can only emit 308", "no-store keeps every client re-asking", "Fail-open must also be fail-fast"); `[handle]/page.tsx` comments ("a shared link carries its campaign parameters"). +- `D-SHELL-3` — `X-Robots-Tag: noindex` on every proxied response outside production, set in one place. Source: A7, §8; `proxy.ts` comment ("so no route can forget it"). +- `D-SHELL-4` — Session reads fail closed: unverifiable = signed out on `/` and the 404, `(app)` pages go to `/login`, a profile shows its visitor view; `/settings/profile` stays inside `(app)` as a redirect so a signed-out visitor goes straight to login. Source: `(app)/layout.tsx` comment; `signed-in-destination.ts` comment; `isOwnerViewing` comment; `settings/profile/page.tsx` comment (#58). +- `D-SHELL-5` — The homepage is unreachable once signed in: `/` redirects to the viewer's own profile or `/onboarding`. The 404 page still renders for a signed-in viewer, but its home link points to that same destination. Source: `signed-in-destination.ts` comment ("the design routes a signed-in visitor to their own public profile instead"); `not-found.tsx` comment; #58 (`e2e/db/happy-path.spec.ts` comment). + +#### Findings + +- `F-SHELL-1` — The seven auth screens render the same for signed-in viewers; nothing sends a signed-in person away from `/login` or `/register`, while `/` and the 404 link do route them (D-SHELL-5). Evidence: no session read in any `(auth)` page or form; the only navigation is `router.push` after success (`login-form.tsx`, `two-factor-challenge.tsx`). May be deliberate (switching accounts); no reason recorded. +- `F-SHELL-2` — Page titles follow no single pattern: `/register/verified` has no `generateMetadata`, so its tab shows only „Architektów 3d” (same as the homepage), the other auth screens name themselves („Logowanie”) without the brand, profile pages use „{name} · Architektów 3d”. Evidence: `register/verified/page.tsx`; `LocaleLayout` (title, no template); `profileMetadata`. +- `F-SHELL-3` — No screen for failure or for unmatched multi-segment addresses: no `error.tsx`/`global-error.tsx` although the profile page deliberately throws database/storage errors ("Everything else must surface as a 500", `[handle]/page.tsx` `lookup`); no catch-all route or `global-not-found`, so `/foo/bar` cannot reach `[locale]/not-found.tsx` (Next docs `not-found.md`: only a root `app/not-found.js` or `app/global-not-found.js` handles unmatched URLs, and `global-not-found` is the documented option when the root layout sits under a top-level dynamic segment). Unmatched multi-segment addresses get Next's default, unlocalized 404 page with no way back (§1 "never lost"). Observed on dev, §2 step 9. UNVERIFIED: the screen shown when the profile page throws. + +## 3. Shared elements + +### C-TOPBAR — top bar + +- **Screenshots:** `v-home--desktop`, `v-home--phone`, `v-profile--desktop`, `v-profile--phone`, `v-profile--other-signed-in--desktop`, `v-profile-owner--desktop`, `v-profile-owner--phone`, `v-profile-edit--desktop`, `v-settings-account--desktop` (Appendix A) +- **Source:** `src/components/ui/top-bar.tsx` — `TopBar`; width class from `layout.ts` `measureWidthClass`. +- **Props:** `left` (node); `right?` (node); `mobileMenu?` (node shown instead of `right` below `sm`); `onPhoto?` (default `false`); `maxWidth?` `"measure-wide"` (default, 78rem) · `"measure-page"` (68rem) — must equal the page's `Footer` value. +- **Frame:** plain `
` (no `
`, no landmark, no skip link); `position: sticky; top: 0; z-index: 20`. Card surface with a hairline bottom border; `onPhoto` → transparent, no border. Inner row centred at the measure: height 56 px on phone / 64 px from `sm`; side gutters 16 px / 24 px; space-between, 12 px gap; left box `min-w-0` with 20 px gap; right box 12 px gap on phone / 20 px from `sm`. With `mobileMenu`: right box `hidden sm:flex`, menu wrapper `sm:hidden` (both copies are in the DOM, one displayed). +- **Coupling:** the homepage hero text uses the same gutter expressions so it starts at the logo's x (`top-bar.tsx` and `(public)/page.tsx` comments). + +#### Variants actually rendered + +| Page | Viewer | Screen | Left slot | Right slot | Collapses into MobileMenu? | +| --- | --- | --- | --- | --- | --- | +| Homepage `/` (`onPhoto`, `measure-wide`) | signed-out | desktop | `Logo` default size, `onPhoto` → `/` | LanguageChip `onPhoto` · „Zaloguj się” `ButtonLink` `onPhotoQuiet` → `/login` · „Załóż konto” `ButtonLink` `onPhoto` → `/register` | — | +| Homepage `/` | signed-out | phone | same | hamburger (`onPhoto` tone); panel: LanguageChip card tone · „Zaloguj się” `quiet` · „Załóż konto” `solid` | yes | +| Profile `/{handle}` (`measure-page`) | signed-out, no-handle, other — identical | desktop | `Logo` → `/` | LanguageChip · „Załóż konto” `ButtonLink` `quiet` → `/register` | — | +| Profile `/{handle}` | signed-out, no-handle, other | phone | same | hamburger; panel: LanguageChip · „Załóż konto” `quiet` | yes | +| Profile `/{handle}` (`measure-page`) | owner, view and edit | desktop and phone | `LogoMark` → `/{handle}` (own profile) | edit toggle (`Button` `quiet`) · AccountMenu | no — row at every width | +| Settings `/settings/account` (`measure-page`) | signed-in with handle | desktop and phone | `Logo` → `/{handle}` | AccountMenu („Profil” → `/{handle}`) | no | +| Settings `/settings/account` | no-handle | desktop and phone | `Logo` → `/` (→ 307 `/onboarding`) | AccountMenu („Profil” → `/` → 307 `/onboarding`) | no | +| Seven auth screens | anyone | both | no bar — compact, non-link Logo above the card (C-LOGO) | — | — | +| Onboarding | no-handle | both | no bar, no logo | — | — | +| 404 | anyone | both | no bar, no logo | — | — | + +Confirmed from code: exactly two bars collapse (homepage, visitor profile); the LanguageChip appears only in those two bars; signed-in viewers of someone else's profile get the signed-out bar. + +#### Elements + +##### `TOPBAR.home.logo` — link + +- **Label:** `Brand.wordmark` — pl „Architektów 3d” · en "Architektów 3d" (A3D mark beside it is `aria-hidden`) +- **Where:** left slot +- **Shown:** V-HOME, signed-out, desktop and phone +- **Does:** navigates to `/` (en `/en`) — the page itself +- **A11y:** link named by the wordmark; inverse focus ring +- **Tests:** none +- **Source:** `(public)/page.tsx` — `HomePage` (``); C-LOGO + +##### `TOPBAR.home.language` — button + options panel + +- **Label:** C-LANGUAGE-CHIP +- **Where:** first item of the action row (desktop) / of the hamburger panel (phone) +- **Shown:** V-HOME, signed-out; desktop `onPhoto` tone; phone inside `TOPBAR.home.menu`, card tone +- **Does:** C-LANGUAGE-CHIP (`/` ↔ `/en`) +- **Tests:** `e2e/i18n.spec.ts` — `getByRole("button", { name: "Polski" })`, `getByRole("link", { name: "English" })` +- **Source:** `(public)/page.tsx` — `sessionActions` + +##### `TOPBAR.home.log-in` — link + +- **Label:** `Session.logIn` — pl „Zaloguj się” · en "Log in" +- **Where:** second item of the action row / panel +- **Shown:** V-HOME, signed-out; desktop `ButtonLink` `onPhotoQuiet` md; phone (panel) `quiet` +- **Does:** navigates to `/login` (en `/en/login`); inside the panel it also closes the panel +- **A11y:** link, name = label +- **Tests:** `e2e/login.spec.ts` "the homepage degrades…" and `e2e/db/happy-path.spec.ts` last step — `getByRole("link", { name: "Zaloguj się" })` +- **Source:** `(public)/page.tsx` — `sessionActions` + +##### `TOPBAR.home.sign-up` — link + +- **Label:** `Session.register` — pl „Załóż konto” · en "Sign up" +- **Where:** last item of the action row / panel +- **Shown:** V-HOME, signed-out; desktop `onPhoto` (white fill); phone (panel) `solid` +- **Does:** navigates to `/register` (en `/en/register`) +- **A11y:** link +- **Tests:** `e2e/db/happy-path.spec.ts` step "A11" — `getByRole("link", { name: "Załóż konto" })` click → URL `/register`; `e2e/login.spec.ts` +- **Source:** `(public)/page.tsx` — `sessionActions` + +##### `TOPBAR.home.menu` — button (disclosure) + +- **Label:** `MobileMenu.menuLabel` — pl „Menu główne” · en "Main menu" +- **Where:** right end, instead of the action row +- **Shown:** V-HOME, signed-out, phone only; `onPhoto` trigger tone +- **Does:** C-MOBILE-MENU; panel holds `TOPBAR.home.language`, `TOPBAR.home.log-in`, `TOPBAR.home.sign-up` in on-card variants +- **Tests:** none +- **Source:** `(public)/page.tsx` — `{sessionActions(false)}` + +##### `TOPBAR.visitor.logo` — link + +- **Label:** `Brand.wordmark` — pl „Architektów 3d” · en "Architektów 3d" +- **Where:** left slot +- **Shown:** public profile; signed-out, no-handle, other; desktop and phone +- **Does:** navigates to `/` (en `/en`): the hero for signed-out; for a signed-in viewer `/` answers 307 to their own profile or `/onboarding` +- **A11y:** link; standard focus ring +- **Tests:** none +- **Source:** `(public)/[handle]/page.tsx` — `PublicProfilePage` (``) + +##### `TOPBAR.visitor.language` — button + options panel + +- **Label:** C-LANGUAGE-CHIP +- **Where:** first item of the row / of the panel +- **Shown:** signed-out, no-handle, other; desktop row and phone panel; card tone in both +- **Does:** C-LANGUAGE-CHIP — the same profile in the other locale (query dropped) +- **Tests:** none (the i18n spec covers the homepage only) +- **Source:** `(public)/[handle]/page.tsx` — `actions` + +##### `TOPBAR.visitor.sign-up` — link + +- **Label:** `Session.register` — pl „Załóż konto” · en "Sign up" +- **Where:** after the language chip +- **Shown:** signed-out, no-handle, other (a signed-in viewer sees it too — F-SHELL-5); `ButtonLink` `quiet` in both copies +- **Does:** navigates to `/register` (en `/en/register`) +- **A11y:** link +- **Tests:** none +- **Source:** `(public)/[handle]/page.tsx` — `actions` + +##### `TOPBAR.visitor.menu` — button (disclosure) + +- **Label:** `MobileMenu.menuLabel` — pl „Menu główne” · en "Main menu" +- **Where:** right end +- **Shown:** phone only; card-tone trigger +- **Does:** C-MOBILE-MENU; panel holds `TOPBAR.visitor.language`, `TOPBAR.visitor.sign-up` +- **Tests:** none +- **Source:** `(public)/[handle]/page.tsx` — `{actions}` + +##### `TOPBAR.owner.logo` — link + +- **Label:** `Brand.wordmark` — pl „Architektów 3d” · en "Architektów 3d" (read client-side, `useTranslations("Brand")`) +- **Where:** left slot +- **Shown:** owner, view and edit, desktop and phone +- **Does:** navigates to `/{handle}` (en `/en/{handle}`) — the page the owner is on. In edit mode the leave guard does not ask (same pathname and query — `use-leave-guard.ts` click handler returns early). UNVERIFIED: whether this same-URL navigation keeps edit mode, scroll position and history unchanged. +- **A11y:** link; standard focus ring +- **Tests:** none +- **Source:** `owner-profile-view.tsx` — `OwnerProfileView` (``) + +##### `TOPBAR.owner.edit-toggle` — button + +- **Label:** idle `PublicProfile.editProfile` — pl „Edytuj profil” · en "Edit profile" (pencil icon); editing `PublicProfile.saveProfile` — pl „Zapisz” · en "Save" (check icon); busy `PublicProfile.savingProfile` — pl „Zapisywanie…” · en "Saving…" (check icon) +- **Where:** right slot, before the account menu +- **Shown:** owner, view and edit, desktop and phone +- **Enabled:** disabled while leaving edit mode is in progress (`leaving`) +- **Does:** view → edit: `editing = true`, hides the saved notice, clears the name, avatar, cover, headline, bio, places and works-order errors, arms the leave guard (V-PROFILE-EDIT). Edit → view: runs the exit sequence specified in V-PROFILE-EDIT; success → `editing = false`, status line `PublicProfile.savedProfile` pl „Zapisano profil” · en "Profile saved" in the card (2.5 s), `router.refresh()`; a failed save or a work form that must stay open → editing stays on, label back to „Zapisz”. +- **States:** idle / editing / busy as in Label; failures are reported by the fields, not the button +- **Input:** mouse, touch, keyboard Enter/Space (native button) +- **A11y:** `