DECISIONS.md
Rama main · réplica de lectura del repositorio original.
# Frontend Decisions & Gotchas
Non-obvious choices in the web app and the pitfalls behind them, one short section each. Grep for
the symbol you are touching. The bar for a new entry is in `AGENTS.md`.
## Dive/trip/dive-site API calls take `uuid` strings, not `username`/numeric ids
`divesAPI`/`tripsAPI`/`diveSitesAPI` in `lib/api/*.ts` identify resources by string `uuid`, never by
`username` or a numeric `id`, matching the API's flat routes (`/dive`, `/dives`, `/dive/{id}`).
- No request names its owner. A create call (`createDive`/`createTrip`/`createDiveSite`) takes the
full request object as a single argument and puts no `user_uuid` in it; a list call
(`getDives`/`getTrips`/`getDiveSites`) takes its page, size and filters and sends no `user_uuid`
query param. The bearer token scopes both, so there is no owner id to pass — and none to thread
down a prop chain to reach a request with.
- Single-resource calls (`getDive`/`updateDive`/`deleteDive` and the trip/dive-site equivalents)
take only the resource `uuid`; the backend authorizes by comparing the fetched object's owner to
the caller.
`authAPI.updateProfile` and `diveStatsAPI.getDiveStats` are the same rule on the current-user routes
— see "Current-user endpoints live on a bare `/user`, not `/user/me`/`/user/{uuid}`" below.
## Never use `z.preprocess()`/`.transform()` on fields feeding `z.input<>`-derived types
Form pages derive their form-data type from Zod via `z.input<typeof schema>` (`DiveCreateInput`,
`DiveUpdateInput`). `.preprocess()`/`.transform()` collapse the input type to `unknown` or to the
output type, which breaks `useForm<T>()`'s binding to `onSubmit` with "two different types with this
name exist, but they are unrelated" errors — and only during a full `next build`, not under
`diagnostics`/editor type-checking.
Keep each schema's input and output types identical (`z.union([z.literal(""), z.number()])`, no
`.transform()`) and do real conversion (`"" -> undefined`) in a plain TS helper called right before
the API call (`normalizeMixtures`, `normalizeTripParts` in `lib/validations/*.ts`). Always run
`npm run build` after touching a form-bound Zod schema; `diagnostics` alone misses this class of
bug.
## The "cleared field resets to default" React Hook Form quirk
Setting a live RHF field value to `undefined` mid-edit makes RHF display that field's default value;
it cannot distinguish "explicitly cleared" from "never set". This affects every optional
numeric/date field with a non-empty-string default.
Use the empty string `""` as the live "cleared" sentinel, never `undefined`, and convert
`"" -> undefined` only in a normalize helper called right before the API call.
`diveMixtureSchema.start_pressure/end_pressure` (numeric) and a trip part's `start_date`/`end_date`
in `tripFormSchema` (date-string) are the reference call sites.
## Explicit field construction beats spread-then-override for generics
`normalizeMixtures()` lists every output field explicitly rather than
`{ ...mixture, start_pressure: ... }` over a generic `mixture` parameter. With a generic source type
TypeScript does not reliably narrow the overridden property away from the original wider union, so
the `""` placeholder type leaks into the inferred return type and breaks assignability to the API's
`DiveMixture[]`. No spread and no generics is the default for any "normalize before submit" helper.
## Bare `YYYY-MM-DD` dates must not go through `new Date(dateString)`
`new Date("2024-06-01")` parses as UTC midnight, which a negative-UTC-offset timezone (most of the
Americas) displays as the previous day. Every date-only field (a trip part's
`start_date`/`end_date`, a course's) is formatted via `formatDateOnly()`/`formatTripDateRange()` in
`lib/date-time.ts`, which split the string and construct a local `Date(year, month-1, day)`.
`Dive.start_time` is usually a full ISO datetime and has no off-by-one-day problem, but displaying
and editing it still cannot go through `new Date(dateString)` and local getters — see the next
section. A bare date there is its own state — see "A bare-date `start_time` is a fourth state, and
never midnight".
## A dive's `start_time` displays/edits in its own timezone, never the browser's
`Dive.start_time` is an ISO 8601 string whose offset, if any, is the dive's own timezone, not the
viewer's (an imported dive may carry none — see "An unknown UTC offset is a third state, and
`new Date()` never sees an offset-less string"). `new Date(start_time)` with
`.getHours()`/`toLocaleString()` converts to the browser's zone. `lib/date-time.ts` uses the
embedded offset:
- `parseUtcOffsetMinutes()` extracts it, or `null` for a naive string.
- `formatDiveDateTime()`/`formatDiveTimeOnly()` shift the instant by that offset and format with
`timeZone: "UTC"`; a `null` offset parses the string as UTC.
- `splitStartTime()`/`combineStartTime()` convert between the string and a `"YYYY-MM-DD HH:mm:ss"`
string plus offset minutes or `null`.
`formatDateTime()` shows browser-local time: for `created_at`, never `start_time`. The form has one
`start_time` field; `DiveStartTimeField` (`components/dives/dive-start-time-field.tsx`) alone calls
`splitStartTime()`/`combineStartTime()`, around `DateTimePicker` + `UtcOffsetSelect`. New dives use
`nowStartTime()` (`getBrowserUtcOffsetMinutes()`); `normalizeParsedStartTime()` keeps an imported
file's offset and falls back to the browser's for naive ones.
## Date fields are typed into, and settle only when the field is left
Where a pointer hovers, every date field is a text box with a calendar button in it
(`ui/date-picker.tsx`, `ui/date-time-picker.tsx`); `lib/date-input.ts` reads it. Separators are
loose (`2024/6/1`, `20240601`), the order always year-first: `01/06/2024` is two different days
depending on who typed it. `parseDateTimeInput()` refuses a trailing `Z` or `+02:00`, since the
offset belongs to `UtcOffsetSelect` and dropping a pasted one moves the dive in silence.
The box holds a draft and commits on blur or Enter, never per keystroke, because a half-typed date
passes through other real ones. Unparseable text is discarded; a date typed without a time takes the
time the popover holds, as a picked one does.
Focus opens the calendar, which follows the draft as typed; `onClick` reopens it, since `focus` does
not fire on a focused input. Only the icon button hands the grid focus, or the first keystroke lands
on a day cell.
## Touch devices get the OS date pickers, chosen by pointer
`DatePicker` and `DateTimePicker` render native `<input type="date">`/`"time"` where
`useCoarsePointer` - `(hover: none) and (pointer: coarse)` - says a finger is driving, and the text
box and calendar elsewhere. Pointer, not viewport: a tablet at desktop width is still a finger. Its
server snapshot is `false`, so the desktop branch renders server-side and a phone switches after
hydration; rendering both would put two controls under one `FormControl` id. `DatePicker` keeps its
clear button: the iOS picker has none.
The date-time field is two inputs, not `datetime-local`, which Android chains two dialogs for and
iOS strips the seconds from when only the date changes. A date change rewrites the date alone, so an
imported dive keeps its seconds; a time change writes `HH:mm:00`, since no wheel offers seconds.
## On touch, every control is 44px to a finger
The `touch:` variant in `globals.css` asks the same query as `useCoarsePointer`. Under it a control
takes 44×44 CSS px, in one of two ways:
- Buttons, fields and menu rows grow. `Button`, `Input` and `NativeSelect` take `touch:min-h-11`, a
floor, so a caller's `h-7` shrinks a button for a mouse only; menu rows take `touch:py-3`.
`--header-height` and `--card-title-lift` follow the 44px button.
- A compact control in a larger shape, or a link standing on its own line, keeps its look and takes
`touch:tap-target`, a centred 44px `::after`. The element must be positioned and its area must
stay off its neighbours: a positioned pseudo-element paints over an unpositioned field.
A link inside running text keeps its size. The unit toggle's area stops at its field, 34px tall.
Rejected: a width breakpoint, which misses a tablet, and growing compact controls, which breaks the
shapes they sit in.
## A finger types numbers on a keypad, with either separator
Under `useCoarsePointer`, `UnitNumberInput` and `DecimalInput` put a value that cannot go below
zero - depth, pressure, visibility, a gas percentage - in `type="text"` on `inputMode="decimal"`, or
`"numeric"` for a whole number. A signed value (temperature, altitude, a coordinate) and a duration
keep the full keyboard: iOS's keypads have no "-" or ":" and no way to switch.
Text, not `type="number"`: the decimal keypad types the region's separator, and WebKit's number
input drops a comma, so "1,5" arrives as 15. `parseDecimal` reads either separator, never both. Text
has no `min`/`max`, so `decimalEntryMessage` sets the bounds as custom validity, which the submit
refusal reads. Plain whole-number fields keep `type="number"` with `inputMode="numeric"`: no
separator to lose.
A mouse keeps `type="number"`: a desktop keyboard has a point key. Contacts and people set
`autoComplete="off"`: autofill would offer the diver's own details.
Rejected: `inputMode="decimal"` on `type="number"`, which shows the comma and still drops it.
## FastAPI 422 errors can be an array, not a string - never render `detail` directly
Pydantic validation errors return `detail` as an array of `{type, loc, msg, input}` objects; other
errors (duplicate name, etc.) return a plain string. Rendering the array in JSX crashes React
("objects are not valid as a React child"). Every error-handling call site goes through
`getApiErrorMessage()` (`lib/api/error.ts`), which normalizes both shapes into a displayable string;
never read `error.response?.data?.detail` directly.
## The generic `CreatableCombobox` pattern
`components/ui/creatable-combobox.tsx` is a generic "pick existing or create on the fly" combobox.
`TripCombobox` is a single-select wrapper supplying the fetch/create calls. `GearItemMultiSelect`
wraps it for several items per dive: `CreatableCombobox` is the "add an item" input, always rendered
with `value={undefined}` so it clears after each pick, above a reorderable list of added items.
`SpeciesMultiSelect` follows the same shape. Wrap `CreatableCombobox` this way for any further "pick
or create" entity rather than copying the interaction logic (filtering, commit-on-blur/Enter,
mouse-down-prevents-blur for option clicks). Read "The species picker resolves a pick into a catalog
row before form state sees it" before writing the next wrapper: it is the only one whose pick needs
a round trip before it has a value, and what follows from that is not visible here.
## Every picker of the diver's records searches its lookup
Pickers of trips, contacts, people, courses and gear search `/<plural>/lookup` through
`CreatableCombobox`'s `onSearch`: thin rows ranked by last use on a dive at or before `until`, the
edited record's date as its form holds it, or none. Remote rows are never re-filtered or re-sorted.
The dive form's site field opens the site finder, which searches `GET /dive-sites/search`: the sites
nearest the camera or, with none, the diver's by last use at or before `until`, as the lookup orders
them. Its one client-side drop is a site the dive already names. The delete dialog's reassign picker
and the review screen's matches still search `/dive-sites/lookup`.
A label comes from a `known*` prop, a lookup row, or a per-uuid read fired once with no `cancelled`
flag (`useLookupLabels`): under StrictMode the request in flight is the discarded mount's. A host
needing the whole record reads it on pick.
The dive form lists its trip's and course's people, and the course's contact, first and unlabelled:
a heading would teach the combobox unselectable rows. A pin the lookup matched by username or city
stays where the lookup put it.
## Dive time is a free-typed, regex-validated "MM" or "MM:SS" string in the form
`Dive.submerged_time` is seconds on the API types (`Dive`/`DiveCreate`/`DiveUpdate`); the form field
holds a plain string (`"45"`, `"45:30"`) — see `dateTimeField()`/`durationField()` in
`lib/validations/dive.ts` (no `.transform()`). `durationField()`'s regex (`^\d{1,3}(?::[0-5]\d)?$`)
is the only validation, via `<FormMessage />`, with no live reformatting. The seconds half is
optional: dive computers report whole minutes. A colonless value is minutes (`"130"` is 130 minutes,
never 1:30), so the minutes half is capped at three digits and a mistyped `"1030"` is refused, not
stored as a 17-hour dive.
Seconds convert right before submit and after fetch via
`parseFormDuration()`/`formatDurationForForm()` in `lib/date-time.ts`; a missing seconds half parses
as zero, and the formatter always writes `"MM:SS"`. Reuse them elsewhere; a "total minutes" number
input loses sub-minute precision.
The `<FormLabel>` is a bare `Dive time`, the word on the diver's wrist; the key stays
`submerged_time`, which nobody reads as end minus start. Two prose strings move with
`DURATION_REGEX`: the placeholder in `dive-form-fields.tsx` and `durationField()`'s default message;
a test pins the message, the placeholder has no guard.
## Occasional `.next` cache corruption during builds
`npm run build` intermittently fails with errors unrelated to the diff (`ENOENT` on `.nft.json`
trace files, `Cannot find module for page: /some-route`) while TypeScript and lint pass. Remedy:
`rm -rf .next && npm run build`. Try it before assuming the code is broken whenever a build failure
does not match the change just made.
A running dev server is not a cause and does not have to be stopped: `next dev` writes its output
under `.next/dev/` and a build writes beside it, so the two share no cache. The remedy clears both,
and a dev server recompiles on the next request.
## Soft navigations are timed against a production build, on its own port
`scripts/measure-navigations.mjs` times a click — when the RSC round trip ends, when `<main>` first
changes, when the destination's data lands — and needs `npm run build` rather than `npm run dev`.
Prefetching is a production-only behaviour, so a dev measurement describes a navigation the app
never performs.
The build serves itself on `:3002`, clear of the map renderer's `:3001`, and talks to the API
same-origin: `NEXT_PUBLIC_API_URL= npm run build`, then
`API_INTERNAL_URL=http://localhost:8000 npx next start -p 3002`. _Rejected:_ the default
split-origin build, whose baked API base sends every request from `:3002` at an origin the API's
CORS allowlist does not name; and copying a `.env` into a worktree, which bakes that base back in.
The commands, the columns and the method for timing the same hop against the flagship are in the
script's header.
## Layout width convention
Every page inside the shared chrome puts its content in one `max-w-6xl` column - `HERO_BODY` in
`ui/map-hero.tsx` under a hero, `PAGE_COLUMN` where there is none - and the header and footer share
that column, so their edges line up with the content's. The landing page (`/`), built from
full-bleed alternating sections, is the exemption, and so are the dive "new"/"edit" forms, whose
hero and body share one centred `max-w-2xl` column (`FORM_COLUMN`). This is a content-width choice
only; `Header`/`Footer` come from `AppShell` (next section).
## `Header`/`Footer` live once in `AppShell`, not per-page
`components/layout/app-shell.tsx`, mounted near the root of `app/layout.tsx`, renders
`Header`/`Footer` around `children` for every route except the hardcoded `NO_CHROME_ROUTES` list.
Rendering them per page duplicates the chrome JSX and unmounts/remounts `Header`/`Footer` on every
navigation (visible jank, header-local state reset), so pages never import them.
`Header` takes no `currentPage`/`showDashboardActions` props; it calls `usePathname()` and derives
the active nav item by matching the path against each `href` in `NAV_ITEMS` in `header.tsx`. A new
top-level destination is an entry there, with its tier.
A route that needs no chrome goes into `NO_CHROME_ROUTES` in `app-shell.tsx` — there is no per-page
opt-out — and renders `layout/standalone-shell.tsx` rather than a hand-rolled centered card:
`AppShell` holds the app's `<main>`, and that component holds the chrome-free half's. See "The
chrome-free routes share `standalone-shell.tsx`, which carries their one `<main>`".
## The header sorts its destinations by use
The bar carries Home, then the most used record pages in usage order. `More ▾`, last in the row,
carries the other record pages, then Admin for a superuser. The account menu carries the account,
with Check-in, Import and Export; "+" carries creation, import included. Below `md` the burger lists
everything in the same order, ruled into Home, the logbook, the other record pages and Admin.
Tiers are breakpoints, not measurement: an `lg` item is `hidden lg:inline-flex` in the bar and
`lg:hidden` in More, so the row needs no `ResizeObserver` and never reflows after paint. More stays
at every width rather than giving way to a tier where everything fits, so the bar's set changes only
at `lg`.
More rather than the burger is the overflow at `md` and up because a burger beside a visible row
hides which pages are missing from it; a labelled dropdown in the row says so.
## Shared list-page pattern: `useAuthGuard` + `useInfiniteResource` + `useDeleteResource`
The dives/trips/dive-sites list pages (`components/dives/dives-page-content.tsx`,
`components/trips/trips-page-content.tsx`, `components/sites/sites-page-content.tsx`) share three
hooks and two components; any new deletable-resource list reuses them:
- `hooks/useAuthGuard.ts` redirects to `/signin` once the auth check settles signed-out
(`useRedirectIfAuthenticated` mirrors it for public-only pages).
- `hooks/useInfiniteResource.ts` takes a `(page, perPage) => Promise<{data, total_count, has_more}>`
fetcher and a `keyOf`, returning
`items`/`isLoading`/`isLoadingMore`/`totalCount`/`hasMore`/`loadFailed`/`loadMore`/`reload`/`removeItem`/`applySaved`.
Pages accumulate. Pair with `components/ui/load-more-trigger.tsx` (`<LoadMoreTrigger />`), whose
button doubles as the scroll sentinel and hides on a single page.
- `hooks/useDeleteResource.ts` takes a `(id) => Promise<...>` delete function and returns
`deletingId`/`pendingId`/`requestDelete`/`cancelDelete`/`confirmDelete`. Pair with
`components/ui/confirm-dialog.tsx` (`<ConfirmDialog open={pendingId !== null} />`), not the native
`confirm()`, which freezes the tab.
Wire `useInfiniteResource`'s `removeItem` as `useDeleteResource`'s `onDeleted` so the row vanishes
without the list collapsing to page one; `reload` is for a delete that changes other rows. Why
`removeItem` rather than a refetch, and why the cursor moves with it, is under "The lists load on
scroll, and a delete leaves the one you are reading in place".
## Access token lives in memory only, never in `localStorage`
Anything running on the page (XSS, a compromised dependency, a browser extension, an error-reporting
SDK serializing storage) can read `localStorage`, so `lib/api/client.ts` holds `access_token` in a
module-scoped variable exposed via `getAccessToken`/`setAccessToken`/`clearAccessToken` — not React
state, since the request interceptor needs the latest value at request time, not a re-render. The
refresh token is already an `httponly`, `secure`, `samesite=lax` cookie set by the API (`login.py`).
The token does not survive a reload, so `AuthContext`'s bootstrap effect calls
`refreshAccessToken()` (POST `/auth/refresh`, re-deriving a token from the cookie) on every mount.
`authAPI.isAuthenticated()` only says whether this tab holds a token right now; use
`refreshAccessToken()` to learn whether a session exists.
This defeats passive exfiltration only. A live XSS payload can call `/auth/refresh` itself while the
page is open; escaping and the CSP (below) close that gap, not storage choice.
## `NEXT_PUBLIC_API_URL` is the full base, `/api/v1` included - not an origin
The variable goes straight to axios as `baseURL` in `lib/api/client.ts`; `lib/api/*.ts` modules pass
route-relative paths on top. The API mounts everything under `/api/v1` (`APIRouter(prefix="/api")` +
`APIRouter(prefix="/v1")`, not configurable), so the prefix belongs in the variable; an origin-only
value 404s every request.
Appending the prefix in `client.ts` instead is rejected: a reverse proxy mounting the API under a
subpath has nowhere else to say so, and `src/proxy.ts` is written around a value carrying a path.
Two other readers: `src/proxy.ts` needs an origin for the CSP's `connect-src` (a path there matches
only itself), so `apiCspSource` in `lib/api-base.ts` takes `new URL(...).origin`, guarded against a
relative value. `scripts/screenshots.mjs` appends only the route; running in Node with no page
origin, it needs an absolute value, hence `.env.example`'s absolute dev URL.
The variable is an optional build-time override for split-origin deployments; unset, the base is the
relative `/api/v1`, proxied by this app (next section).
## The web app proxies `/api/v1` to the API, and that is the shipped topology
With `NEXT_PUBLIC_API_URL` unset, axios' `baseURL` is the relative `/api/v1`
(`DEFAULT_API_BASE_URL`, `lib/api-base.ts`) and the catch-all `app/api/v1/[...path]/route.ts`
streams to `API_INTERNAL_URL` (default `http://api:8000`; `http://localhost:8000` under `next dev`).
`NEXT_PUBLIC_*` values are inlined at build, tying a baked-in address to its builder; a route
handler reads `process.env` per request, whereas `rewrites()` in `next.config.js` freeze into the
`output: "standalone"` build.
Load-bearing in `lib/api-proxy.ts`: `Expect` is dropped (`undici`: `UND_ERR_NOT_SUPPORTED`);
`Accept-Encoding` is not forwarded nor `Content-Encoding`/`content-length` passed back (`fetch`
decodes its hop); `Set-Cookie` is re-added via `getSetCookie()` (comma-joining breaks `Expires`); no
body for `HEAD`/204/205/304; `request.body` streams (`duplex: "half"`) only while `src/proxy.ts`'s
matcher excludes `api/v1/`, as middleware buffers, truncating at
`experimental.proxyClientMaxBodySize`; a `Location` on the internal origin is cut to its path
(Starlette builds redirects from the `Host` it was dialled on); the unreachable-API log omits the
`?token=` query; `X-Forwarded-For` passes through unappended (a handler cannot see the socket peer).
The API trusts it only from `TRUSTED_PROXY_IPS`: omitted, callers share one rate-limit bucket;
naming a directly exposed container makes every per-IP limit forgeable (`.env.example` warns).
## Strict, nonce-based CSP via `src/proxy.ts` - Node server only
`src/proxy.ts` (export `proxy`, not `middleware`) sets a per-request
`script-src 'nonce-...' 'strict-dynamic'` CSP. `app/layout.tsx` reads it from the `x-nonce` header
via `headers()` for `next-themes`' `ThemeProvider`; Next propagates it elsewhere. All routes go
dynamic (`ƒ`); accepted. Node server only (`output: "standalone"`): a static export
(`output: "export"`) cannot carry a per-response nonce; that needs a hash-based CSP.
- `eslint.config.mjs` sets `"react/no-danger": "error"`; `dangerouslySetInnerHTML` needs a sanitizer
plus disable.
- `style-src-attr` is plain `'unsafe-inline'`: third-party inline `style` attributes carry no nonce
and execute no script. `style-src` is the nonce plus the two Scalar stylesheet hashes in
`lib/api-reference-csp.ts` in production; dev needs `'unsafe-inline'` for Fast Refresh.
- Radix scroll locks (`react-remove-scroll` -> `react-style-singleton`) inject a raw-DOM `<style>`
with `get-nonce`'s nonce, so `src/components/nonce-provider.tsx` calls `setNonce(nonce)` in
render, before descendants' effects.
- The matcher omits Next's guide's `missing:` clause: prefetch payloads carry no nonce, and it lets
any `purpose: prefetch`/`next-router-prefetch` header skip the CSP; `src/proxy.test.ts` pins that.
`cacheComponents`/PPR does not reopen it, for the reason the section below gives.
## Cache Components asks for one opt-out, and leaves the nonce CSP alone
Under `cacheComponents` and `partialPrefetching` the root layout's `headers()` read is request-time
data outside a boundary: every static shell is empty and the build fails.
`export const instant = false` on the root layout allows a blocking route. The shells stay empty: a
shell is rendered per request, and the gain is one per route rather than one prefetch per link. The
root is the placement because the read is in it, and nothing lower covers the app. No URL hook needs
a Suspense boundary of its own.
The nonce holds. Every document render mints its own; a prefetched shell carries the nonce of the
request that produced it, and its scripts load under `'strict-dynamic'`.
`connection()` replaces `dynamic = "force-dynamic"`, which the flag rejects, where a read must stay
in the container.
_Rejected:_ a hash-based or SRI CSP.
## A page under an auth-gate layout opts out of instant validation
`admin/`, `dives/(detail)/` and `settings/(sections)/` are Client Component layouts that draw a
spinner or skeleton in place of `children` while auth is loading, which on the server it always is.
Next's dev-time validation renders each page below them there, so the page never renders and the
overlay reports it dropped. Each such page is a Server Component exporting `instant = false`, its
client body in `components/` where it has one: a Client Component cannot carry the export, and the
validator also tries the navigation that changes only the page, which no layout's `false` reaches. A
new page below a gate needs the same.
_Rejected:_ rendering `children` while auth loads, which mounts cards that fetch before there is a
token; and `validationLevel: "manual-warning"`, which stops validating every other page too.
## A route stays mounted, so an effect that loads on mount guards on what it loaded for
With Cache Components on, the router keeps the route a diver left mounted under
`<Activity mode="hidden">`, whose effects are destroyed on hide and re-created on show. State
survives, so an effect that loads on mount runs again and overwrites it: a list scrolled six pages
deep snaps to page one, a half-typed form repaints, an open dialog blanks.
`hooks/useEffectOnChange.ts` holds the dependencies an effect last ran for and skips a re-run
against the same ones. The data hooks compare the same way but re-read rather than skip, since a
dive logged elsewhere has already made what they hold wrong: `useResource` without its loading state
or its `onLoaded` re-seed, `useInfiniteResource` over the rows on screen rather than page one, and
reloading where it holds none, the return being the only retry a failed first load gets.
_Rejected:_ counting mounts, which cannot tell a return from a genuine change.
## Unified auth flow: one passwordless `AuthForm`, no password-based `/signin`/`/signup` pair
There is one auth form, `components/auth/auth-form.tsx` (email, "Continue", "Continue with Google"),
for the passwordless flow; Settings has no "Change Password" card. Its host page depends on
`REGISTRATION_MODE` and on join and invite links; see "The landing hero holds one of two forms, and
the API is what says which" and "`/signin` is a dedicated sign-in page, and carries where the
visitor was headed".
- `/auth/verify` (`components/auth/verify-page-content.tsx`) is the magic-link target
(`{FRONTEND_URL}/auth/verify?token=...`): a page rather than the `POST /auth/email/verify` call,
so a scanner's GET never burns the single-use token; it fires only on a click (see "Both
magic-link pages require an explicit click before the verifying `POST` fires").
- `/onboarding` (`components/auth/onboarding-page-content.tsx`) completes the profile (name,
username) from `AuthContext`'s `onboarding` state, set in memory by
`verifyEmailLink`/`signInWithGoogle` on `status: "onboarding_required"`, never persisted, so a
direct load bounces to `/`.
- `AuthContext` exposes `onboarding`/`completeProfile`/`clearOnboarding` and
`requestEmailLink`/`verifyEmailLink`/`signInWithGoogle`; the last two resolve with the applied
`AuthOutcome`, and callers pick the destination from its `status` (see "The entry points resolve
with the whole outcome, not a status").
## `google-icon.tsx`'s "G" mark is extracted directly from Google's own pre-approved asset download - not hand-reconstructed
`components/icons/google-icon.tsx` holds the current gradient "super G" that Google's Sign in with
Google branding guidelines mandate; the flat four-quadrant mark
(`#4285F4`/`#34A853`/`#FBBC05`/`#EA4335`) is outdated and rejected. The SVG comes from the "Download
Pre-Approved Brand Icons" bundle, not a hand-drawn approximation: the mask path tracing the
letterform and the gradient layer it masks — a CSS `conic-gradient()` in a `<foreignObject>` (SVG
has no conic paint server) plus blurred colour ellipses — are kept verbatim; the pill
background/stroke and Figma-only metadata (`data-figma-gradient-fill`, `data-figma-skip-parse`) are
dropped. The `viewBox` is cropped from `0 0 40 40` to `10 10 20 20` without touching inner
coordinates. Every mask/clip-path/filter `id` is namespaced through `React.useId()` so multiple
instances cannot collide. The root `<svg>` keeps `fill="none"`: `fill` inherits, and without it
Figma's fallback `<path>` beside the `<foreignObject>` paints solid black over the gradient.
## Changing your account email is a request/confirm flow, not a plain field edit
`USER_FIELDS` in `lib/validations/user-fields.ts` has no `email`, matching the API's `UserUpdate`.
Email lives in `components/settings/email-change-card.tsx`: enter a new address, submit via
`authAPI.requestEmailChange(newEmail)`, get the same generic "check your new email" message even for
a taken address, and the change applies only once the emailed link is confirmed.
`POST /user/email-change/request` always acts on the caller's own account, so there is no address to
name but the new one.
The field is always visible with one full-width "Send confirmation link" button — no edit toggle, no
cancel — matching the Profile Information card above it.
The link points at `/settings/confirm-email` (`components/settings/confirm-email-page-content.tsx`),
a standalone centered card in `NO_CHROME_ROUTES` like `/auth/verify` and `/onboarding`. On success
it calls `refreshUser()`, harmless when the visitor is not signed in there.
## Both magic-link pages require an explicit click before the verifying `POST` fires
`/auth/verify` and `/settings/confirm-email` render a `"ready"` state with a plain button ("Sign in"
/ "Confirm email change") and call `verifyEmailLink`/`verifyEmailChange` only from its `onClick`,
never on load. Mail clients (Outlook Safe Links, iOS Mail previews) load links in a JS-executing
browser before a human clicks; verifying from a `useEffect` hands them the single-use token. A click
is what automation cannot fake, as `/onboarding` already requires for a new account.
Rejected: a same-browser pairing cookie (requesting on a laptop and opening on a phone is normal),
and verifying on load while leaning on the backend's idempotent reuse of a used-but-not-invalidated
token (`AuthenticationRequest.used_at` vs `invalidated_at`), which still lets automation sign in.
That leniency remains, as defence in depth for double clicks.
`/settings/confirm-email` auto-redirects to `/settings/account` 3 s after `"success"` (`setTimeout`,
cleaned up on unmount) beside a "Back to settings" link; `/auth/verify` needs none, since a
`router.replace` to the outcome's destination follows `verifyEmailLink` directly.
## Magic-link pages: The button itself shouldn't show for a link that's already been used
Both pages call a side-effect-free precheck on mount —
`authAPI.checkEmailLink`/`checkEmailChangeLink` (`GET /auth/email/verify/check`,
`GET /user/email-change/verify/check`) — before showing the confirm button, in a `"checking"` state
distinct from the post-click `"verifying"`. A used, invalidated or expired token goes straight to
`"error"`; only a live one reaches `"ready"`. Otherwise the back button lands on a page whose button
"succeeds" again via the backend's idempotent-reuse leniency, meant for races. A `checkedRef` guard
keeps Strict Mode from double-firing the check.
The check returns the token's target email, shown in `"ready"` ("Sign in below as `{email}`") and,
for confirm-email, in `"success"` from `verifyEmailChange`'s response.
`AuthForm`'s "Check your email" screen gates "Resend link" behind a 30 s client-side countdown
(`RESEND_COOLDOWN_SECONDS`), a self-rescheduling `setTimeout` (not a mount-tied `setInterval`) that
stops at zero and restarts on resend. The real limit is server-side (`MagicLinkSettings` in the
API's `core/config.py`); a resend past it surfaces `RateLimitException`'s message.
## Current-user endpoints live on a bare `/user`, not `/user/me`/`/user/{uuid}`
The API serves current-user-only routes on a bare `/user` (no `/me`, no `{uuid}` — see the API's
`DECISIONS.md`), separating "my account" (full data, always the signed-in caller) from a future
public-profile endpoint for other users (limited fields, no `email`, not built). `lib/api/auth.ts`
matches: `authAPI.getCurrentUser()` calls `GET /user`, and `authAPI.updateProfile(profileData)`
calls `PATCH /user`. `lib/api/dive-stats.ts`'s `diveStatsAPI.getDiveStats()` calls
`GET /user/dive-stats`; its callers are `home-page-content.tsx` and `checkin-page-content.tsx`.
There is no way to fetch or manage another user's data through this API until the public-profile
endpoint exists.
## FIT imports: one vendor-neutral label, and gas gaps filled here but declared
A stored FIT file reads "FIT", not "Garmin FIT": one reader reads every vendor's FIT files under the
format id `fit`, and the label is logbook import's, as for every format the form takes.
Gaps are never filled: an invented 11.1 L of air corrupts `compute_gas_use`, so a gap stays `""`,
saves as NULL, and the import box names it in a line that stays on screen. `guessed` is keyed off
the file alone and holds a per-field source fixed at import; the note quotes no value, the fields
being ~2,200 px away. Helium folds into "gas mix" when oxygen was guessed too, `helium fraction`
otherwise.
`mergeMixture` reads the file first, then the same-position cylinder, pairing only when counts
match; pressures move as a pair. `role="status"` renders unconditionally, `sr-only` until it has
text. `lib/dive-import.ts` holds the logic; `dive-file-import.test.ts` tests `applyParsedDiveToForm`
against the pages' literal seeds, `[]` and one `DEFAULT_MIXTURE`.
## Parsed dive-file mixtures need `useFieldArray().replace()`, not `form.setValue()`
`applyParsedDiveToForm` (`dive-file-import.tsx`) fills top-level fields with `form.setValue(...)`
but hands `ParsedDiveSchema.mixtures` to an explicit `replaceMixtures` parameter, mapped to
`DiveMixtureInput` via `mergeMixture`. `MixtureFields` (`mixture-fields.tsx`) renders the array
through `useFieldArray({ name: "mixtures" })`, which keeps its own `fields` state keyed by generated
`id`, and `setValue` does not keep that bookkeeping in sync.
There must be exactly one `useFieldArray({ name: "mixtures" })` per form. Two instances on one
`control`/`name` do not sync on shrink: `replace()` through one leaves the other's `fields.length`
unchanged when the new array is shorter, so an import with fewer mixtures than the form holds leaves
trailing rows behind. `mixture-fields.tsx` exports the `MixtureFieldArray` type
(`UseFieldArrayReturn<MixtureFieldsValues, "mixtures">`) and `useMixtureFieldArray(control)`;
`new-dive-page-content.tsx` and `edit-dive-page-content.tsx` call it once beside `useForm()` and
pass the result through `DiveForm` to `DiveFormFields`/`MixtureFields` and `DiveFileImport`, which
never create their own. `ParsedDive` (`lib/api/dives.ts`) declares `mixtures: ParsedDiveMixture[]`
explicitly.
## `dives/new`/`dives/[id]/edit` pages share `DiveForm`/`PlainHero`/`PageSpinner`
A dive form page is its own data-loading effects, its own `onSubmit` and its early-return states,
then one `PlainHero` and one `DiveForm`. `useMixtureFieldArray(control)` (`mixture-fields.tsx`)
holds the `useFieldArray` generic parameter and cast in one place. `DiveForm` (`dive-form.tsx`)
wraps `Form`/`form` + `DiveFileImport` + `DiveFormFields` + `DiveFormActions`, each section a card
of its own; the per-page inputs are `mode`, `onSubmit`, `cancelHref`, `submittingLabel` and
`submitLabel`. The hero is the one every record page draws, narrowed with the body to one centred
`FORM_COLUMN` at the form's width: the page's wide column would put the Fields control a window away
from the form it configures, and leave the form off centre.
`PageSpinner` (`components/ui/page-spinner.tsx`) is the full-viewport `min-h-screen` `<Loader2>` for
the top-level auth-loading gate. The list and detail pages render below `AppShell`'s header and
footer, so their spinners use `min-h-[60vh]` inline. `SectionSpinner`
(`components/ui/section-spinner.tsx`) is a loading section inside a rendered shell; `NotFoundState`
(`components/ui/not-found-state.tsx`, `message`/`backHref`/`backLabel`) the not-found state. Both
omit the outer container `div`: the page wraps them in its own column, `PAGE_COLUMN` on a detail
page.
## Mixture numbers display at the API's 2-decimal precision
The API rounds parsed `oxygen`/`helium`/`start_pressure`/`end_pressure` to two decimals, and
`DiveMixturesCard` renders `oxygen` and `helium` as raw numbers — `toFixed(1)` hides a real digit
(`20.99%` as `21.0%`).
Volume is `VolumeCombobox` (`volume-combobox.tsx`), built like `CreatableCombobox`: a plain
`<input type="text" inputMode="decimal">` plus a hand-rendered absolute-positioned dropdown of
`<button>`s, not a `<select>`, because a parsed volume usually matches no preset. The input shows
the bare number (`11.1`, never `11.1 L (AL80)`); a preset's label appears only in the dropdown,
which always lists every preset with no filter-as-you-type — few enough that filtering only hinders
comparing them. There is no custom clear button: select-all and delete already clear the input.
## Gear sets are loaded into the dive form, never linked from the dive
The dive form's gear section (`components/gear/dive-gear-field.tsx`) has a "Load a gear set..."
picker, the item list (`GearItemMultiSelect`) and a "Save as set" button. Picking a set replaces
`gear_item_uuids` with that set's items; from then on the two are independent, and the dive is saved
with items only — the API has no `gear_set_uuid` on a dive.
Loading a set into a non-empty list asks first via `ConfirmDialog`, naming what is about to be
replaced; loading into an empty list is one click. The picker un-selects itself once the list is
edited: `GearItemMultiSelect` fires `onManualChange` only on user-driven add/remove, not on the
programmatic `onChange` from loading a set, so it stops claiming "Sidemount" after half the kit is
swapped. "Save as set" opens `GearSetDialog`, the same component the gear page uses, with
`initialItemUuids` plus `allowChoosingTarget`, which adds a "Save to" dropdown of existing sets
alongside "Create a new set".
## The gear picker displays archived items but won't offer them
`GearItemMultiSelect` renders archived items — an old dive or an older set can reference retired kit
— with an "Archived" badge, removable but never offered for a new selection. The dropdown searches
the gear lookup, which never lists archived items, so retired kit does not eat into the page of
matches; an archived selection reaches the list through the same per-uuid lookup as any other
unknown uuid. New-dive prefill (`new-dive-page-content.tsx`) carries the previous dive's gear over
but skips archived items, since the picker would not offer them either.
## Gear is created and edited in dialogs, not on `new`/`edit` pages
A dive gets `/dives/new` and `/dives/[id]/edit` pages; nothing else does — trips and dive sites are
dialogs for the reason below, and so is gear. `GearItemDialog` and `GearSetDialog` handle both
create and edit — passing an existing record edits it in place, omitting one creates — and `/gear`
is one page listing items and sets together. A gear item is a handful of fields (name, brand, type,
rented, notes, and a cylinder's volume, working pressure and material), and the flow that matters
most is adding one from inside a half-filled dive form, where navigating away would lose the form or
need draft persistence. `/gear/[id]` remains as a detail page because it hosts the "Dives with This
Gear" list.
## The gear delete dialog offers "Archive instead", and its wording is pinned by a test
Both gear delete confirmations read:
> Deleting removes this gear from your dives and gear sets. To keep it in your log and its service
> history, archive it instead. Either way, its service reminders stop.
A deleted item drops off dives and sets entirely (`crud_dive_gear_items.py`,
`crud_gear_set_items.py`). `ConfirmDialog` takes `secondaryAction?: { label, onClick }`, rendered
between Cancel and confirm; it is disabled by `isLoading`, not by `confirmDisabled`, which only
means the dialog's content is incomplete.
The offer appears only while `is_archived` is false, because both pages archive through a toggle
(`toggleArchived` on the list, `handleToggleArchived` on the detail page). It archives directly,
skipping the row action's confirmation (`handleArchiveToggle` / `setIsArchiveConfirmOpen`).
"Either way" stands as its own sentence: archiving stops reminders exactly as deleting does
(`send_gear_service_digests` filters `GearItem.is_archived.is_(False)`), and hanging the clause off
either verb reads as "archiving keeps them". A test pins the wording.
## Selection is a checkbox, and a setting is a switch
The line is selection against setting: a switch is a state you leave set, while a checkbox picks
members of a set — invite-queue rows for a batch, or a contact's roles. Those are the app's
checkboxes. Every other boolean (the Fields dialog alone has one per `DIVE_FORM_FIELD_REGISTRY` and
`DIVE_FORM_ALWAYS_ON_FIELDS` entry) is `Switch` (`ui/switch.tsx`), taking
`checked`/`onCheckedChange`; Radix's `Root` renders `<button type="button">`, which
`<label htmlFor>` names.
The tri-state settles it: select-all is `indeterminate` on a partial selection, and ARIA forbids
`aria-checked="mixed"` on `role="switch"`, so a switch would read as off with rows selected.
`ui/checkbox.tsx` is a styled native `<input type="checkbox">`, not `@radix-ui/react-checkbox`;
`indeterminate` is a DOM property, not an attribute, so the header box sets it through a `ref`
callback. A screen reader reads the mixed state at the header box; the "N addresses selected"
`aria-live` region above the table announces as rows are ticked.
## A dialog's submit event bubbles into the form that opened it
Every "quick add" dialog — gear item, gear set, dive site, trip — renders from inside the dive form,
and each contains its own `<form>`. Radix portals `DialogContent` to `document.body`, so the DOM has
no nested forms, but React bubbles through the React tree: the dialog's submit lands in the dive
form's `onSubmit`, and `handleSubmit` calls `preventDefault()` but never `stopPropagation()`. Saving
in the dialog then submits the dive form too, which fails validation on an unfilled field ("Dive
time is required") and scrolls to it; Enter in any dialog input does the same via implicit
submission.
`lib/dialog-form.ts`'s `dialogFormSubmit()` stops propagation before handling:
```tsx
<form onSubmit={dialogFormSubmit(form.handleSubmit(onSubmit))}>
```
Any new dialog containing a form must use it; the bug is invisible until the dialog is opened from
inside another form. `lib/dialog-form.test.ts` covers stop-before-handle, event passthrough and
errors not swallowed.
## Gear types are a closed vocabulary shared with the API, not free text
`GEAR_TYPES` in `lib/api/gear.ts` mirrors the API's `GearType` enum, which is DiveJSON 1.0's
`gear_item.type` enum value for value and in order; the check is a diff against
`schema/1.0/divejson.schema.json`'s `#/$defs/gear_item/properties/type/enum` in
[DiveJSON](https://github.com/divejson/divejson). Declaration order is the picker's order, so a new
category enters the format first and lands here at its position.
`gearTypeLabel()` de-snakes a type this build lacks, through `labelOrFallback`, and a test walks
`GEAR_TYPES` for a type without a label. Nothing generates the list from either source, so the API
widens first — it rejects an unknown `type` with a 422 — and the picker last.
Type is optional: `""` is the form's "not set" state, sent as explicit `null` on update and omitted
on create, and it is the "No type" option's value.
## `ComboboxItem.hint` is a cosmetic second line, not a `location`
`CreatableCombobox` items carry an optional `hint`: a purely cosmetic second line after the name.
Dive sites put a location there; the gear picker puts the item's type ("Apeks XTX50, Regulator"),
since kit often has cryptic model names and the category is what makes the dropdown scannable. The
name is `hint` rather than `location` because the slot is not about places. A person's is the linked
account's `@username`, the second field the people search matches, so a row found by its username
shows why it is there.
## Drag-to-reorder uses Pointer Events and no library
`hooks/useDragSort.ts` drags gear and dive sites by a grip handle without a library.
Pointer Events, not HTML5 drag-and-drop, which emits nothing for touch; the hook sets
`touch-action: none` on the handle or the browser scrolls instead. Move listeners go on `window`,
not `setPointerCapture`: React moves the capturing row on reorder, which fires `lostpointercapture`
and kills the drag. The row follows the pointer via `dragOffset`, re-based at each swap
(`desiredTop - targetRect.top`); swaps compare row centres and walk every neighbour cleared.
`moveItem()` returns the same reference for a no-op move since `onReorder` fires per pointermove.
The handle is a real `<button>` answering Up/Down, and uuid-keyed rows keep focus across reorders.
Dive sites have no separate arrows; their handle's `aria-label` names the position and the primary
slot, since position 0 decides where the dive is filed. `resolveSwapTarget()` is exported so the
pure logic is unit-testable; jsdom has no layout.
## The combobox opens on click as well as focus, and stays open for multi-select
`CreatableCombobox` opens its menu on `onClick` as well as `onFocus`. Picking an item closes the
menu while the item's `onMouseDown` `preventDefault` keeps focus on the input, and `focus` does not
fire on an already-focused element — so focus alone leaves the input a dead end after any close that
keeps focus (a pick, Escape, a dialog restoring focus). A click always brings the menu back.
`keepOpenOnSelect` keeps the menu up after a pick and clears the typed filter so several items can
be added in a row. The append-only pickers set it, their picked items moving into the list above;
`TripCombobox` leaves it off because it fills a single field, where closing and showing the chosen
name is right.
A pick that keeps the menu open runs the search once more for the current query, the empty one after
an untyped pick: neither the query nor the open state changed, and a feed that leaves out what the
field holds would otherwise lose a row per pick instead of refilling. The gear and people pickers
make one extra request per pick; the tags picker filters locally, so it does not re-ask.
## Dropdowns are navigable with Up/Down and Enter
`CreatableCombobox` (dive sites, trips, gear) and `VolumeCombobox` (cylinder presets) highlight rows
with Up/Down and take the highlighted one with Enter, sharing `nextActiveIndex()` so they move
identically.
The menu opens with nothing highlighted (`activeIndex === -1`), so Enter keeps its meaning — commit
the typed text, matching an exact name or creating one via `onCreate` — rather than picking the
first row; Down enters from the top, Up from the bottom. Movement clamps rather than wrapping. The
"Add new..." row is option 0, not a special case. Typing resets the highlight and hovering moves it,
so mouse and keyboard agree. The input carries `role="combobox"` with
`aria-expanded`/`aria-controls`/`aria-activedescendant`, rows carry `role="option"`/`aria-selected`,
and the highlighted row scrolls into view (`block: "nearest"`) in the 15rem-tall menu.
`VolumeCombobox` takes Up/Down for list navigation, so they never nudge the volume: browsing the
presets beats stepping a volume by a hundredth of a litre.
## Weight sits with the gear, not with the environment readings
`weight` (kilograms of ballast, a plain per-dive number on the API's `Dive`) renders directly below
the gear picker in `DiveFormFields` and inside the "Gear" card on the dive detail page, not beside
the environment readings. The form splits what the diver observed (depth, temperature, visibility,
water type, altitude) from how the diver was configured (gear, weight), and weight is the field most
often looked up to check against the suit and cylinder used.
The detail page's Gear card renders when either is present: `hasGearInfo` is
`gear_items.length > 0 || weight != null`, mirroring `hasEnvironmentInfo`. `dives/new` pre-fills it
from the last dive alongside gear and mixtures, since it rarely changes between consecutive dives.
Zod uses `min(0)` rather than `positive()`, matching the API's `ck_dive_weight_non_negative`: zero
is a real entry, distinct from an omitted one.
## Loading a gear set fills in the dive's weight too
A `GearSet` carries an optional `weight` (kg), and picking a set in the dive form applies it
alongside the item list; the dive keeps its own copy from then on.
`applySet` calls `onWeightChange` only when `set.weight != null` — a set without a weight is not
asserting "dive with zero lead", which is also why `gear_set.weight` is nullable rather than 0. The
`ConfirmDialog` fires on a non-empty item list only; the weight rides along unguarded, because
rebuilding eight hand-picked items is laborious and retyping one number is not. "Save as set" passes
`initialWeight={weight}` beside `initialItemUuids={value}`.
`DiveGearField` takes `weight`/`onWeightChange` without rendering the input. In `DiveFormFields` the
gear `FormField` is nested inside the weight one so both `field` objects are in scope; registering
`weight` twice (`useController` plus `FormField`) works in react-hook-form but leaves two
subscriptions to one name for no benefit.
## Service status is derived in the browser, because a cached "days remaining" is a lie
The API returns only clock-stable facts — `next_due_on`, `next_due_at_dive_count`,
`next_due_at_dive_time`, `last_service_on`, `dive_count`, `dive_time` — and no service status.
Status depends on today's date, and the single-item cache is an hour (the list 60s), so a
server-computed "due in 1 day" would still say so tomorrow.
`serviceStatus()` in `src/lib/gear-service.ts` does the arithmetic at render time. It is a
line-for-line twin of `service_status` in the API's `services/gear_service.py`, which exists only
for the reminder email; the constants are named identically on both sides
(`SERVICE_DUE_SOON_DAYS = 30`, `SERVICE_DUE_SOON_DIVES = 10`, `SERVICE_DUE_SOON_HOURS = 10`) so
`grep SERVICE_DUE_SOON` finds the trio. Every interval arm is evaluated and the most urgent wins
("annually or every 100 dives, whichever comes first"); an hours remainder is compared in seconds
and shown rounded up to whole hours of diving, as the digest shows it. `worstServiceStatus()`
collapses an item's schedules into a gear card's one chip and returns `null`, not `"ok"`, when there
are no schedules: nothing tracked is not everything fine, so the card shows no chip.
## `lib/gear-service.ts` holds the logic, `lib/api/gear-service.ts` holds the transport
`lib/api/gear-service.ts` is transport: hand-written types and axios calls (`SERVICE_KINDS`,
`serviceKindLabel`, the `gearServiceAPI` object) mirroring the API's Pydantic schemas, like
`lib/api/gear.ts`. `lib/gear-service.ts` is pure functions — no axios, no React — so the rules that
matter (status thresholds, date arithmetic, due-text phrasing, type presets) are unit-testable;
`gear-service.test.ts` holds the status truth table. The rules live in `lib/`, as plain functions a
unit test reaches without rendering anything.
`lib/api/gear.ts` imports `GearServiceScheduleSummary` from `lib/api/gear-service.ts` as
`import type`; the API side points the other way (`schemas/gear_service.py` imports nothing from
`gear_item.py`), so the pair can never become a cycle.
## Service dates are `YYYY-MM-DD` and never touch `new Date(dateString)`
`next_due_on`, `starts_on`, `serviced_on` and `last_service_on` are bare dates, and
`new Date(dateString)` parses one as UTC midnight — the previous day anywhere west of Greenwich,
making every due date read a day closer than it is.
`daysBetweenIsoDates()` builds both ends from split parts (`new Date(y, m-1, d)`), like
`formatDateOnly()`. Local midnight on both ends is immune to DST: both Dates shift by the same
offset, and rounding the millisecond difference absorbs the one 23- or 25-hour day between.
`todayIsoDate()` is built from local getters rather than `toISOString()` for the same reason. The
tests assert exact day counts across both DST transitions and run under UTC-8 through UTC+14.
## Gear service uses two dialogs, and both need `dialogFormSubmit`
`GearServiceScheduleDialog` and `GearServiceRecordDialog` follow `GearItemDialog`: dialogs rather
than pages, since they are a handful of fields reached from a gear detail page the diver wants to
stay on, with the same three-way open state (`null` closed, `undefined` creating, an object
editing).
Both contain a `<form>` and wrap their submit in `dialogFormSubmit()`. They are not opened from the
dive form today, but the rule is about the React tree, not the call sites, and costs nothing.
Every interval field uses the `""`-means-cleared convention of `gearSetSchema.weight`, mapped to an
explicit `null` on PATCH so an interval can be removed rather than ignored as an omitted key. The
"at least one interval" rule is an object-level `.refine()`, which unlike
`z.preprocess()`/`.transform()` leaves `z.input<>` untouched.
## Per-gear-type service presets are prefills, not safety advice
`defaultSchedulesForGearType()` prefills "Add schedule" — "visual inspection, every 12 months" for a
cylinder, "service, every 12 months or 100 dives" for a regulator — purely to save typing. The
presets are not authoritative: manufacturer intervals differ, and cylinder test periods are set by
jurisdiction (five years across much of the US and EU, two and a half in some regimes). Every field
stays editable, nothing is filled in silently on save, and the dialog says so. This is
dive-safety-adjacent UI and must not read as advice; keep that framing if the list grows. Gear with
no meaningful convention (a mask, a knife) or no type gets `[]`, not a made-up default.
## Gear due a service and renewals live behind the header's bell
`NotificationsMenu` sits left of the avatar on every page, its count chip `bg-destructive-solid`. It
is a `Popover`, not a `DropdownMenu`: a row holds a link and a button, and a menu item cannot
contain a second control. A failed read is said in the panel, since an empty panel answers "nothing
is due".
`useNotifications` reads `GET /gear-service-due` and `GET /certifications-expiring` on every
pathname change: `Header` outlives the pages where services are logged and expiry dates move, and
both reads are cached by the API. Neither takes a date horizon — a server-side "due within N days"
would bake today's date into a cached response and go wrong at midnight — so bucketing is
client-side, through the same `serviceStatus()` and `certificationRenewals()` every surface uses.
The gear list needs no extra request: `GET /gear-items` embeds each item's schedules as
`item.service`.
## A bell row opens the form that deals with it, and its title goes to the item
A certification row reads the card with `getCertification` before opening `CertificationDialog`,
which edits every field; the renewals read carries five. A policy's row opens the check-in page's
`CheckinDetailsDialog` on the insurance group, which saves through the shared check-in details, so
the rows and a sheet underneath show the save without an announcement.
A save from the bell is announced through `lib/saved-elsewhere.ts`, and what shows that data reads
again — the gear card and list, `/certifications`, `/checkin`, a course's certifications card —
since none shares a cache with the bell. Rejected: remounting the page, which drops its scroll and
the state of the routes kept hidden behind it.
`GearServiceRecordDialog` takes `gearItemUuid` rather than a `GearItem`, and its `schedule` is
`useMemo`d: the dialog resets its form on that prop's identity, so one built during render wipes
half-typed notes. `useNotifications` reads through a `.then()` chain, because
`react-hooks/set-state-in-effect` reads an awaited call in an effect body as a synchronous
`setState`.
## The email toggles live in their own settings card, and save on change
`NotificationsCard` is factored out like `EmailChangeCard` rather than bolted onto the profile form:
one card with a switch per scheduled email — `gear_service_emails`, `renewal_reminder_emails`,
`year_in_review_emails` — rather than a card per kind. Each switch saves on change rather than
behind "Save changes" — a single boolean, and a toggle that needs confirming reads as broken — and
sends only its own key, since `PATCH /user` is `extra="forbid"` and anything else alongside
would 422. The three fields are optional in the TS type and fall back to `true` when absent,
matching the server default.
## Private card images render from a blob URL, which needs `blob:` in `img-src`
`GET /certification/{uuid}/file/{side}` is owner-only and needs an `Authorization` header, which an
`<img src>` cannot send; `lib/api/client.ts` holds the access token in memory and only the refresh
token is a cookie, so a plain `src` would 401.
`hooks/useAuthedBlobUrl.ts` fetches through the API client with `responseType: "blob"`, wraps the
result in `URL.createObjectURL`, and revokes it on replacement and unmount (an unreleased object URL
leaks the blob for the life of the page). `proxy.ts` therefore lists `blob:` in `img-src`: `'self'`
does not cover blob URLs, and a `blob:` URL can only name data this document created.
The hook keeps one settled result object and derives `isLoading` from `result === null`; setting a
flag synchronously in the effect body trips `react-hooks/set-state-in-effect`. The blob is refetched
on every mount, so a card in both list and dialog is fetched twice — fine behind
`Cache-Control: private, max-age=300` and `ETag`, not at gallery scale.
## Private card images: Keying that fetch on (certification, side) is not enough - it breaks Replace
The card image sits at a stable URL whose contents change, which defeats two caches at once:
`fetchBlob` keyed on `[certificationUuid, side]` never re-runs `useAuthedBlobUrl`'s effect after a
replace, and `max-age=300` lets the browser serve the old bytes for five minutes from an identical
URL.
`certificationFileVersion(file)` in `lib/certification.ts` fixes both: it is in the `useCallback`
deps and is sent as a `v` query param the API ignores. `updated_at` moves on every replace; `uuid`
covers delete-then-upload, which inserts a new row whose `updated_at` is null like the original's.
It does not fold in `side`, already in the URL, so repeat views of an unchanged card still hit the
cache. It lives in `lib/` to be testable: `certification.test.ts` covers replace, second replace and
delete-then-reupload, none of which a type checker sees.
## PDFs are download-only, never previewed inline
`proxy.ts` sets `object-src 'none'` with a narrow `frame-src`, and inline PDF rendering needs an
`<object>` or `<iframe>`. Loosening either to display user-uploaded documents is a bad trade for a
preview, so a stored PDF shows as a labelled file with a Download button; most c-cards are
photographed, so the image path is the common one. Downloading goes through `saveApiDownload` in
`lib/download.ts` — a plain `<a href>` to the endpoint would 401.
## `useWatch`, not `form.watch()`
`CertificationDialog` shows `agency_other` only when the agency is `other`, read with
`useWatch({ control, name })`. `form.watch("agency")` returns a fresh function every render that
cannot be memoized, which `react-hooks/incompatible-library` flags — against the `useForm()` call,
and while it is present the react-hooks plugin stops analysing the rest of the component, so fixing
it can reveal previously silent `set-state-in-effect` errors in the same file.
## Certification expiry is derived in the browser, and `null` means "don't badge"
`lib/certification.ts` reuses `todayIsoDate`/`daysBetweenIsoDates` from `lib/date-time.ts`; both
avoid `new Date(dateString)` on a bare date, which parses as UTC midnight and lands a day early in
western timezones.
`certificationExpiryStatus` returns `null` for both "no expiry date" and "expires, but not soon" —
there is no "valid" state. Most recreational certifications never expire, so badging them all green
would bury the rows that need attention, the same reasoning as `worstServiceStatus` returning `null`
for untracked gear. The window is 90 days, not gear's 30: renewing a rescue or first-aid card means
booking a course with an instructor, not dropping a regulator at a shop.
## Card images and both pictures ride on their form's own save
Each is held as a pending edit and sent after the form's fields save, so Cancel leaves every stored
image untouched. For cards the order is forced: `PUT /certification/{uuid}/file/{side}` needs a uuid
a new card has only once `createCertification` resolves. `CertificationCardFiles` collects
add/replace/delete per side and `applyCertificationCardEdits` sends them serially, in
`CERTIFICATION_SIDES` order. The avatar (Profile Information) and the portrait (Check-in Details,
About You) each hold one `PictureEdit` — replace, adjust, remove, or the portrait's copy — which
`applyPictureEdit` sends after the form's own `PATCH`. Rejected: a picture card saving on pick
beside forms that wait for Save.
A failed image does not fail the save: the fields are written, and refilling the form to retry one
picture costs more than the picture. One toast names the side or the picture. A certification is
re-read whenever anything was sent, since only the API knows which sides landed. A picture that
failed stays pending, and the dialog holding it stays open: the failure is usually transient, and
the next Save sends it alone, since fields beside a pending picture are skipped when they would
write what is stored. Without a picture, a Save always writes the fields.
## Every c-card is drawn in one shape, and cropped to it on the way in
`CERTIFICATION_CARD_ASPECT` is 1013/638, measured off a current PADI e-card. Cards arrive in at
least four shapes (old PADI 1005×660, RAID 802×519, TDI/SDI 330×207), so one ratio everywhere is
what makes a row of them read as cards. `CertificationCardFrame` draws one, and so does
`CertificationCard` through `BackdropCard`'s `shape`, so the list shows each front whole;
`ImageCropDialog` crops uploads to that ratio, so stored bytes need no fitting; anything stored
uncropped is `object-cover`, trimming at most 4% off the old PADI design, which carries nothing near
its edges.
`self-start` guards that ratio rather than tidying anything: `aspect-ratio` applies only where
height is auto, and a flex or grid item stretches to its line. It is inert while every caller
renders the frame as an ordinary block child, and there for the one that does not.
`CERTIFICATION_CARD_ASPECT_CLASS` is spelled out because Tailwind generates only the classes it
finds written down; `certification.test.ts` holds it and the number in step.
Cropped bytes are WebP: this API stores what it is given. A browser with no WebP encoder falls back
to PNG on its own, so `croppedFilename` reads `Blob.type`.
## The dive form holds the imported file in page state and uploads it after saving
`DiveFileImport` calls `onFileAdded` only after a successful parse, and the page — `dives/new` and
`dives/[id]/edit` — parks the pending files in `useState` until `createDive`/`updateDive` resolves.
The API stores nothing at parse time and there is no dive to attach to before the save succeeds.
Uploading after the save also means importing a file and then cancelling an edit never changes the
dive's stored export.
A failed attach is a toast, not a rollback: the dive is saved and correct, and the file is kept so
parsing features can be developed against real exports, which is not worth undoing the diver's save
over. Both real failures surface with the API's wording — a 409 means the export is already attached
to another dive, a 422 that the import went stale and needs redoing. `isSubmitting` stays true
across the upload so the button does not re-enable mid-flight.
## The dive's source file downloads through the API client, like card images
`DiveRecordingsCard` fetches a Blob through the API client (`getDiveFileBlob`, keyed by dive and
file uuid) and saves it through `saveApiDownload`, the same path as `certification-view-dialog.tsx`
and for the same reason: the endpoint needs an `Authorization` header and the access token lives in
memory, so a plain `<a href>` would 401. No CSP change is needed, since nothing renders the file —
`useAuthedBlobUrl` and the `blob:` `img-src` entry are not involved. The `v` param is
`${uuid}:${updated_at}`: `uuid` covers delete-then-reattach, `updated_at` a replace; without it
`max-age=300` keeps serving the previous file's bytes.
## Deleting the imported file refreshes without the page-level spinner
`dives/(detail)/layout.tsx` hands `useResource`'s `refetch` down as `refreshDive`, which
`DiveRecordingsCard` calls as `onChanged`. It leaves `isLoading` alone and never redirects: a fresh
load would blank the page into its skeleton to swap one card, and redirect to `/dives` if the
re-read failed after a delete that had already succeeded. The detail page has no `onLoaded`, so the
re-seed `refetch` runs costs nothing there.
## `Dive.recordings` is optional because the list response never carries it
The same `Dive` interface backs `GET /dives` and `GET /dive/{uuid}`, and the API sends `recordings`
only on the detail one — the list is its hottest query and nothing in the table renders an
attachment. Hence `recordings?:` rather than a required member; do not "fix" the missing value by
adding it to the list server-side. A file's `parser_key` renders through `importSourceLabel`, which
de-snakes the id of a format this build has not heard of rather than showing a blank or "Unknown":
the API can grow a reader ahead of the frontend, and an empty cell reads as a bug.
## Air consumption is the API's number; the browser only explains its absence
`dive.gas_use` (SAC/RMV) arrives computed from the detail endpoint and renders as-is: unlike service
status, it depends only on stored dive fields, so a cached value cannot go stale. The one
implementation is the API's `services/dive_gas.py`; do not add a second here.
The browser owns `lib/dive-gas.ts`'s `gasUseUnavailableReason()`. The API returns `gas_use: null`
for an un-derivable dive without saying why; the Air Consumption card renders whenever the dive logs
a tank, showing the figures or the reason, because an absence despite filled-in pressures reads as a
bug. Its branches mirror the guards of `compute_gas_use()` and `compute_multi_tank_gas_use()` and
change with them (`grep gas_use` finds the set). It returns `null` when `mixtures` is absent (the
list response carries neither `mixtures` nor `gas_use`), and the multi-tank branch names a
limitation rather than asking for a field, except the one case in "The multi-tank branch splits
three ways, and tests attribution first".
## The air-consumption chart is hand-rolled SVG, and summarizes dives per calendar bin
No charting library: `src/proxy.ts` ships a nonce-based CSP that allows inline style attributes
(`style-src-attr 'unsafe-inline'`) but nonce-gates `style-src` in production, so a library injecting
a `<style>` element breaks only in production. `gas-use-chart.tsx` is plain `<svg>` themed off
`currentColor`.
The trend mark is a quartile box per calendar bin: the median as a tick, the middle half as the box,
a thin line between bin medians, dashed across a break longer than `TREND_GAP_MS` (60 days) so a
summer without dives is a gap, not a steady line. `binUnit()` picks the unit by the room each bin
gets on the axis, and its comment carries why a fixed unit, a trailing window of dives or grouping
by the gaps between dives were rejected. Quartiles rather than mean and deviation because one
misread gauge moves a quartile by at most a rank; the all-time baseline is a median inside the
all-time middle half, and the card's headline stat and its change against the previous period are
medians too, so the headline is judged against the baseline like for like.
Coral boxes, teal dots: declared once in `globals.css`, never under `.dark`, so they hold contrast
in both themes.
The y axis is not zero-based: `niceDomain()` rounds outward from the data, with 2.5 in its
progression so a 5-to-26 spread does not step by 10. It is scaled to the series less one dive in a
hundred at each end (`clipOutliers`), so one reading at twice the rest does not squash the rest into
the bottom third; a clipped dive is pinned to the edge and drawn hollow.
Each dot is a plain SVG `<a>`, not `next/link`. Below 560px the chart narrows its viewBox rather
than scrolling (`fittedChartWidth`). The card renders when empty, unlike the bell's service-due
list: missing pressures or an average depth are something the diver can fix.
## The chart windows to All/Year/Month, but scales itself from the whole series
`GasUseCard` owns a scope switch (`all`/`year`/`month`) with prev/next, and opens on `year` at the
most recent dive. Three things derive from the whole series, not the window: the y domain, so
periods stay comparable; the bins, summarized before windowing so a week straddling New Year is one
box in both years; and the x bounds, which are the calendar period, so one April trip is one
cluster, not a stretched year.
`periodRange`, `periodLabel`, `availablePeriods`, `stepPeriod` and `resolveAnchor` live in
`lib/chart-period.ts`, shared with `DiveActivityCard`. `stepPeriod()` skips to the next period
containing dives; `null` disables the button. The label is a `<select>` over `availablePeriods()`
whose value is the period's start, never the anchor: a `<select>` whose value matches no option
shows the first one. The anchor is always a dive's own timestamp.
Everything buckets on `diveWallClockTime()`, never `new Date(start_time).getTime()`, and reads back
with `getUTC*`/`Date.UTC`/`timeZone: "UTC"`; a local getter in that chain moves a New Year's dive a
year.
## The chart's tooltip is one state-driven card, not a tooltip per dot
One `hovered` index for the whole chart drives an HTML card over the plot, not a `Tooltip.Root` per
point: the same state drives the dot's enlarge-and-brighten, so dot and card cannot disagree.
`@radix-ui/react-tooltip` is for buttons.
It is positioned in percentages of the chart box (the SVG scales uniformly in a wrapper of its own
size) through the `style` prop, an inline attribute the CSP allows
(`style-src-attr 'unsafe-inline'`). It flips to stay inside the box — below the dot in the top
third, edge-aligned within 18% of either side — and `useKeepInside` pulls in whatever a phone-width
chart still leaves past an edge, where it would scroll the page sideways.
Each dot has an invisible `r=7` hit circle with `fill="transparent"`, not `fill="none"`, which takes
no pointer events. The card is `pointer-events-none` so it cannot steal the hover, but for the link
a finger's card carries (below); it has no accessible text (the link's `aria-label` does), and
clears when the window changes.
## A finger reads a chart by tapping or scrubbing, and the readout stays
Every chart reads out through `useChartReadout`. A mouse and the keyboard drive it through `hover`;
a finger taps a point or drags sideways to scrub, and the plot's `touch-action: pan-y pinch-zoom`
leaves vertical swipes and pinches to the page. The readout stays after the finger lifts, since
nothing is left hovering, and clears on a press outside the chart. Mouse events the browser sends
after a tap are ignored for a second: in whole pixels they name a neighbour on a continuous axis.
The dive profile zooms, so it keeps pinches for itself (`touch-action: pan-y`) and, zoomed, a drag
pans the plot unless it starts on the crosshair; a tap still reads out.
A gas dot is a link, so a first tap reads it and the card offers Open dive; a second tap opens it.
The dot pinned is the one the tap's `click` lands on, since the browser aims it at the nearest link
under the fingertip.
Rejected: a readout only while the finger is down, which covers what it reads.
## `--tooltip` is its own surface token, because `--popover` isn't one
`--popover` and `--card` are the same colour in both themes, so `bg-popover` over a card is
separated from it by a 1px border alone — fine for a dropdown over page background, wrong for a
hover card overlapping its own data points, which then reads as cut out of the card.
The hover card uses `--tooltip`/`--tooltip-foreground`, declared in `globals.css` and registered in
`tailwind.config.mts` like `--coral` and `--teal`. It is dark in both themes rather than inverting:
a near-black chip carries the same weight over a white card as over a dark one. Contrast against the
card: 17.9:1 light, 1.22:1 dark, where shadow and hairline border do the separating; chip text 17:1,
70%-alpha secondary lines 8.8:1.
The border is `border-white/10`, not `--border`, which is invisible on a near-black chip in light
mode and merges with it in dark.
## `niceDomain`/`axisTicks` live in `lib/chart-scale.ts`, not `lib/dive-gas.ts`
`niceDomain` and `axisTicks` live in `lib/chart-scale.ts`, with their tests, not in
`lib/dive-gas.ts`: a depth axis has nothing to do with gas use, and importing a gas module to scale
metres reads as an accident.
`niceDomain`'s "deliberately not zero-based" docstring is right for depth too. The depth axis
anchors at the surface by arithmetic — feeding the surface's own `0` into the values makes
`Math.floor(0 / step) * step` equal 0 — so a `zeroBased` flag would do nothing.
## The dive profile chart is hand-rolled SVG too, with three channels and one hovered time
Same CSP reasoning as the air-consumption chart, restated in the component so nobody reaches for
Recharts; the arithmetic lives in `lib/dive-profile.ts`, Vitest-tested.
Depth is inverted, surface-anchored and drawn as its fill alone (`text-teal`, `opacity-15`), with no
line along the edge, by the owner's choice. That makes it the one mark under the 3:1 bar the chart's
other marks clear, knowingly: no fill reaches 3:1 short of opaque, the depth axis and the crosshair
carry the reading, and the fill only says which side is water. Temperature gets its own domain,
since 21.6–21.9 °C is flat on a depth-wide axis. Every cylinder shares one pressure domain.
One hovered time, not index: channels are sampled independently, so a full-plot transparent `<rect>`
maps the cursor to an instant on the profile's millisecond axis once and each channel resolves its
own sample with `nearestSampleIndex`. Readouts are real readings, never interpolations.
`tooltipVerticalAnchor` pins the card to the plot's top or bottom edge, whichever keeps it off the
readings: card height depends on channel count, so offsetting from a point overflows the chart.
Keyboard scrubbing is out of scope. The `aria-label` uses `formatDurationHoursMinutes`, not `MM:SS`.
`--pressure` is a third theme-stable token in `globals.css`, grey.
## The profile's line breaks are derived from the series' own cadence
`segmentByTimeGap` (`lib/chart-path.ts`): never draw a line across data that is not there, or a
ten-minute transmitter dropout reads as a smooth pressure fall. `Dive_2025-03-08-1440.xml` has a 1
341-second hole in its pressure series.
The threshold is derived (`gapThreshold`), not fixed: cadence ranges from 1 s (Suunto Ocean
temperature) to 10 s (Suunto depth series), and the API's min/max downsampling stretches it
unevenly, so a fixed value either breaks every downsampled line into confetti or draws through a
real dropout. Three times the median delta sits above jitter and below any dropout worth showing;
`MIN_GAP_MS` (15 s) keeps a fast, regular series from breaking on the jitter in its own stamps.
## The profile card fetches on mount and needs no `onChanged`
`DiveProfileCard` renders nothing when the shown recording has no `profile` summary — the same call
as `DiveRecordingsCard`, the opposite of `GasUseCard`. A hand-logged dive has no samples, so there
is nothing for the diver to act on and no empty state worth showing.
It takes no `onChanged` callback. Deleting a recording's last file deletes the recording
server-side, and the page's `refreshDive` drops it from `dive.recordings`, which re-renders this
card; a callback would be a second mechanism for something that already happens.
The series come from `GET /dive/{uuid}/recording/{rid}/profile`, fetched separately from the dive
and keyed on the profile's `updated_at` as a `v` cache-buster, because they are tens of KB and the
detail response carries only the summary. A failure shows a muted line in the card rather than a
toast: nothing the diver did caused it and nothing they can do fixes it.
## A recording's profile is optional for the same reason as `recordings`
The profile summary rides on `recordings[].profile`; there is no `Dive.profile` member.
The series stay integer-scaled on the wire (depth in cm, temperature in hundredths of a degree,
pressure in tenths of a bar) and are divided in `toChannelSeries`. Divided, never multiplied by a
reciprocal: `1234 / 100` is the correctly rounded `12.34`, whereas `1234 * 0.01` is
`12.340000000000002`, exactly the noise the integer encoding removes. `PROFILE_CHANNELS` holds the
divisors and mirrors the API's `DEPTH_SCALE`/`TEMPERATURE_SCALE`/`PRESSURE_SCALE`; the two lists are
a pair.
## The support form posts to the API, and the page it lives on claims only what exists
`supportAPI.sendRequest` posts to `POST /support`; the API forwards it to its `CONTACT_FORM_EMAIL`,
and nothing here decides the recipient.
`CONTACT_EMAIL` is display only — the `mailto:` fallback in the error state — optional, with no
default: `contact@opendiving.app` would hand a self-hosted instance's visitors an address that
cannot see their server. Unset, `SupportForm` points at `FALLBACK_ISSUES_URL` in `lib/support.ts`,
since a broken form is this repo's bug. Set it only where the operator reads the mailbox.
`SUPPORT_CATEGORIES` in `lib/api/support.ts` mirrors `SupportCategory` in the API's
`schemas/support.py`; the backend 422s anything else, so adding a category changes both sides.
`supportSchema` duplicates the API's length bounds to fail before a round-trip.
The form prefills empty fields from `useAuth()`; `defaultValues` cannot, since the user arrives
after the auth bootstrap resolves. The page is a Server Component for `metadata`; the form and its
`IndexHero` heading are its only `"use client"` parts.
## `/signin` is a dedicated sign-in page, and carries where the visitor was headed
`/signin` (`components/auth/signin-page-content.tsx`) is a dedicated sign-in page: the shared
`AuthForm`, a "Sign In" heading, and no chrome (`NO_CHROME_ROUTES` in `app-shell.tsx`, like
`/auth/verify` and `/onboarding`). Sending signed-out visitors to `/` dumps a shared dive link or an
expired session onto the marketing page, where the form is one section among many and nothing
records the destination. There is still exactly one form and one entry point, not a password-based
`/signin`/`/signup` pair. On an `open`-mode instance the landing hero hosts its own `AuthForm`; on
`invite`-mode, the default, the hero holds the invite-request form and, outside a join link's or
invite link's `/join`, `/signin` is `AuthForm`'s only mount, which makes this page load-bearing.
`Header`'s signed-out state shows a coral "Sign in" button linking here, kept in the actions row at
every breakpoint rather than folded into the mobile menu: on a phone it is the most important thing
a signed-out visitor can do.
## The destination round-trips through `lib/auth-redirect.ts`
`useAuthGuard` redirects with `router.replace` to `signInHref(...)`, `/signin?next=<encoded path>`.
`sanitizeRedirectPath` rejects anything but a plain `/`-relative path, in and out, since the value
sits in storage between. It judges the parsed URL, not the spelling: a different origin (`//host`,
`/\host`) or a normalised pathname starting `//` (`/.//host`, `/a/..//host`, `%2e` forms) is
refused, and what passes comes back normalised. The guard reads `window.location`, not
`useSearchParams()`, which would force a `Suspense` boundary onto every page calling it.
The magic link returns on `/auth/verify` with no destination, so `AuthForm` calls
`rememberPostAuthRedirect` on every link request (resends included) and `/auth/verify` calls
`consumePostAuthRedirect`, which removes the key as it reads it. Google keeps its destination in its
own per-attempt record.
The store is `localStorage`: a mail client opens the link in a context whose `sessionStorage` is
empty. The entry is `{ path, expiresAt }` with a one-day expiry, not
`MAGIC_LINK_TOKEN_EXPIRE_MINUTES`, which the API can raise unseen. `signOut` clears it. Tests
install `test/memory-storage.ts` per suite in `beforeEach`, never in `vitest.setup.ts`, or state
leaks between files.
## Signing out lands on `/`, and gets there with a page load
`AuthContext.signOut` names its own destination, `/`, or the guard sends the leaving diver to
`/signin?next=%2Fdives`. It uses `hardNavigate("/")`, not `router.replace("/")`: a client-side
navigation from `signOut` loses to the guard's effect, whereas a page load cannot be cancelled by a
later `replaceState`. Signing out from a public page reloads to `/` too.
It navigates only on success and otherwise changes nothing and rejects. `POST /auth/logout` alone
blacklists the token pair and deletes the refresh cookie; after a failure a page load re-bootstraps
from that cookie onto `/home`, and clearing the user locally would paint "signed out" over a session
the interceptor rebuilds on the next 401. `Header` turns the rejection into a "Couldn't sign you
out" toast.
`hardNavigate` raises `isLeavingPage()` and `useAuthGuard` returns early on it, or the guard fires
an RSC request for `/signin?next=…&_rsc=…` before the document dies. It lives in `lib/navigation.ts`
so `AuthContext.test.tsx` can mock it.
## The Home page shows only what the app actually tracks
Nothing on the Home page (`components/home/home-page-frame.tsx`) claims what the app cannot back.
Each of the hero's figures is one the API derives (`services/dive_stats.py`, and `/user/trip-places`
for Destinations), Species Seen included. There is no quick-actions card; the one action worth
promoting, logging a dive, is the hero's only control. `SetupChecklistCard` is driven by real counts
(`/user/dive-stats`, `/gear-items`, `/certifications`, the last two fetched with `items_per_page: 1`
for `total_count` alone) and removes itself once all three are done.
## The layout is a flat stack, so the cards that can vanish leave no hole
The Home page is one `space-y-6` column with `SetupChecklistCard` and `PasskeyNudgeCard` as direct
children: `space-y-*` spaces rendered siblings, so a card returning `null` costs nothing, whereas a
wrapping `<div>` would leave its own gap on every day neither renders. The same reasoning rules out
a two-column grid whose sidebar sits empty for an established logbook.
The checklist is the body's first card, being the first thing a new account should see. The hero's
dive figures and the charts hide at zero dives, but not while the stats request is in flight — they
stay until the answer is in. Destinations stays: a trip can be planned before a dive on it is
logged.
## There is no `/profile` until there is someone else to show it to
There is no `/profile`. A profile page exists to be someone else's view of a diver, and the API
cannot serve one yet: the public profile endpoint is deliberately not built (see "Current-user
endpoints live on a bare `/user`, not `/user/me`/`/user/{uuid}`"), so a `/profile` could only read
the signed-in caller. That only duplicates the Home page — the same `getDiveStats()` numbers behind
the same `hasDives` gate, the same `RecentDivesCard`, and an identity header that is a read-only
copy of what `/settings` edits — at a second URL reachable only from the avatar dropdown.
When the public endpoint lands, the page comes back as `/divers/[username]`, written fresh: it takes
a username parameter, must not render `email`, and shares no fetch with the Home page.
## `/profile`: the Home page carries no certifications summary in its place
`CertificationsCard` and `certificationsByRecency` (`lib/certification.ts`) have no caller without
`/profile`, so they are removed rather than left exported; both are recoverable from git when
`/divers/[username]` wants them.
The Home page deliberately does not get the card in exchange. The header's bell already carries the
renewals, the half of the subject that needs the diver to act; "every c-card you hold, newest first"
is not an alert, and `/certifications` is one nav click away with images, dates and dialogs. A
read-only echo of a page in the nav is the duplication that took `/profile` down.
Before writing that ordering again: `GET /certifications` sorts by `certified_on` descending with
nulls last, tie-broken by uuid — not by when the row was entered, so a card with no date on it sits
at the bottom rather than wherever it was typed in. `/certifications` renders that order rather than
one of its own: it is a paginated table, and reordering one page client-side lies about the pages
either side. `/checkin` renders it too, so the two agree about which card leads.
## There is no Gravatar line; `/settings` shows the avatar itself
There is no Gravatar line: `/settings` shows the avatar itself, with the controls that change it
(see "Avatars are this instance's own, and there is no Gravatar fallback").
The username hint under the profile form states the rule the field enforces
(`USER_FIELD_SCHEMAS.username`: lowercase letters and numbers, unique), not "used in your profile
URL and for mentions": there is no profile URL and mentions are not a feature.
## Dive numbering: the suggestion follows the date, and only the diver renumbers
The API owns the rules (`services/dive_numbering.py` and the matching section of its
`DECISIONS.md`): `dive_number` is a label, gaps in it are as often deliberate as accidental, and
nothing renumbers a log automatically. The three sections that follow are what this repo does with
that.
## The new-dive form's number tracks `start_time`, not the last dive
`useSuggestedDiveNumber` refetches `GET /dives/next-number` whenever the start time changes, so a
back-filled 2019 dive is not offered #213, and importing a dive-computer file, which rewrites
`start_time`, moves the number with it.
The suggestion is written with `resetField`, not `setValue`. The hook stops suggesting once
`dive_number` is dirty, meaning the diver has taken the field over. `setValue` without `shouldDirty`
breaks that: react-hook-form recomputes `dirtyFields` against `defaultValues` on the next change to
any field, so a suggestion written over the default reads as dirty the moment the diver touches the
date, exactly when the hook must run again. `resetField(name, { defaultValue })` writes the
suggestion as the default, so "dirty" keeps meaning "the diver typed a number".
Create-only. Renumbering an existing dive because its date was corrected is the silent renumbering
the app avoids everywhere: the number may be in a paper logbook.
## The duplicate note is attached to a value, not rendered outright
`diveNumberNotice` is `{ forValue, message }` and `DiveFormFields` shows it only while the field
still holds `forValue`. A note about a number no longer on screen is worse than none, and the
alternative — `form.watch("dive_number")` in the page — opts that whole component out of React
Compiler memoization (`react-hooks/incompatible-library`), whereas the field's own render already
has the current value.
It is a `FormDescription`, never a validation error: duplicate numbers are a normal state while
back-filling, reconciled later with Renumber, so nothing about them may block a save.
## The numbering card describes; it doesn't scold
`describeDiveNumbering` never says "should" and never phrases a gap as a problem: a diver continuing
a paper logbook has deliberate gaps forever, and the card sits above their list forever too. It says
nothing about a tidy log, so Renumber has no entry point on one — #47–#212 cannot restart at #1,
bought against a card every settled log would carry forever.
`RenumberDivesDialog` mounts its form as a child rendered only while open, so each visit starts from
clean defaults without a `setState` in an effect. Its preview is tagged with the inputs that
produced it and every branch, including the confirm button, is gated on that tag matching the form,
so it can never apply a renumber the diver has not been shown. The change list renders in full
inside a scroll container: "and 180 more" hides exactly the rows someone checks against a paper
logbook.
## "Due today" is overdue, and the certification boundary is deliberately the other way
`serviceStatus` treats `next_due_on === today` as `overdue` (`days <= 0`) and `formatServiceDue`
says "Overdue (due today)" to match; `formatServiceDue agrees with serviceStatus` in
`gear-service.test.ts` pins the pair. The boundary is the API's: `service_status` in
`services/gear_service.py` uses `today >= next_due_on`, and the reminder digest in
`core/worker/functions.py` emails "overdue since …" about a schedule due that morning, so a
`days < 0` badge would disagree with the email.
`certificationExpiryStatus` uses `daysLeft < 0`, and that is not an inconsistency to harmonise: a
c-card is valid through its printed expiry date, whereas a service interval that has arrived has
arrived. The API's renewal reminder draws the same line: its `expiry_stage` in
`services/renewals.py` is the twin of `certificationExpiryStatus`, and
`CERTIFICATION_EXPIRING_SOON_DAYS` is named identically on both sides, like `SERVICE_DUE_SOON_DAYS`,
so one grep finds the pair.
## Service status is derived in the browser, and so is a timezone off the digest
The API returns only clock-stable facts about a schedule (`next_due_on`, `next_due_at_dive_count`,
`next_due_at_dive_time`, `last_service_on`) and never a computed status: `ServiceStatus` in the
API's `schemas/gear_service.py` is neither a stored column nor a field on any read schema, because
those routes are cached for 60 seconds and a cached status is wrong the next morning. Deriving it in
`lib/gear-service.ts` is the intended price of cacheable responses, not drift.
The browser computes "today" in the viewer's timezone (`todayIsoDate` uses local date parts on
purpose) while the reminder digest in `core/worker/functions.py` uses UTC, `User` having no timezone
column. A diver at UTC+13 can see "Overdue" up to a day before the email agrees. At a 30-day lead
time that is cosmetic, and the API-side fix (run hourly, gate on the offset of the most recent dive)
is not worth it. Documented, not fixed.
## One paging helper, and neither notifications list pages
`fetchAllPages` in `lib/api/client.ts` is the one `while (hasMore)` loop: page cap, abort signal,
dedup. Runaway is not the hazard (the API clamps `items_per_page` to 100); snapshots are. List pages
are cached for 60 seconds per `page`, so an insert between pages makes the boundary item arrive
twice — `keyOf` dedups it. The gap half, an item pushed to the next page by a delete, cannot be
fixed client-side; hence single-shot endpoints.
Truncation is `console.warn`ed, not thrown: a short list that looks complete is unexplainable later.
The abort signal stops the loop between pages, not the request in flight (`fetchAllPages` never
hands it to `fetchPage`); `isAbortError` tells unmount from failure.
Neither notifications list pages: `useNotifications` reads `/gear-service-due` and
`/certifications-expiring` (no `within_days`; see the API's `DECISIONS.md`), and both lists honour
the `truncated` flag via `TruncatedNote`, since a safety-adjacent list must not under-report
silently. `fetchAllCertifications` survives for callers needing whole records.
## Blob-wrapped error bodies are unwrapped in the interceptor, not at the call sites
Axios applies the request's `responseType` to error responses too, so a failed
`responseType: "blob"` request arrives with its JSON error body wrapped in a Blob and
`getApiErrorMessage` finds no `response.data.detail` (every binary route raises an ordinary
`HTTPException`, so the JSON body is always there).
`unwrapBlobErrorBody` runs in the response interceptor, the one place every rejection passes
through, which keeps every call site synchronous. A body that is not JSON is left as the Blob and
the caller's fallback is used; throwing inside the interceptor would replace the real error with a
parse error.
The fixture for that case in `client.test.ts` must not contain a NUL: git then classifies the whole
file as binary and GitHub renders no diff for it. It is the escape sequence `"\x89PNG-ish bytes"`,
which fails `JSON.parse`.
`useAuthedBlobUrl` returns the raw `error` alongside `hasError` so callers run it through
`getApiErrorMessage` themselves.
## The combobox will not clear a selection it cannot prove is gone
`CreatableCombobox`'s `onBlur` calls `commit()`, which matches the typed text against
`remoteResult.items` — empty until the debounced search lands — so tabbing through the Trip field
would commit a clear. `commitAction`, a pure function, decides: `searchedQuery` records which query
the current results answer, and until it equals the text being committed an empty result set is not
evidence, so the action is `keep`. A failed search leaves `searchedQuery` untouched (a 500 is not
evidence either), and a selection whose name the input still shows is kept. Once the server has
answered that exact query with nothing, the clear goes through.
`clampActiveIndex` re-derives the highlight every render, because the option list can shrink under a
stationary `activeIndex` (a narrowing search, a sibling pick changing `excludeIds`), leaving Enter
reading `undefined`. Out-of-range collapses to -1, not the last row, giving Enter its other safe
meaning: commit the typed text.
## Fetch states are one settled value, not a pile of booleans
`DiveProfileCard` holds a single `ProfileResult | null`, `null` meaning in flight, cleared in the
effect's cleanup — the shape `useAuthedBlobUrl` uses, avoiding a `setState` in an effect body.
Separate `profile`/`hasFailed` booleans that nothing reset made one failure permanent and showed a
stale chart after a re-import.
It branches on which failure: a 404 ("This dive has no profile") is permanent and gets no retry
button, because re-asking returns the same 404; a 401, 5xx or network failure gets one.
The Home page stats fetch shows an error with a retry rather than leaving its figures on "—": the
API returns zeroed stats for a diver with no dives, so anything landing there is exceptional.
`useInfiniteResource` guards races with a request-id ref: only the newest request may settle, so a
slower earlier page cannot overwrite newer rows or the next-page cursor, and superseded requests
leave the spinner alone.
## Assorted rules: `useDragSort`, `use-toast`, downloads, `DateTimePicker`, `AuthContext`
- `useDragSort` removes its window listeners on unmount: `handleMove`/`handleEnd` are closures
inside `startDrag`, so `endDrag` calls a remover stored in a ref.
- `use-toast` is vendored from shadcn/ui with two upstream bugs fixed: the store is read through
`useSyncExternalStore` rather than an effect subscription (see "The toast store is a real external
store, and a lesson in measuring against a dev server"), and `TOAST_REMOVE_DELAY` is 1000ms, not
`1000000`, which with `TOAST_LIMIT = 1` pinned every toast ever shown in `memoryState`.
- Downloads go through `lib/download.ts`, which appends the anchor (Firefox ignores a detached one)
and defers revoking the object URL, since Firefox and Safari read the blob asynchronously after
`link.click()`.
- `DateTimePicker` holds a typed time until a date is chosen rather than committing against
`new Date()`, never right for a back-filled dive; a cleared field stays empty instead of
`Number.parseInt(raw, 10) || 0` snapping it to "00".
- `AuthContext` memoizes its value and every method on it; a fresh `signOut` or `refreshUser`
identity every render re-ran every effect depending on them.
## `useResource` for the detail pages, and one delete flow for everything
`useResource` is the block the `[id]` detail pages and the dive edit page share: read `params.id`,
cast it, fetch, toast-and-redirect on failure, clear loading in `finally`. Five copies drift; an
unguarded one settles after unmount and toasts and redirects on whatever page the diver reached. It
holds the one `params.id as string` cast.
`refetch` re-reads without touching `isLoading`, so an archive toggle or file delete swaps one card
instead of blanking the page; failures there are non-fatal and do not redirect, since whatever
prompted the refresh already succeeded. The dive edit page seeds its form through `onLoaded`, held
in a ref so an inline arrow does not restart the fetch.
Detail-page deletes go through `useDeleteResource`, which formats the failure with
`getApiErrorMessage` itself, so a 409 ("this dive site is used by 3 dives") reaches the diver
instead of a generic "Please try again."
## `PaginatedResponse` lives in `lib/api/client.ts`, and dialogs share their error state
`PaginatedResponse<T>` lives in `lib/api/client.ts`, beside the client that produces it, and the
paginated `lib/api/` modules write it inline rather than aliasing it per resource; nothing in
`lib/api/` imports upward from `hooks/`, so a copy beside the hook would leave each module declaring
its own. `hooks/useInfiniteResource.ts` re-exports it for pages importing the type alongside the
hook.
`useDialogApiError` owns the `apiError` state of the create/edit dialogs, so the effect that resets
a form does nothing but `reset(...)`, which `react-hooks/set-state-in-effect` has no quarrel with.
The disables that remain outside the two hooks — theme mount, avatar fallback, the date picker's
external sync — are genuinely different patterns, not copies of one.
## `FormControl` only labels what it can reach
`FormControl` is a Radix `Slot`: it merges `id`/`aria-describedby`/`aria-invalid` onto whatever its
child renders. That works for a leaf `<Input>` and silently fails in two cases.
A custom component that never spreads rest props. `DiveStartTimeField`, `TripCombobox`,
`DiveSitesField`, `DiveGearField` and `VolumeCombobox` extend `FormControlSlotProps` from `form.tsx`
and spread it onto their own focusable control; otherwise `FormLabel`'s `htmlFor={formItemId}`
points at an id that exists nowhere.
A wrapper `<div>`. Fields with an icon or unit adornment keep `FormControl` inside the
`<div className="relative">`, around the `<Input>`: `<label for>` only associates with labelable
elements, so an id on the div gives the input no name. It only needs to be inside the `FormItem`.
The markup looks correct either way; this check surfaces it:
[...document.querySelectorAll('label[for]')]
.map(l => document.getElementById(l.htmlFor)?.tagName)
For a composite field the slot props go on the primary control (the date picker in
`DiveStartTimeField`) and the secondary one keeps its own `aria-label`.
## `--coral-solid` and `--teal-solid` do not exist: the brand accents are one hue each
`--coral-solid`, `--coral-text` and `--teal-solid` do not exist; the brand accents are one hue each
(see "The brand accents are their CSS named colours, and `--coral-solid` and `--teal-solid` do not
exist"). The arithmetic is the price: `--coral` as a filled button background under white text
reaches 2.3:1, which axe flags on the sign-in button.
Two other contrast rules hold. The landing page's stats strip does not use
`text-primary-foreground/70` on `bg-primary` (3.4:1 in dark mode). `text-primary` is not a link
colour (3.67:1 in dark mode, `--primary` being mid-grey there); in-copy links are a plain underline
inheriting the surrounding colour.
`npx @axe-core/cli --tags="wcag2a,wcag2aa,wcag21aa"` over `/`, `/signin`, `/support`, `/privacy` and
`/terms` is the check that covers contrast; the `code-quality` workflow scans only `/`, so the other
four are re-checked by hand after any change to `globals.css`.
## Metadata, and why every page is a Server Component
The root layout sets `metadataBase` (`/join`'s relative canonical needs it), a `title.template` of
`"%s – OpenDiving"`, the README's pitch, and only the site's own OpenGraph fields, so each page
previews as itself. `SITE_URL` lets a self-hosted instance name its own origin; the localhost
fallback is harmless, since only public pages unfurl.
Every `page.tsx` is a Server Component exporting its name as `metadata.title` and rendering a client
body from `components/`, the only way a client page gets a title. A page inside a section names
both: "#44 El Puertito – Dives – OpenDiving" (`pageTitle`). A name only the browser learns, a dive's
after sign-in, comes from `useDocumentTitle`. A React `<title>` in each page was rejected: Next
keeps left routes mounted under `<Activity>`, and their titles stay in the head. The landing page
leads with the product through `title: { absolute: ... }`, and is the one page worth indexing.
## Component filenames are kebab-case
Files under `src/components` are kebab-case (`auth-form.tsx`), exported components PascalCase
(`AuthForm`), and icons live in `components/icons/`. Nothing else states the convention, which is
how files drift out of it.
## One spinner component per shape, not per call site
`PageSpinner` takes a `variant`; `ButtonSpinner` covers the in-button case. Three styles otherwise
accrete: the `Loader2` icon, hand-written `min-h-[60vh]` copies of `PageSpinner`, and bordered CSS
rings like `<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />` in forms.
The `min-h-[60vh]` variant is deliberate: a page rendering below `AppShell`'s header and footer must
not reserve a full viewport, or the footer is pushed off the bottom of a page shorter than one.
`ButtonSpinner` uses `currentColor`; a hardcoded white is right only on a filled button and wrong on
the outline and ghost variants.
## Theme tokens, and the three colours that needed a second variant
Colours come from theme tokens, not raw Tailwind palette classes. Two raw uses stay on purpose: the
scrims' `bg-black/80`, the dialog overlay's and the mobile menu's (a scrim is black in both themes)
and the chart tooltips' `border-white/10` hairline over a dark surface.
A colour tuned to sit behind white text cannot also read as text. `--coral` and `--teal` carry no
second variant; the destructive pair does: `--destructive-solid` fills buttons, badges and toasts,
since white on `--destructive` is 3.6:1, while `--destructive` stays light enough to read as text on
the dark background.
`--success` replaces hand-picked `green-600`/`green-400` pairs. `--warning` is shared ("`--warning`
is a shared token, not the safety notice's private one"); the terms page's safety notice is one
user, deliberately not a `bg-muted` callout. `--muted-foreground` is 40%, not shadcn's 46.9%, so it
passes on `--muted` as well as `--background`.
In-copy links are underlined, not coloured: `text-primary` is 3.67:1 as link text in dark mode, and
colour alone trips axe's `link-in-text-block`. The one exception is the owner's: a map's credit chip
underlines its links on hover only (`Attribution`'s `underline={false}`); the same credit under a
form field keeps the underline.
## Outcomes go in toasts; `StatusMessage` is the documented exception
Outcomes are reported as toasts. `StatusMessage` is the inline exception, for a form whose result
the diver has to re-read while fixing it: the sign-in form's expired link, the email-change card's
"check your new address". It carries `role="alert"`; the state is signalled by icon, border and tint
while the body text stays `foreground`, because `text-destructive` on `bg-destructive/10` is only
3.3:1.
## Verifying colour work
Reading the accessibility tree catches labelling; contrast needs measuring, in both themes, against
the rendered value: `bg-destructive/10` composites over whatever is behind it, so the painted
background is not the token's colour.
Switch themes the way the app does. Toggling `dark` on `documentElement` and reading
`getComputedStyle` in the same script lies: the custom property re-resolves while elements already
on the page keep painting the other theme's `color`. Emulate `prefers-color-scheme` and reload — the
Browser pane's `resize_window{colorScheme}` or Playwright's `colorScheme` — then measure; the tell
is that unrelated body text changes colour too.
`npx @axe-core/cli` covers the public pages. Authenticated pages have no session under it, so sweep
them with an in-page script that walks every text node, resolves the nearest opaque background and
applies WCAG's large-text threshold. A scan run while the dev server is mid-recompile reads a
half-updated stylesheet; re-run before believing a sudden spike.
## Component and hook tests run on Testing Library, and coverage says where the gaps are
`@testing-library/react`, `/dom`, `/jest-dom` and `/user-event` are dev dependencies;
`vitest.setup.ts` registers jest-dom's matchers plus the browser APIs Radix needs on mount that
jsdom lacks (`matchMedia`, `ResizeObserver`, `scrollIntoView`), or a dialog throws before its
assertion.
Hook tests each pin a bug: `useDeleteResource` shows the API's own message on a refused delete,
`useInfiniteResource` ignores a late superseded response, `useResource` stays silent once unmounted,
`AuthContext` clears the user on session-expired even when sign-out fails.
Mock `useRouter` with a hoisted object: `useResource`'s fetch effect depends on the router
reference, so a fresh object per call loops forever. `vi.mock` factories are hoisted above every
statement; shared spies come from `vi.hoisted`.
Coverage scopes `lib/`, `hooks/`, `contexts/`, `components/` and `app/`, excluding vendored
`components/ui/**`. Global thresholds sit just under current values as a ratchet; per-directory
floors on `lib/`, `hooks/` and `contexts/` hold the tested layers. Raise them as coverage grows;
never lower one. CI runs the suite without coverage, so the floors hold only when
`npm run test:coverage` runs.
## The instant-navigation tests run against fixtures, not the API
`e2e/` asserts what paints at a click on three navigations, through `instant()` from
`@next/playwright`. `page.route()` answers every `/api/v1` call from fixtures, the auth bootstrap
included, so the suite needs no API container and CI runs none. A test holds the destination's
endpoints across the scope: the pages are Client Components fetching over `XMLHttpRequest`, which
`instant()` does not gate, so without a hold the data can land fast enough to pass for a frame.
`exposeTestingApiInProductionBuild` is a compile-time define, so `playwright.config.ts` makes a
production build of its own with `NEXT_EXPOSE_TESTING_API=1`. Nothing else sets that variable and
the `Dockerfile` has no `ARG` for it, so a published image cannot expose the API.
_Rejected:_ standing an API up in CI, which is launch-sized; and reusing `ci.yml`'s build, which
carries no testing API and so cannot be driven at all.
## The toast store is a real external store, and a lesson in measuring against a dev server
`useToast` uses `useSyncExternalStore`, not the `useState` + `useEffect` subscription shadcn/ui
ships. The store is module-level so `toast()` is callable outside React. The effect-based version
drops toasts: `dispatch` notifies whoever is in `listeners` at that instant, the subscription is set
up in an effect, and `<Toaster />` is a later sibling of `<AppShell>` in the root layout, so a toast
raised from a page's own effect reaches nobody. `useSyncExternalStore` reads the snapshot during
render and re-checks after subscribing.
`TOAST_REMOVE_DELAY` is 1000ms, all the exit animation needs; on-screen time is Radix's, 5s by
default.
Measure toast timing from an event you control: a dev-server navigation that compiles the route
hands control back after Radix has auto-dismissed. Break the request on an already-loaded page,
sample from `t=0`, and print `performance.now()` in the first sample. A result implicating something
as inert as a `setTimeout` constant is a measurement error first.
## Both charts' legends are the control for what they plot
Every mark on both charts is toggled from its legend entry, which names it and carries its swatch.
Entries are `<button aria-pressed>`, not checkboxes; the label stays the mark's name in both states,
and a hidden mark's swatch turns muted. The profile's channels and markers would wrap to three lines
on a phone, so its legend is a menu on the card's title row, grouped by graph. Hiding everything is
allowed and replaces the plot with a one-line message.
The profile chart toggles by channel, not plotted line: both pressure lines share `--pressure`, and
"tank pressure" survives across dives where "gas 2" does not.
Labelled axes follow what is plotted, so `PlottedChannel` carries the `domain` it was scaled against
rather than the axis recomputing one — wrong for pressure, where every cylinder shares one domain.
The `aria-label` uses visible channels too.
The gas chart's box and its median tick share one toggle, and the legend names the bin it summarizes
by (`BIN_LABELS[unit]`) whether or not the mark is drawn: it describes what the toggle turns on.
## Remembered selections use `useSyncExternalStore`, and the handler uses the updater form
`lib/chart-series-view.ts` is the storage half, generic over series keys. `localStorage`, as in
`chart-period-view.ts`: the selection must survive the tab closing and work from the bare `/home`
and `/dives/{uuid}` URLs.
The read is `useSyncExternalStore` with a server snapshot of `null`; the snapshot stays the raw
string because `useSyncExternalStore` compares with `Object.is` and a freshly parsed array loops
forever.
`parseSeriesVisibility` filters unknown keys: a key this build no longer plots is stale, not
corrupt. A stored `[]` restores as "hide everything"; only-unrecognised keys fall back to `null`.
The profile chart also intersects with the channels this dive recorded, ignoring an empty result.
The toggle handler uses `setChosen(current => ...)`: computed from the render's `visible`/`marks`,
two toggles in one batch both see the pre-click value and the second undoes the first. The storage
write is a `useEffect` keyed on the selection, so the updater stays pure.
## The hand cursor on buttons is restored once, in the base layer
Tailwind v4's Preflight drops v3's `button, [role="button"] { cursor: pointer }`, leaving
`<Button asChild>` around a `<Link>` with a hand (it renders an `<a href>`) and the same component
as a real `<button>` with an arrow. The rule is restored once in `@layer base`, widened to native
controls, instead of per-component `cursor-pointer` patches:
```css
button:not(:disabled),
[role="button"]:not(:disabled),
select:not(:disabled),
input:where([type="checkbox"], [type="file"]):not(:disabled) {
cursor: pointer;
}
```
`[type="radio"]` has no call site; `[type="checkbox"]` is for the invite queue's boxes (a switch is
a `<button>`). In `@layer base`, every `cursor-*` utility outranks it: Radix's
`role="menuitem"`/`role="option"` items keep their explicit `cursor-default`, and
`disabled:cursor-not-allowed` and `cursor-grab` still apply. `:not(:disabled)` is for the inputs;
`Button` already sets `disabled:pointer-events-none`.
`<label>` is deliberately out: a bare `label` selector would put a hand over text-input labels.
Labels beside a boolean control carry an explicit `cursor-pointer`; count them with
`git grep -n cursor-pointer -- "*.tsx"`. The Fields dialog's switch labels do not, which is the one
uneven spot.
## The README screenshots are generated, at one width that is a breakpoint
`scripts/screenshots.mjs` retakes every image in `docs/screenshots/`;
`npm run screenshots -- you@example.com home` retakes only those. `playwright-core` (with
`executablePath`), not `playwright`'s browser download.
1024px wide, where `lg:grid-cols-3` stops stacking the detail pages. Height lands on a card
boundary, measured by `cutBelow()` before the shutter: `CUT_BELOW` names a card; the gear page's
`HEIGHT` is the one literal. `deviceScaleFactor: 2`; PNGs are twice their frame. Selectors are
scoped by card `<h3>` (`chartCard()`) and match shared label halves as a regex.
Subjects are ranked from the log using the access token a Playwright `request` listener lifts off
the app's requests; a second magic link is rate-limited and `/auth/refresh` rotates the cookie.
`DIVE_UUID` names the dive when ranks tie; an unknown uuid or a sample-less dive throws before the
first shutter rather than falling back.
## The dive shot is ranked by recordings
The dive subject is ranked, not filtered, by how many of its recordings carry samples. Two is the
picture worth having (the _Recordings_ card lists both computers and `DiveProfileCard` shows its
switcher), one is an honest photograph, and zero is the only disqualifier: the profile card renders
nothing without samples. Ranking costs a `GET /dive/{uuid}` per candidate; `recordings` is
deliberately absent from the list schema.
A requested shot with no subject throws before the first shutter press, so a run leaves no
half-updated set behind; a shot nobody asked for stays silent (`wanted()`).
`CUT_BELOW["dive-detail"]` names the _Recordings_ card the image exists for, not the card that comes
out last. The committed image shows no switcher: `DiveProfileCard` draws it only once a second
recording has a profile. Shooting a different account is no way out; the README images are one
product tour.
## A cut that cannot slice a card
`cutBelow()` returns the first height below the anchor at which no card is still open: every card
that begins above it also ends above it. On the two-column dive page the main column is mid-card
when the sidebar's next card starts, so no sibling's top yields a clean frame. The row gap comes
from the anchor's neighbour, either side.
It iterates: one sweep over the cards open at the anchor's bottom misses the sidebar card beneath
it. Each pass that moves the cut has found a card the previous one could not see, so the loop is
bounded by the card count.
The frame can end well below the card that named it; that distance is the subject's, not the cut's.
A one-column page settles on the first pass, seeded from the neighbour's top, at exactly the
single-line answer; 3px is a sliver of the next card.
## The README row's right-hand pair is framed to come level with the dive shot
`gear-item.png` stacks over `dive-site.png` beside `dive-detail.png`, so the pair is framed against
the row rather than against its own pages. `gear-item`'s `HEIGHT` is written down: 697 ends in the
gap below the first entry of the _Service_ card's history, so that entry is whole, and
`refuseSlicedRow()` throws before the shutter if the account's schedules have moved a row onto it.
`dive-site` takes what is left: `dive-detail.png`'s height, read off its header, less `gear-item`
and `README_BR`. It ends wherever that lands, through a dive card if need be.
`README_BR` is 13: a `<br>` between stacked images adds 6px of unscaled descent on GitHub, at a
column about 459px wide against images shot 1024 wide. The balance is exact at that width and a few
pixels out at others.
The subject is the placed site with the most dives; a position is required, since a site without
coordinates has no map at the head of its page. `/dive-sites?sort=dive_count` is paged (capped
at 100) to the first placed site.
## `visit()` fails loudly when a navigation lands on `/signin`
`visit()` throws when a navigation lands on `/signin` — "signed out on the way to /home" — rather
than letting a run quietly produce four screenshots of the sign-in form. It costs nothing and
catches any future auth regression, not one in particular.
## The same script writes the product repository's copies
`opendiving/opendiving` renders copies of these images on its front page and cannot retake them, so
`shot()` writes both trees from one shutter press: `docs/screenshots/` here and
`$PRODUCT_DIR/docs/screenshots/`, defaulting to `../opendiving` in the sibling shape `API_DIR` uses.
An absent clone is a printed note, not a failure. One press, not two: the pages are live, and
writing the buffer twice is what makes the copies identical.
A new shot reaches that repository as a file, not a picture on its page; that README names what it
renders. A capture in a git worktree resolves `../opendiving` inside `.claude/worktrees/` and writes
one copy; the next full-checkout run makes the other. The files are byte-identical wherever both
exist; the front pages may differ until that README is edited.
Rejected: hotlinking this repository's raw URLs from the product README, which breaks on a rename.
The script commits nothing over there.
## "Due soon" is never `secondary`, because `secondary` is invisible on a card
`serviceStatusBadgeVariant` never maps `due_soon` onto `secondary`. Every place the chip renders
(gear cards, gear detail card, the bell's service-due list) sits on `--card` or `--popover` in dark,
and dark `--secondary` against it is a near-neutral grey a few lightness points off its surface:
1.2:1, with no border. The label passes every text-contrast scan, so a text-node walker never flags
it; non-text contrast is the check that fails. It also made "Due soon" identical to "Rented", a fact
about an item rather than a status.
`--warning` is dark and slightly brown in light mode (`32 92% 27%`) because it carries white text
and a mid-amber only reaches 3.9:1 under white; in dark mode it is `38 95% 62%` with near-black
text. Both halves hold as text on a card and as a fill under its foreground, because the token is
used both ways. `lib/gear-service.ts` does not touch it.
## Dark `--secondary` is 22%, because every secondary chip renders on a card
Dark `--secondary` is 22%, nine points above `--card`, because every `secondary` chip (the count
chips on dives, sites, trips, certifications, gear and gear sets; "Rented" in `dive-detail-main` and
`gear-item-multi-select`; Home's `doneCount/steps` chip) renders on a card header, where three
points of lightness is no background at all.
Lightness rather than a border: the base `Badge` carries `border` and `secondary` sets
`border-transparent`; a visible border makes it look like `outline`, which sits three rows away as
"In service". Light `--secondary` (`210 40% 96%`) keeps its 4-point gap because it separates by hue;
the dark palette is 4%-saturated system grey with no hue to spend. 22% is where the chip stops
disappearing before it reads as a button.
`bg-secondary` is also the selected segment of the gas-consumption time-range control and the unused
`Button` `secondary` variant. `--muted` stays at 16%: a large-area wash at 22% reads as a panel.
## The service scale is three brand fills, and `--warning`'s amber would be a fourth accent
`serviceStatusBadgeVariant` returns `"teal"` for `ok`, `"coral"` for `due_soon` and `"destructive"`
(`bg-destructive-solid`) for `overdue`; `warning` and `outline` are out of it. The scale reads by
hue, not weight: an outline beside two fills reads as an absence, and "In service" is a verdict.
The palette has two accents (`--coral` 16, `--teal` 180) and a chart grey (`--pressure`);
`--warning`'s amber on a status chip would be a third hue for one badge. Coral and `destructive` are
six degrees apart, so they separate by lightness.
`--coral-foreground` and `--teal-foreground` are both white so the three chips share one label
colour. Coral pays 2.50:1 on the label, under AA; the fix if wanted is near-black on
`--coral-foreground` alone. Measured, both themes:
| Surface | Light | Dark | Bar |
| -------------------------------------- | ------ | ------ | ----- |
| Badge label on the coral fill | 2.50:1 | 2.50:1 | 4.5:1 |
| Coral fill against `--card` | 2.50:1 | 6.51:1 | 3:1 |
| Badge label on the teal fill | 4.77:1 | 4.77:1 | 4.5:1 |
| Teal fill against `--card` | 4.77:1 | 3.41:1 | 3:1 |
| Badge label on `--destructive-solid` | 5.61:1 | 5.87:1 | 4.5:1 |
| `--destructive-solid` against `--card` | 5.87:1 | 2.77:1 | 3:1 |
`--destructive-foreground` is redeclared under `.dark`; the brand foregrounds are not. The fill
misses are accepted: a saturated fill separates by hue. Watch the coral fill if light `--card` stops
being white.
## A bell row's chip sits on the title's line, and its qualifier under it
`NotificationRow` is a grid: title and chip on one line, subtitle and qualifier on the next, the
qualifier centred under a right-aligned chip. The qualifier drops the chip's own word — "by 40
days", "on Oct 23, 2026" — through `formatServiceDueQualifier`, which shares `formatServiceDue`'s
arm selection so the two cannot disagree about which arm is urgent.
`certificationExpiryBadgeVariant` returns `destructive` / `coral`, never `secondary`: grey beside
coral reads as not a status. A healthy certification has no status and no chip.
Chip widths: service `min-w-24`, certification `min-w-28`; `courseStatusBadgeVariant` keeps its own
vocabulary, courses sharing no screen with gear or certification chips. All carry
`whitespace-nowrap`, so a longer label overflows the pill visibly rather than growing a second line.
## One card-header shape: `space-y-1.5` only reaches `CardHeader`'s _direct_ children
`CardHeader` is a `flex flex-col space-y-1.5` column, and `space-y-*` is a `> * + *` selector, so
the 6px gap above the description exists only while the description is a direct child. A card whose
header carries a control puts the title and the control in one row and the description under it:
```tsx
<CardHeader>
<div className={CARD_TITLE_ROW}>
<CardTitle …>…</CardTitle>
<Button size="sm" className={CARD_TITLE_ACTION}>…</Button>
</div>
<CardDescription>…</CardDescription>
</CardHeader>
```
The control sits on the title's line and wraps under the title, never beside the description; why it
is lifted rather than centred is at `CARD_TITLE_ROW` in `card.tsx`. The title and description
wrapped together in a `<div>` beside the control eat that gap and centre the control on the block
rather than on the title.
Card-title icons are `gap-2` on the title, never `mr-2` on the icon, and inherit the title's colour:
`text-primary` is a mid-grey in dark mode, dimmer than the description beneath. `contact`'s
`text-success` shield and `text-destructive` heart stay, being semantic.
## The dive page's gear list is a table, and its columns lead with Type
`DiveDetailMain`'s Gear card is a `Table` rather than a `<ul>` of `gearItemLabel(item)`: brand and
name glued into one string give the eye nothing to scan down at seven items.
Columns are Type, Brand, Name, deliberately not led by the name as `/gear`'s cards are. `/gear`
lists things you own, looked up by name; the dive page is a kit list read by type ("what suit? what
computer?"), and the closed vocabulary gives a short repeating left edge. Splitting brand out
retires `gearItemLabel` here; it stays where a one-line label is needed (picker, sets card,
archive/delete confirmations). The Name cell keeps the link and the Rented/Archived badges.
The whole Type column is `text-muted-foreground`: the type is the question, the name the answer, and
a full-strength category column competes with the names. Brand stays full strength, being part of
the item's identity.
## The activity chart is bars over the same three scopes as the gas chart
`DiveActivityCard` mirrors `GasUseCard`'s header, stat row, stepper, remembered view and
All/Year/Month toggle over `lib/chart-period.ts`.
Bars with a zero-anchored axis: a count has no meaning between values, and bar height is the
quantity. `countDomain` sits beside `niceDomain`, which is deliberately not zero-based and whose
1/2/5/10 ladder includes 2.5.
All is one bar per calendar year, Year twelve months, Month every day, as on the gas card. The
default is `all`, not `year`: a bar per year is legible at any career length.
The anchor is the start of a day with diving in it, so `periodRange`, `periodLabel`,
`availablePeriods`, `stepPeriod` and `resolveAnchor` live in `lib/chart-period.ts`.
`GET /user/dive-activity` returns one row per day and `activityBars` sums into months and years
client-side, so scope switches need no request (reasoning under _"Dives-per-day is counted in
Python"_ in `opendiving-api/DECISIONS.md`). `X_LABEL_SPACING` gives a label 33 units for years, 26
for months and 21 for days: twenty years, all twelve months and every day at the design width, fewer
on a phone.
## Every chart control names its own card, because the two cards draw the same row
Both cards render on `/home`, `Card` is a plain `div` with no role, and a screen reader's controls
list is flat names with no heading context, so identical toggles give four arrows all reading
"Previous period with dives". Each `aria-label` leads with its card:
`Dive activity: previous period with dives`, `Gas consumption: time range`. `screenshots.mjs` scopes
by the card's heading for the same ambiguity.
The period dropdown is `Gas consumption: period`; a `<select>` announces its value after its name.
## Empty buckets are the point, and the ceiling comes from the whole logbook
`activityBars` fills every day of the month, every month of the year and every year between first
and last dive. Plotting only buckets with diving spaces three trips evenly and says the year was
busy throughout; gaps are what make a season read as a season and tell a liveaboard from four
weekends. Nothing is drawn before the first dive or after the last, where the answer is "no data".
`barCeiling` scales the y axis to the tallest bar the scope can produce across the whole logbook,
not the period on screen; bar height is the quantity, and a per-year axis would draw a four-dive
August as tall as a forty-dive one.
Empty periods are not offered: `availablePeriods` lists only years or months containing dives and
`stepPeriod` skips the fallow ones, for both cards.
## Activity chart: What the bars can't be, and what that costs
The bars are not links, so the svg is `role="img"` (like the profile chart) rather than the gas
chart's `role="group"`. A `role="img"` label cannot state every bucket, so the figures follow the
chart as an `sr-only` list, one entry per bar, pluralized: a one-dive day is common and "1 dives"
reads badly aloud.
The hover target is the whole column, not the bar, since a quiet month is a few units tall and an
empty one has no bar; the bar is `pointer-events-none` so it cannot steal its column's hover.
`barPath` rounds only the top corners, because `<rect rx>` lifts the bar off its baseline.
The change figure is in dives, not percent, and uncoloured: a light year can be a house move rather
than a slump. `ChartStat` is shared with `gas-use-card.tsx`; the two cards stack, so a drifting
label size reads as a rendering fault.
## The two chart cards stack, and activity leads
Below 560px each plot narrows its viewBox instead of shrinking, so its axis text stops at 8.6px
(`fittedChartWidth`). `lg:grid-cols-2` on Home's `max-w-6xl` gives 482px (546px at `max-w-7xl`), and
two things break: the axis text halves (16.6px to 8.6px), and each header grows a row as its period
stepper drops below the title. `RecentDivesCard`/`RecentTripsCard` pair up fine below: their content
reflows instead of scaling.
Dive activity leads because every logbook fills it; gas consumption needs dives that recorded
pressures and an average depth.
## `npm run format` covers the docs at the repo root, not just `src/`
The format scripts take `"**/*.md"` beside the code globs (`src/**`, `e2e/**`,
`playwright.config.ts`), because no markdown lives under `src/` and the repo's markdown
(`DECISIONS.md`, `README.md`, `CONTRIBUTING.md`, `CLAUDE.md`, `CODE_OF_CONDUCT.md`) is at the root.
Recursive rather than root-only so a later `docs/` is covered. Prettier skips `node_modules/` on its
own and `.prettierignore` handles `.next/` and `coverage/`. The `*.md` override in
`.prettierrc.json` (`printWidth: 100`, `proseWrap: "always"`) matches opendiving-api's mdformat
width, so the two `DECISIONS.md` files look alike.
`code-quality.yml` runs `npm run format:check` rather than repeating the globs; see "The Prettier
check fails the build, and the axe scan stays a report" for its failure mode. Prettier also rewrites
`*emphasis*` to `_emphasis_` and `*` bullets to `-`; `git blame` on a reflowed line points at the
reflow, so blame its parent or use `git log -L`.
## Breathing-gas maths lives in `lib/dive-mixtures.ts`, client-side, and is only ever a label
`gasName`/`mod`/`endDepth`/`ead`/`ppO2AtDepth`/`modWarning` live in `lib/dive-mixtures.ts`, not a
`lib/dive-mix.ts` one hyphen away. Client-side because the decisive input is live form state the API
cannot see; `services/dive_gas.py` derives only from stored columns.
Names round to whole percent (`gasName(32.4, 0)` is `"EAN32"`); `O₂`/`He` hold the exact fractions.
An impossible mix gets no plausible name: `gasName(50, 60)` returns `"O₂ 50% / He 60%"`, `oxygen: 0`
falls back, either fraction `null` returns `null`. Air gets neither END nor EAD; helium mixes get
END, nitrox EAD.
`modWarning` returns two sentences, 1.4 a planning note and 1.6 never appropriate;
`PPO2_WORKING`/`PPO2_DECO` are parameters, not a user setting. Callers holding a dive use
`diveModWarning`: one cylinder, `max_depth` applies with both thresholds; several, only "the
deepest-capable gas cannot reach `max_depth`" is reported, at 1.6 only.
The warning is spelled out under the cards, not in a `title` tooltip. `text-warning`, not
`text-warning-foreground`, which is the white on `bg-warning`.
## `diveMixtureSchema` refines `o2 + he <= 100`, mirroring `ck_dive_mixture_oxygen_helium_sum`
`diveMixtureSchema` carries a refine mirroring `ck_dive_mixture_oxygen_helium_sum`, so a 50/60
trimix fails as a field error rather than a 500. It reports on `path: ["helium"]`: helium is the box
filled second on the trimix entries where this fires, so the message lands under the field being
looked at.
## The Exposure card renders stored numbers and derives nothing
`components/dives/dive-exposure-card.tsx` shows the primary recording's CNS, OTU and surface
pressure unchanged; each recording carries its own, and the recordings card lists every one. CNS and
OTU are the output of whichever algorithm the device ran; a "corrected" figure disagreeing with the
diver's wrist is worse than useless. The fields are read-only; the API keeps them off its
create/update schemas.
Start → end is shown, a missing half as an em dash. The card renders nothing when the primary
recording has none of the three: a diver never had the option to enter them. It is titled "Exposure
& Pressure" because a lone surface pressure is common and gating on CNS/OTU would drop it.
Past 100% CNS is `text-warning` emphasis on the number, not a warning sentence. The second channel
is an `sr-only` span, not an `AlertTriangle`, because a visible icon is visible advice; it is
carried as `alert?: string` on `Reading`. `dive-exposure-card.render.test.tsx` asserts it through
the accessible name.
## A recorded ppO₂ limit moves the MOD, and pointedly not the warning
`DiveMixture.po2_limit` is what the computer planned a gas to (a Suunto writes 1.4 on back gas and
1.6 on the deco bottle), so `ppO2Limit()` feeds it into every displayed MOD, falling back to
`PPO2_WORKING`. It does not reach `modWarning`/`diveModWarning`, which judge against
`PPO2_WORKING`/`PPO2_DECO`: a limit from a file is the edit that turns an over-MOD warning into
silence, and a cylinder recorded at 2.0 would become unwarnable. `OxygenFractions` declares
`po2_limit` and never reads it, so the answer is visible in the type.
Every tank card states the limit its MOD was computed at, `MOD @ 1.4`, an unrecorded limit counting
as the 1.4 default.
`DEFAULT_MIXTURE` leaves `po2_limit` blank with placeholder `1.4 (default)`, so a hand-added
cylinder never claims a limit the diver did not choose.
## `gas_number` round-trips through the form untouched, and 0 is a real value
`DiveMixture.gas_number` is on `diveMixtureSchema` and `normalizeMixtures` with no input: it is the
API reader's cylinder label and the join key to that cylinder's pressure curve on the profile chart.
In `mergeMixture` it needs `??`, not `||`: the reader labels the first cylinder 0. The zod rule is
`min(0)`, mirroring `ck_dive_mixture_gas_number_non_negative`.
`role` is a `<select>` with "unset" as a real option. Its empty value stays `""`, converted to
`undefined` by `normalizeMixtures` at the edge; react-hook-form re-displays a field's default
whenever the value resolves to `undefined`, so `e.target.value || undefined` snaps an imported
`"deco"` back on clearing. Any optional form field needs a non-`undefined` empty value, and `role`
is `z.union([z.literal(""), z.enum(GAS_ROLES)]).optional()` for that reason.
`MixtureFields role input > lets an imported role actually be cleared` pins it.
## `mod()` returns null below the surface, where `end`/`ead` floor at zero
A diver-editable `po2_limit` across the schema's `[0.4, 2.0]` band makes `mod(50, 0.4)` −2.0 m, and
both `diveMixtureSchema` and `ck_dive_mixture_po2_limit_range` accept the inputs. `mod()` returns
`null` for a strictly negative result, not the `Math.max(0, …)` that `endDepth` and `ead` use: 0 m
is a real END or EAD (a rich mix in shallow water is equivalent to the surface), whereas "MOD 0 m"
reads as a depth the gas may be breathed at, the opposite of the truth. Both call sites render `-`
for `null`. Exactly 0 stays a number: `mod(40, 0.4)` is 0 m.
## The API sends `null` and the form schema wants `""`, so `toDiveMixtureInput` converts at the boundary
`DiveMixtureBase` declares optional fields `X | None` with no `exclude_none`, so an unrecorded field
arrives as an explicit `null`. `DiveMixture`'s optional fields are `| null` for that reason, and the
same rule covers `volume`, `oxygen` and `helium` (see _"A cylinder may record a mix with no vessel,
and three fields are `number | null`"_). A `null` reaching `zodResolver` fails silently:
`handleSubmit`'s valid callback never fires, so no request, no toast, no error, and `gas_number`,
having no input, has no `FormMessage`.
`toDiveMixtureInput` in `lib/validations/dive.ts` is the one API→form conversion, beside
`normalizeMixtures` for form→API. Field-by-field, no spread: a spread silently admits whatever field
is added next. A fixture written the way the API serializes it, with the `null`s spelled out, is
parsed through `diveMixtureSchema` and round-tripped through `normalizeMixtures`. The rule: a field
arriving from the API needs a boundary conversion the moment the form gives it a sentinel empty
value.
## The deco ceiling rides depth's axis, and hiding depth hides the water, not the scale
`ceiling` is a fourth `PROFILE_CHANNELS` entry with `scale: 100` and `inverted: true`, mirroring
`CEILING_SCALE = DEPTH_SCALE` in the API's `schemas/dive_profile.py`.
`depthDomain(depthValues, ceilingValues)` computes one domain from both channels whether or not
either is plotted: an axis of its own could draw a 3 m ceiling below a 40 m depth, and an axis
depending on what is visible moves under the diver's eyes when the ceiling is toggled.
`leftChannel` is depth, or the ceiling if depth is off; `depthChannel` alone gets the teal fill.
The forbidden zone is shaded, from the surface down to the ceiling, drawn over the depth fill. One
region per segment from `segmentByTimeGap`: a gap here is a stretch with no obligation, and spanning
it would shade a no-decompression descent. The line is dashed because it is the only curve never
measured, a computed limit rather than a reading.
## Adding a channel needs a remembered-selection key bump; removing one does not
`DIVE_PROFILE_SERIES_KEY` carries a version suffix (`-vN`). `parseSeriesVisibility` filters a stored
selection down to the keys the build plots, right for a key that has gone and wrong for one that has
arrived: a selection naming `depth`, `temperature`, `pressure` restores cleanly with the ceiling off
and a legend entry implying the diver chose that. The rule: adding a key to a
`parseSeriesVisibility` list needs a key bump; removing one does not.
The ceiling is a toggle rather than drawn with depth unlisted, because _"Both charts' legends are
the control for what they plot"_ forbids a legend entry that is not a control.
A bump owes no `removeItem` on the old key: that starts a list of dead names carried and grown on
every bump. The device-memory switch clears orphans by prefix, walking live storage rather than a
list; see _"One switch against every remembered preference on this device"_.
## `--ceiling` is one value for both themes, like `--teal`, `--coral` and `--pressure`
`--ceiling` is a single `0 80% 55%`, never redeclared under `.dark`, like `--teal`, `--coral` and
`--pressure`: a per-theme pair is tuned twice and drifts. Computed contrast is 4.3:1 against the
light card and 3.8:1 against the dark 13% one, clearing the 3:1 for a graphical object. The web
`accessibility-check` job would not catch a failure here: it runs axe against the landing page only,
as a report. Not `--destructive`, a pair tuned for text and labels on a fill.
Hue 0 sits close to `--coral`'s 16; amber would buy separation, but red is what every dive computer
uses for a ceiling. Lightness and saturation separate them (pale salmon at 66% against saturated red
at 55%), plus the ceiling being the only dashed line over a shaded region. Colour does the least of
the work, which is what makes the overlap acceptable.
## Markers are annotations, so they have no axis
`events` render as a tick on the x-axis with a glyph on top at `x(t)`, the anchor surviving depth
toggled off; not a fifth `PROFILE_CHANNELS` entry (switch: _"The markers have a legend switch, and
it is not a fifth channel"_).
Three glyph families, not five. Colour marks only what joins elsewhere: a gas switch is `--pressure`
for its `gas_number`. With pressure grey that sits close to the other markers' `--muted-foreground`,
and the diamond is what tells a gas switch apart. A stop is not the ceiling's red: both types are
the Suunto app's `Notify` values as the `divejson` package's reader maps them (`STOP_TYPES`), a
recommended pause.
`describeEvent` passes an `other`'s label through unchanged; `gas_number` is tested with `== null`.
`label` is free text, so its line wraps: `max-w-56` in the hover card, and in the full-screen panel
`w-0 min-w-full`, which wraps it inside the column the labels set rather than widening it.
`glyphFor` takes a `string` with a grey-circle fallback and `describeEvent` has a `default`:
`ProfileEventType` is closed only here, and an unknown `EVENT_GLYPHS` key would otherwise trade the
dive page for `app/error.tsx`.
Resting opacity is 0.9; 0.55 puts `--ceiling` and `--pressure` under 3:1.
## The crosshair quotes a channel only within its `gapMs`, the threshold that breaks its line
`nearestSampleIndex` clamps at both ends, so a naive readout quotes a channel across stretches it
has no samples for — on the ceiling that invents a deco obligation.
`sampleIndexAt(t, at, maxDeltaMs)` is `nearestSampleIndex` bounded by the channel's `gapMs`, the
same threshold that breaks its line, so plot and readout cut the same stretch; beyond it the channel
keeps its row in the card with a dash, as its line keeps its place on the plot with nothing drawn.
`PlottedChannel` carries `gapMs`, and every call site goes through one local `runs(t)` returning
segments and threshold together.
`gapThreshold` answers `Infinity` below three samples — right for segmenting ("never break this
line"), wrong for quoting ("no distance is too far"). `readoutTolerance(t)` replaces that infinity
with `MIN_GAP_MS`. A sentinel meaning "unbounded" is safe in "should I split here?" and dangerous in
"is this close enough?". The floor errs toward silence: no number beats an invented one on the
ceiling.
## Crosshair readout: `runs()` derives segments and `gapMs` from one threshold, and drops single-point runs
Segmenting the ceiling at an infinite threshold joins two isolated deco samples into one run, and
`ceilingAreas` shades a forbidden zone across water the diver owed nothing — the readout's lie in
pixels, which the readout then contradicts. `runs()` derives segments and `gapMs` from one threshold
so the two cannot disagree. Two samples ten seconds apart still join and draw; twenty minutes apart
they become single-point runs, and single-point runs are dropped rather than emitted: a one-sample
`<polyline>` draws nothing and `buildAreaPath` turns one point into a zero-width shape, so dropping
them makes "no ceiling here" true of the DOM, which is what a test can assert. `max_ceiling` still
puts the obligation in the card's description.
## Crosshair readout: a tolerance cannot answer "was this drawn?", so `PlottedChannel.drawn` does
`gapMs` answers whether a sample is near enough to quote, and a sample dropped for sitting in an
undrawable run of one is trivially near itself — so a readout could name a ceiling, plant a dot and
write "deco ceiling to 3.0 meters" into the `aria-label` over a chart that drew none. Distance
cannot answer a membership question. `PlottedChannel.drawn` is the set of sample indices that
reached the picture, and a readout needs both `sampleIndexAt` and `drawn.has(index)`.
A channel with nothing drawable is not a plotted channel: `channels` is filtered on
`segments.length > 0`, which holds the rule for legend, both axes, crosshair and summary at once.
Without it a two-sample series claims a toggle reading "on", a labelled axis and a line in the
`aria-label` over an empty plot, and a two-sample depth series draws its area fill with no line,
`depthArea` being built from the whole series.
## Crosshair readout: `drawnValues` feeds the axis and summary, and `drawnSampleIndexAt` skips undrawn samples
The `segments.length > 0` gate asks whether a channel is on the chart; runs are dropped per sample,
one level below it. A ceiling keeping one drawable run passes the gate and hands its raw series to
everything reporting an extreme, so the summary can announce a deeper ceiling than any drawn one and
the shared vertical axis stretches to fit a curve nobody sees. `drawnValues(series, drawn)` feeds
both `depthDomain` and `describeProfile`; `describeProfile` takes plain `number[]` per channel
rather than `ChannelSeries | null`, so the raw series cannot be passed by habit and an empty array
means "not on screen".
A nearer undrawn sample must not mask a drawn one: `drawnSampleIndexAt` walks outward from the
nearest sample and takes the first drawn one — the nearest drawn sample within tolerance.
## `gapsAreMeaningful` says what a gap means: only the ceiling refuses to join two samples
Segmenting every channel at `readoutTolerance` applies ceiling reasoning to measured channels: below
three samples `Infinity` becomes 15 s, so a two-sample channel more than 15 s apart splits into two
dropped singletons and stops plotting; where depth is the only channel the component falls through
to `available.length === 0` and reports no samples.
`ProfileChannel.gapsAreMeaningful` splits the cases on what a gap means. For a measured channel it
means "not recorded", there is no cadence to judge two samples by, and joining them is the honest
option — `gapThreshold`'s `Infinity`. For the ceiling it means no obligation existed, and joining
draws a forbidden zone over free water, so only it pays the cost of refusing. It coincides with
`dashed` and says something different: `dashed` is how a curve is drawn, this is what its absence
means. `dive-profile-chart.render.test.tsx` covers the component wiring the `lib` tests cannot.
## Markers are clipped to the plot, because the API leaves their high end alone
The API's `shape_events` clamps an event's time at zero and leaves the high end alone: a FIT
`user_marker` can sit past the profile's last sample. The chart's axis also ends at the dive's end
(`dive_end_time`) rather than at the recording's last sample, so a low-pressure alarm logged at the
surface sits past it too. Clipping to the visible end is the chart's job.
Unclipped, a marker past the dive's end lands in the axis-label gutter or the card's padding, which
the `overflow-visible` SVG draws into, aligned with no time — while `describeProfile` names it. One
filtered list feeds the glyphs, the crosshair and the summary, so they cannot disagree. Markers past
the domain are dropped, not clamped to the last second (clamping invents a time), and not left to
the SVG's edge, which clips nothing.
## A dive-wide bar/min is not a rate, so multi-tank `sac_bar_per_min` is null
Litres and RMV survive summing across cylinders: RMV is defined at surface pressure so a 22 L
twinset and an 11 L stage compare. SAC does not: bar/min is a rate of pressure, 10 bar from the
stage is half the gas of 10 bar from the twinset, and a dive-wide sum or average has no referent.
`DiveGasUse.sac_bar_per_min` is `number | null` on the wire and null on multi-tank dives, where each
`tanks` entry carries its own — meaningful because a tank has one volume, and the figure a diver
reads off a gauge, so it stays in the table. `gas-use-chart.tsx` and `gas-use-card.tsx` plot `rmv`
and label with `gas_used`, so the Home page is untouched. The total row renders a greyed `-` rather
than omitting the cell: an empty cell reads as a layout bug, a dash as "no answer here".
## The multi-tank branch splits three ways, and tests attribution first
`compute_multi_tank_gas_use` gives up for five reasons: no attribution; two mixtures sharing a
`gas_number` (whole dive refused); a cylinder the attribution never names; a cylinder failing
`compute_gas_use`'s pressure arithmetic; a per-tank RMV past `MAX_PLAUSIBLE_RMV`. Only the fourth is
a skip. The third skips when that cylinder's pressures show no drop and refuses when they do, since
the surviving figures are then wrong, not incomplete; the fifth refuses likewise.
So `gasUseUnavailableReason` never claims the import records no switches; `DiveProfileCard` may be
drawing them above. It says the switches don't account for every cylinder, true of all three.
Without `dive_profile.gas_attribution`, the browser infers backwards: a cylinder whose pressures
would have produced a figure when the dive produced none means attribution was missing; otherwise no
pressure drop, or no pressures. The duplicate case gets no sentence; `gas_number` is never edited
here. Not "this tank's", and no mention of `dive.avg_depth`, not a multi-tank input.
## Attribution comes from gas switches only, and the litres never come from the profile
Per-cylinder pressure activity is not an attribution source: the exports carry one pressure channel
per file, so there is usually nothing to compare, and a cylinder's pressure keeps moving with its
temperature long after the diver switched away. The column is `gas_attribution`, not `gas_usage`,
because it carries only which cylinder, for how long, at what mean depth — no pressures. The litres
come from the form's `start_pressure`/`end_pressure`, not the profile's curve: the two disagree by a
few bar for the same cooling reason, and the recorded header is the number the Tanks card prints
directly above, so deriving consumption from another would make the table unreconcilable with it.
## The consumption table is six columns wide and scrolls
The consumption table scrolls inside shadcn's own wrapper, so a narrow pane scrolls the table and
never the page body. `RMV` and `SAC` are content-driven (`18.24 L/min`, `0.82 bar/min`), so
shortening headers gains nothing there, and moving units into the headers is forbidden by the card's
rule: units stay with the values, never doubled in the label. "Avg depth" keeps its full length: it
is a mean depth over the stretch a cylinder was breathed, and "Depth" beside a per-tank row invites
reading it as that gas's deepest point — the misreading `diveModWarning` refuses to warn per tank
over, a mean depth being the wrong input for a MOD. Before shortening a header, check what fraction
of the table is `p-4` padding; halving it is what made this table fit. Widths: see "The consumption
table scrolls inside shadcn's own wrapper, and no card adds another".
## The tank↔mixture join applies the API's duplicate rule rather than trusting it
`compute_multi_tank_gas_use` refuses a dive whose mixtures share a `gas_number`; `tankGasUseRows`
guards anyway, since a pure function sound only via a server-side check has no contract, and the
failure is litres listed twice under a total counting them once. A gas number naming more than one
cylinder names none: both rows are unattributed. Same on the tank side: a `Map` keyed by gas number
keeps the last writer, so a shared number would drop a tank while its litres stayed in the total.
Both get rows keyed by position in `tanks`.
Invariant, test-pinned: every tank reaches exactly one row. Two asymmetries: a mixture with no tank
stays, "Not attributed"; a tank with no mixture is appended as `Gas N`, not `Tank N`: the mixtures
card numbers by position and this row has none. Row keys use `mixture.id` or position, never
`gas_number`. No `!number` shortcut: the reader labels the first cylinder 0.
## Attribution coverage is stated when it's short, and silent when it isn't
Per-tank figures are an inference that can leave a remainder — a file recording switches but not the
entry gas has nothing to assign the descent to — and figures covering 38 of 42 minutes presented as
the dive understate every one. `gasAttributionNote` says so from `attributed_seconds`/`duration`,
and says nothing below a one-minute remainder, which is rounding and would print two spans rendering
identically. The denominator is the profile's span, not `Dive.submerged_time`: it is what the
attribution ran over, and `Dive.submerged_time` may be hand-edited, making the fraction
unfalsifiable. It returns null when `attributed >= total` rather than printing "covers 45min of the
40min recorded" — unreachable from a correct API, but the shape a degenerate profile takes. The
sentence names the dive computer ("the 1h 12min the dive computer recorded") because
`DiveGasUse.duration` routinely outruns the header's `Dive time`; two right numbers for two spans
must not read as one being wrong.
## The total row is dropped when it would restate the only row above it
The corpus's normal multi-gas shape is one `tanks` entry: one transmitter on the back gas, a deco
bottle recording no pressures. There an "All tanks" row repeats the single row verbatim and prints a
dash under SAC beside a real `bar/min`, reading as "no SAC available". The total renders only when
two or more cylinders were attributed, the only case where it reconciles anything.
The condition is `attributedCount`, counted after the join. `tanks.length > 1` is equivalent, since
every tank reaches exactly one row, but `attributedCount` states the reason directly rather than
through an invariant. The layout switch is `rows.length > 0`; there `tanks.length > 1` would hide
the deco bottle's row on every one-transmitter dive. The "Not attributed" row stays regardless: on
such a dive it is the card's second fact, a bottle carried whose cost this log can't say.
## The Home page chart names no depth the rate is not divided by
Multi-cylinder dives reach `GET /user/gas-use-history` with RMV normalized against each cylinder's
own mean depth, so the single-tank framing — `{avg_depth}m average · {gas_used} L used` in the
tooltip, `at {avg_depth}m average` in the dot's accessible name — makes `avg_depth` a true fact
about the dive and a false claim about the number beside it, while `gas_used` sums attributed tanks
only. Both are dropped together and replaced by the one thing needed: this rate is per cylinder, and
the dive page has the split. `point.gas_use.tanks?.length` is the switch, the same one the
consumption card uses. The accessible name gets the same treatment, not a simplified one, being the
only form a screen-reader user gets. `gas-use-chart.render.test.tsx` asserts the absence of the old
depth as directly as the presence of the replacement. A derivation that changes what a number means
has to be chased to every consumer of it.
## No profile, no attribution — and that is 18 of the 19 multi-gas dives
`gas_attribution` is a column on `dive_profile`, so a dive without a profile cannot reach the
multi-tank derivation whatever the diver types; `gasUseUnavailableReason` tests that first. The
guard reads `primaryRecording(dive)?.profile == null` — the primary recording's deliberately, since
the API joins gas attribution through `ordinal == 0` and a second computer's profile puts no
`gas_attribution` on the dive; see "A dive has recordings, and the first one is primary, picked only
by `primaryRecording()`". 18 of the corpus's 19 multi-gas dives are hand-logged, with no profile and
no source file, so "needs an import whose gas switches account for every cylinder" describes an
import that does not exist. The `mixtures` guard above has already returned for a list dive, so a
missing profile here is a real absence, not a withheld field. Test user-facing strings against the
corpus's ordinary case, not the one the code was written for.
## An empty tank row says "No pressures recorded" or "No figures", never "Not attributed"
`TankGasUseRow.use` is null for two server outcomes: the attribution never mentioned the cylinder,
or it did and `_tank_arithmetic` declined it — no pressures, no drop, or a degenerate stretch
(`seconds <= 0`, `mean_depth_cm <= 0`, which a gas switched to at the surface at the end of a dive
produces). A label reading "Not attributed" names the first and is contradicted on the second by the
coverage note reporting that cylinder's seconds as attributed. The row says "No pressures recorded"
where the mixture carries no pressure pair — checkable in the browser, and the corpus's deco bottle
without a transmitter — and "No figures" otherwise. `TankGasUseRow` carries `hasPressures`, read
only when `use` is null. When a null collapses several upstream states, a label naming one of them
is a guess: distinguish them or say less.
## One dot, two sentences, one predicate
The gas chart describes each point twice, in the tooltip and the dot's accessible name, and both
must agree on whether the RMV was derived per cylinder. `isPerTankPoint` is the single predicate,
replacing two independent `(point.gas_use.tanks?.length ?? 0) > 0` checks joined only by a comment.
Both renderings are tested: the tooltip is the path almost every diver takes, and pinning only the
accessible name lets a regression that puts `avg_depth` back into the tooltip ship green. Testing
the accessible name is not a proxy for testing the visible one, even where both come from the same
data.
## The dive's clock sits with its date on the hero's line, as its card prints them
The dive hero's line is its card's: `formatDiveDateTime`, the short date and the clock in the dive's
own timezone (see "A dive's `start_time` displays/edits in its own timezone, never the browser's"),
then the place. `formatDiveStartTime` - weekday, month in full, and the offset appended so `10:04`
can be checked - is rejected there so a dive reads the same on its page as in every list; it stays
on the import's match rows, where telling two dives apart is the point. A bare date prints no clock
in either. The hero's figures are in whole units as the card rounds them.
## The ppO₂ limit is picked from a list, and an unlisted one is added to it
`po2_limit` is a `<select>`, not `<input type="number">`: the schema's 0.4–2 band catches a unit
error (Suunto JSON writes 140000 Pa for 1.4 bar), the values a diver picks are seven, and free entry
bought typos and a 422 on `ck_dive_mixture_po2_limit_range` the diver cannot act on. As with `role`,
`""` is "unset" and must reach react-hook-form as the live cleared value — see "The API sends `null`
and the form schema wants `""`, so `toDiveMixtureInput` converts at the boundary".
The options are strings: `String(1.0)` is `"1"`, so an option labelled `"1.0"` would never match its
stored value; selection compares `Number(option) === value`, one direction only. A limit not on the
list is inserted, sorted, since a `<select>` matching no option renders blank over a held value. The
floor is 1.0 by decision: below it a figure is a CCR setpoint, not a MOD ceiling.
## The profile's left edge always carries a scale, and an all-hidden chart is still a chart
The plot holds at most three scales: depth and ceiling share one (see "The deco ceiling rides
depth's axis"), so the rule is over scales, not channels. With two scales, sides are fixed (position
reads faster than colour): meters left, temperature right, pressure taking whichever is free. With
all three on, pressure's numbers sit under temperature's (`underRight`), its domain fitted by
`alignedDomain` to temperature's step count so each pair labels one height. A single scale goes
left, right edge empty; mirroring it invites reading two scales. Invariant: the left edge is
labelled whenever anything is plotted, the right exactly when a second scale is on.
`DiveProfileChart vertical axes` sweeps every non-empty selection, computing the expected right edge
with `distinctScales`; see "Heart rate and the deco readouts have a panel, because the depth plot
has two edges" for what "the plot" means. Switching the last channel off keeps the plot: same box,
time axis and markers, the sentence over the middle with `pointer-events-none`, neither edge
labelled. A `??` chain that runs out is a case, not an absence.
## The depth fill is built per segment, from the same `segments` as its line
`segmentByTimeGap` cuts every channel into runs so no line spans data that isn't there (see "The
profile's line breaks are derived from the series' own cadence"). `depthArea` is one `buildAreaPath`
per segment, from the same `segments` the polylines use, as the ceiling's has always been — not a
single path over `depthChannel.series.t` entire, which shades water across a dropout the curve above
refuses to span: a claim the diver was down there through a stretch the device recorded nothing
about. The test `leaves the dropout unfilled rather than spanning it` reads the `d` attributes back
and checks where the first path ends and the second starts; a count alone passes on two overlapping
fills.
## The markers have a legend switch, and it is not a fifth channel
A dozen markers on one dive is a picket fence, and opacity only trades marker legibility against the
plot's, so the legend switches them. `ProfileChannelKey` stays "a thing with a domain", which
`lib/dive-profile.ts` is written against; the legend switches the wider
`PROFILE_VIEW_KEYS = [...PROFILE_CHANNEL_KEYS, "events"]`, `parseSeriesVisibility` is generic over
its keys, and `ChannelToggles` is named `LegendToggles`. It lives in the legend only (see "Both
charts' legends are the control for what they plot"). The swatch is an uncoloured circle: diamond
and triangle mean specific types, and colour means "joins to something else on the chart", true only
of a gas switch. Hiding hides everywhere: glyphs, crosshair readout and accessible summary read one
`eventsShown`, and tests pin that a hidden marker is neither announced nor quoted. The markers key
is in that list, so adding it took a suffix bump, or stored selections would restore with markers
off. The empty-plot message is held back while markers are up.
## A remembered selection is held over every key, not over the keys one dive has
`available` is per-dive while the stored selection is one app-wide entry. Narrowing it to
`available` before storing leaks: a toggle on a markerless dive writes back an entry without
markers, which `parseSeriesVisibility` reads as "off" on the next dive. It is held at full width,
`chosen ?? remembered ?? PROFILE_VIEW_KEYS`, and intersected with `available` to draw. `visible` is
that intersection; `shown`, `eventsShown`, `shownValues` and the empty-plot overlay read it, not the
selection. An empty `chosen` is honoured (`chosen !== null`, not truthiness): `[]` is a choice,
while a remembered selection plotting nothing here shows what the dive has. That test asks whether a
curve survives, not a key (`"events"` alone draws none), and the fallback restores curves only,
leaving markers untouched. The toggle's base is `visible` lifted over the full list, not the
selection. `DiveProfileChart selection across dives` and
`DiveProfileChart remembered selection that plots no curve here` pin it.
## A cylinder is named by its 1-based position, and `DiveMixture` has no `name`
`DiveMixture.name` is not a field: the label is a pure function of the fractions from the API and so
from here: schema field, form input, column, label fallbacks and `getDefaultMixtureName`. A cylinder
is named by 1-based position; `ParsedDiveMixture.name` was always `null`, so imports were already
positional. The Tanks card names each cylinder `#1`, `#2`, and the consumption table's `#` column
numbers its rows the same, since the two are read against each other and `TankGasUseRow.label`
derives the same number; an unmatched cylinder stays `Gas N`. The profile's pressure channels are
named against the same positions. `mergeMixture` lost `index`.
The per-tank form is eight boxes in a two-column grid, nothing widened (`Volume | ppO₂`, `O₂ | He`,
`Start | End`, `Role | Usage`), so pairs read as pairs and the two `<select>`s sit adjacent.
`DEFAULT_MIXTURE` is complete, so `append({ ...DEFAULT_MIXTURE })` and both pages' seeds share one
shape.
## The export filename is the server's, with a local mirror behind it
`lib/api/export.ts` calls `filenameFromContentDisposition` on the response and saves what the API
named. `Content-Disposition` is not a CORS-safelisted response header, so `fetch` reads it as `null`
unless the API names it in `expose_headers` on `CORSMiddleware` (`core/setup.py`); the API's
DECISIONS.md carries it under "`Content-Disposition` has to be named in `expose_headers` or the
browser hides it", and any other header the web app needs to read has the same problem. Behind the
parser, `exportFilename` mirrors `export_filename` in the API's `services/export/naming.py` —
`opendiving-<username>-<YYYYMMDD>.<ext>`, the `[^a-z0-9]+` scrub, `"export"` when the username
scrubs to nothing — kept as a fallback for a self-hosted API behind a proxy that strips the header.
The date is stamped in UTC from `getUTC*`, matching the server's `datetime.now(UTC)`, so a diver in
UTC+13 gets the name `curl` does. `export.test.ts` is the coupling and spells out the scrub cases so
it fails when the API's rule moves.
## The whole export is buffered in browser memory, and the escape hatch is the download ticket
Every export is fetched with axios `responseType: "blob"` and saved through `downloadBlob`, the path
the dive source file and c-card images take: the endpoints require an `Authorization` header and a
plain `<a href>` cannot send one, the token living in memory rather than a cookie. Those two are
bounded by upload limits; an archive is bounded by the account, held in RAM before writing and for
`REVOKE_DELAY_MS` after. Accepted. The escape hatch is the ticketed navigation `saveApiDownload`
takes in Firefox for iOS, streaming to disk outside the JS heap. It is not the default because the
page never sees the response: a failure replaces the app instead of toasting. Widen that branch when
someone hits the ceiling — not a streaming-download library on this side.
## Buttons all named "Download" need an accessible name each
Every export button reads `Download`, so each carries an `aria-label` naming its row. An
`aria-label` overrides the button's text, so it tracks the busy state
(`Preparing Full archive export` while fetching, `Download Full archive` after), with `aria-busy`
backing it (`aria-busy` alone is unreliable).
The busy button is `aria-disabled`, not `disabled`: a real `disabled` drops focus to `<body>`, so
the name change reaches no one. Three parts replace it — an early
`if (busy.has(row.format)) return`, `aria-disabled:opacity-50` (Tailwind's `disabled:` variant never
fires), and a test that a busy click sends nothing.
Busy is a `Set<ExportFormat>`, updated functionally: a boolean disables every row while the archive
runs, and a single `ExportFormat | null` lets one row's `finally` clear the other's. A duplicate
click costs a second save, not a second request, since `apiClient.get` dedupes in-flight requests
(`getRequestKey`). Rows may run concurrently; the per-user export rate limit is the diver's budget
to spend.
## `<` is the earlier dive, which is the opposite of what the log list would suggest
`<` is the earlier dive, `>` the later: a timeline, not the newest-first order of `GET /dives`.
Neighbours come from `GET /dive/{uuid}/neighbors`, keyed by `start_time`, never `dive_number`, which
has gaps.
A route-group layout above `dives/[id]` owns the fetch, so a step keeps the outgoing dive mounted:
`MapHeroPageSkeleton` shows only when `isLoadingDive && !dive`, and a step dims the cards to
`opacity-50` under `aria-busy`. Neighbours are stored under the uuid they were fetched for, and
fetched for the displayed `diveUuid`, not `params.id`, so arrows and date agree.
Each arrow is one `<a>` swapping its `href`, not `next/link`: alternating element types replaces the
node and drops keyboard focus. Unavailable is `aria-disabled` plus `pointer-events-none` with
`role="link"`; a plain click calls `router.push`. The log's end leaves its arrow dead, not removed;
`aria-busy` marks not-yet-known. A failed fetch is silent, as in `DiveNumberingCard`.
`dive-neighbor-nav.render.test.tsx` pins node identity across the `href` swap.
## The consumption table scrolls inside shadcn's own wrapper, and no card adds another
The table fits its 582 px slot at the 1024 px `lg:col-span-2` pinch; `px-2` cells did most of the
fit, see "Cell padding is `px-2` app-wide". The card adds no `overflow-x-auto` wrapper: shadcn's
`Table` wraps itself in `relative w-full overflow-auto` (`ui/table.tsx`), so an outer one never
scrolls and `closest('[class*="overflow-x-auto"]')` reports 0 px. Measure `table.scrollWidth`
against `table.parentElement.clientWidth`, any conditional `tfoot` rendered. Chart wrappers
(`dive-profile-chart.tsx`, `gas-use-chart.tsx`, `dive-activity-chart.tsx`) stay: SVG has none.
Columns: gas has its own column; `Used`, not `Gas Used`; header `#` plus `sr-only` "Tank",
`TankGasUseRow.label` `"1"`, unmatched tanks `Gas 3`, footer `Total`. `GAS_BADGE_CLASS`
(`lib/dive-mixtures.ts`) is a 4.5 rem `min-width` sized to `Oxygen`; a `w-16` is rejected, since it
collapses under `table-layout: auto`.
## The Tanks card is a grid of tank cards, columned by its own width
One card per cylinder, drawn as a dive's card is but with no backdrop. Columns are `auto-fill` at
`min(19rem, 100%)` rather than breakpoints, because the slot is narrowest at `lg`: three columns at
`xl` squeezed a card to 212 px and overlapped its figures. 19 rem holds three figures on one line;
the `min` keeps a lone column inside a 320 px phone's card. Figures are `min-w-20`, about the width
of `211.44 bar`, so they line up from card to card, and wrap under about 350 px as the hero's do.
The MOD's ppO₂ sits in the label, `MOD @ 1.4`, so the depth is the figure. A missing volume or
fraction is left out of the facts line; a missing pressure or MOD is a muted dash.
## Cell padding is `px-2` app-wide
`ui/table.tsx` diverges from shadcn: `TableHead` is `px-2`, not `px-4`; `TableCell` is `px-2 py-4`,
not `p-4`. Vertical padding is untouched. Only the import tables override it, for dense rows: an
import can list hundreds of dives. Re-adding the component from the shadcn registry silently
restores `p-4`/`px-4`, hence the comment in the file.
Measured on nine tables at 375–1440 px with `padding-inline` injected, reading `table.scrollWidth`
against `table.parentElement.clientWidth`. At 1024 px and above nothing overflows at any padding; at
375 px everything scrolls regardless; 640–768 px is the argument: `px-2` clears every table's 640 px
overflow, and at 768 px stops `Date & Time` and `Apr 16, 2026, 12:59` wrapping in the dive log.
`px-3` clears under half. An `overflow-x-auto` div around a `Table` is inert, and fools
`closest('[class*="overflow-x-auto"]')`.
## Dive site coordinates are two free-typed strings, validated as a pair
`latitude`/`longitude` are `number | null` on the API but form strings, converted by
`parseFormCoordinate()`/`formatCoordinateForForm()`; a `z.preprocess()`/`.transform()` would
collapse the `z.input<>` form type.
Both-or-neither is two `.refine()`s, messaging the empty field. `WholeCoordinatePair` enforces it,
422ing a body naming one coordinate without the other, and never reads the stored row, so moving a
site sends both. The dialog always submits the pair; a half pair from the database must be resolved
first.
`parseCoordinatePair()` splits a pasted `27.8506, 34.3136` into both fields. DMS is not parsed;
`AMBIGUOUS_DECIMAL_COMMA` refuses a bare comma between dot-less integers: `-16,5` is `-16.5` in
Europe and as a pair lands off Angola.
`toDecimalString()` uses `String()`, never `toFixed()`, going fixed below 1e-6 where `String(5e-7)`
fails `COORDINATE_REGEX`.
The pair hint is an `aria-hidden` `<p>` plus a per-field `sr-only` `<FormDescription>`:
`<FormDescription>` throws outside a `FormField`, and a hand-set `aria-describedby` overrides
`<FormControl>`'s, silencing errors. No `inputMode`: a coordinate needs its minus (see "A finger
types numbers on a keypad, with either separator").
## The map writes into the coordinate fields, and can tell its own echo from a diver typing
The picker holds no position, reading `latitude`/`longitude` from the form and writing through
`setValue`. `emit` records what it hands out, rounded to five decimals there, so the echo in props
is not read as typing, which recentres the map. The record is separate from the previous props,
spent once matched, never armed by a no-op placement; "ever placed" is latched (`parseFormPosition`
rejects `34.` mid-edit); comparison runs during render against the last props, never the view.
The geocoded name goes straight into Location, keyed to the clicked coordinates, announced via
`role="status"`. `reverseGeocode` returns a three-way `ReverseGeocode`: `204` nameless (clear unless
empty), `200` with `null` could-not-ask (leave alone); branch on `response.status`, as axios gives a
204 `data` of `""`.
Gesture rules are MapLibre configuration ("The picker's contract is its own, and MapLibre is
configured to meet it"). The basemap host sees divers' IPs and tile areas (`/privacy` §4.4); the CSP
follows the basemap configuration (`lib/basemap.ts`) to a self-hosted server.
## A trip part's place is self-describing, so nothing has to be resolved
`TripPartsField` is built on the same `CreatableCombobox` as `GearItemMultiSelect` but holds
`{name, latitude, longitude, bbox_*}` objects, the snapshot the API stores, rather than uuids, so a
row renders from its own content with nothing to fetch and no loading state. See "A geocoder row's
hint is its region, composed once" for what a menu row shows.
Places therefore have no id. `locationKey` derives one from content: `geo:{lat}:{lon}:{name}` for a
geocoded place, `txt:{name}` lowercased and trimmed for a typed one. `mapSearchResults` collapses
results sharing a key, since two rows sharing a React key cannot both be picked, and the row's
`value`/`selectedItem` pair is keyed by it. Nothing compares keys across parts: two parts may name
the same place — Dahab, then Sharm, then back to Dahab — so there is no `excludeIds` and no "already
in the list" refusal. Rows are keyed and removed by position, or a repeat would go as a pair.
Position-keyed rows swap content under a focused drag handle, so `useDragSort` moves focus to the
destination handle (`data-drag-handle`) a frame after a keyboard reorder; for id-keyed lists that is
a no-op.
## An unmatched query is addable as text, and that is an outage hatch as much as a long tail
Enter on an unanswered query adds the place as name-only text: the geocode proxy answers `[]` when
over the provider's one-request-a-second limit, so a throttled minute would otherwise accept
nothing.
`CreatableCombobox` tells a search that resolved empty (`searchedQuery` set, `commitAction` creates)
from one that rejected (`searchErrorLabel`); creating from an unanswered query needs
`createWithoutSearch`, which `keepOpenOnSelect` enables since an append-only field has no value a
blip could clear. That flag also makes `onCreate` fire from Enter only, never on blur or from
`handleInputChange`, without waiting for an in-flight search, which blur cancels.
Empty-menu text runs `minSearchLength`, search failed, `searchPending` ("Searching…"), then "No
places found — press Enter to add as text".
`min-w-0` goes on the row text and on the `<form>`, the `DialogContent` grid item
(`min-width: auto`). The 20-location and 255-character ceilings apply as the list is built: a schema
error at `locations.0.name` makes `errors.locations` an array and `FormMessage` renders "undefined".
## The picker types more slowly than the rest of the app, on purpose
`searchDelayMs` takes an optional override and `CreatableCombobox` a `searchDebounceMs` prop so the
trip picker waits 450 ms where every other remote combobox waits 250. Our own endpoints tolerate
four requests a second; the geocoder sits behind a proxy enforcing one request a second to each
provider across the instance and answers `[]` rather than queueing, so a fast debounce turns
keystrokes into empty menus. The empty query fired on menu-open skips the debounce, there being no
keystroke to coalesce.
`searchPlaces` answers `[]` locally for a query outside the endpoint's `2..200` instead of calling
the API: either end is a 422, which arrives as a rejection, while a local `[]` keeps
Enter-to-add-as-text working.
The create branch runs `setInputValue(keepOpenOnSelect ? "" : created.name)`: a multi-select's input
is a filter that belongs empty, and the awaited `onCreate` resolving after the menu closed would
otherwise write the stale name back.
## Coordinates that were picked, not typed, are numbers
`lib/validations/coordinates.ts` validates a _site's or a contact's pin_ as strings against a regex
because a diver typing a latitude passes through "-" and "-17." on the way to "-17.9". A place's
coordinates arrive whole inside an object picked from a menu, or are absent for a typed-in place;
there is no half-entered state, and no field for one. So `locationSchema` in
`lib/validations/location.ts` uses plain `z.number()` with the API's bounds, catching a nonsense
object before the round trip. It carries no `z.preprocess` or `.transform`, which keeps `z.input<>`
inference working and `LocationFormValue` usable as either form's own type — one schema, because a
dive site's locality and a trip part's are one object.
## The confirmation map is not `MapPicker`, and that is most of why it is short
Both maps draw through MapLibre and share `components/map/map-canvas.tsx` and `lib/basemap.ts`; they
stay two components because nearly everything in `MapPicker` serves write-back, telling a position
the map emitted from one typed into the coordinate fields, plus the gestures that place a pin.
`LocationsMap` emits nothing: give it locations, it draws them.
Pins are `bg-coral`, not `bg-primary`: primary is near-black in light and mid-grey in dark,
invisible on the dark basemap's grey land, and coral is the one accent constant across themes.
The attribution overlay sits outside the `role="img"` surface, so the frame is two nested elements:
a link inside an image role drops out of the accessibility tree.
The surface is measured through a callback ref rather than a mount-only effect, because the map
renders `null` until something has a position; tying the `ResizeObserver` to the element appearing
lets callers skip gating on having something to draw.
## The read-only map lives in `components/map/`, not in `components/trips/`
It lives beside the canvas, credit, marker and label it shares with the cards' and the heads' maps,
rather than in `components/trips/` with its one caller.
`subject`, the aria-label's fallback, is read only when the places have no usable names; names are
joined by `formatTripLocationNames`. It is required despite an obvious default because the reading
path has no visual tell: an omitted value gives a wrong label on a screen-reader-only path no
screenshot exercises, so omission is a type error.
The frame's height lives in the component, duplicated once in the `next/dynamic` skeleton beside it,
so a caller cannot make the page jump when the chunk lands.
The site and dive heroes credit a map only for a record with a position - the site's pin, or
`hasMapPosition` over a dive's sites and fixes; a half-set position, which only raw SQL produces,
counts as none.
## `fitBounds` unwraps longitudes before it unions them
MapLibre does the camera arithmetic; `unionBounds` in `lib/basemap.ts` supplies the box, and unwraps
each location's box against the first before the union. A trip to Fiji and Samoa spans about six
degrees across the antimeridian; unioning raw coordinates describes the 354 degrees the other way
round, which fits at exactly one zoom, the whole world with both pins at opposite edges. Each box's
width is taken as a signed span first, so an east edge folding behind its west stays one interval
rather than a negative one, while a zero-width point stays a point.
The fit is capped at `MAX_FIT_ZOOM` (9, counted against MapLibre's 512 px tile) rather than
`MAX_ZOOM`: a single location fits at any zoom, and a trip location is a town, an island or a sea,
so it should open where the coast is recognisable rather than in a grid of house numbers.
## A dive site's map opens further out than the picker that placed its pin
`MapPicker` opens at `PLACED_ZOOM` (11, `components/map/map-picker.tsx`) for a site with a position;
the site page's map fits to `MAX_FIT_ZOOM` (9, `lib/basemap.ts`), the same cap a trip location gets.
Matching the picker is wrong for one reason: the picker can be zoomed out and a static map cannot.
Across a phone an offshore site (Chumphon Pinnacle off Koh Tao, Kimud Shoal off Cebu) renders at the
deeper zoom as featureless grey with a coral dot and no land or labels; two levels out shows the
island and named towns. A shore site reads well at either, and roughly half of dive sites are
offshore.
The cap is `MAX_FIT_ZOOM`, single rather than per caller: a per-caller argument had one value, one
explicit caller, and a second plausible number sitting in `MapPicker` inviting someone to pass it.
## Locations are always sent on edit, never omitted
The API's PATCH treats an omitted `parts` key as "leave them alone" and any list as a wholesale
replace. `TripDialog` always sends the list: the form shows the whole set every time it opens, and
omitting the key when nothing changed would make "remove them all" inexpressible. The same rule
covers every list field the app edits (a dive's sites, gear and cylinders, a gear set's members)
because each form knows the whole set; see "The edit form submits the whole dive, because the read
is the whole dive".
The map beneath the parts field is driven by `useWatch`, not `form.watch()`, which re-renders the
whole dialog on every keystroke in the notes. The map is a `next/dynamic` import with `ssr: false`,
since it measures its element and reads the theme; the wrapper lives in its own file so the
skeleton's height cannot drift from the map's.
## A "+N" is a promise that the label will say what N was
`Dahab, Egypt +2` compacts a list the payload already holds, so every surface showing it carries the
full list as a `TextHint`: a `title` for a mouse, a hint on a tap for a finger. On a card the tap is
the card's, and the record's page answers it.
The hint sits on the whole label, not the "+N": a two-character badge is a small target and splits
the answer in two.
A hint that repeats the label is worse than none, so `formatTripLocationNamesHint` sits beside
`formatTripLocationNames`, answers `undefined` when nothing is hidden, and decides that under the
same blank-dropping rule: `["Moalboal", " "]` under `max: 1` shows no "+N" and gets no hint.
`TripLocationsLabel` calls both, as `DiveSitesLabel` does for dives; the trip card passes locations
only.
`SHOWN_LOCATIONS` is **one**: a place's name carries its country, so two of "Dahab, Egypt" do not
fit a trip card's line beside its dates. It stays private because every surface that joins a trip's
places for a reader — the trip card, the dive sidebar and the trip page's own subtitle — renders
this component, so there is one number and nothing for a second one to disagree with. Every such
list separates with `; `, a comma being indistinguishable from the commas inside each name. A map's
accessible label (`mapLabel`) takes the separator and not the cap: a cap withholds names from the
one reader who cannot count the pins.
A keyboard still cannot reach the hint: the label is a `<span>`, and making it focusable would put a
second tab stop inside a card's link.
## `DialogFooter` is one row while the buttons fit, and its gap is `gap-2` not `space-x-2`
shadcn's footer is `flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2`, a column with no
spacing below `sm:`, so on a phone Cancel and the action sit flush. Most dialogs end in Cancel plus
one action; the widest pair (`Cancel` beside `Create certification` with its `Plus` icon) needs
about 326px against a content box of viewport minus 50px (`p-6` plus borders), so it fits on one row
at 360 and wraps only at 320. The column spent a second row on something that never needed one.
`gap-2` rather than `space-x-2` because the gap has to survive a wrap: `space-x` is a sibling margin
that knows nothing about line breaks, so a footer that did wrap (320px, a longer label, a larger
text size) would lose its spacing. `flex-wrap-reverse` keeps the buttons on one line when they fit
and puts the action above Cancel when they cannot, with 8px between rows.
## A headerless card restores its top padding by hand
A card whose figures are each labelled - Home's stats - has no header, and its `CardContent` takes
an explicit `pt-(--card-pad)`: the default `p-(--card-pad) pt-0` assumes a `CardHeader` supplied the
top padding. A `headerless` variant in `ui/card.tsx` would have to guess whether the next one wants
the same padding.
## Pages hold their shape while they load, instead of collapsing into a spinner
Every page is a client component that fetches on mount, and client-side navigation is instant, so a
route that early-returns a centred `Loader2` produces content, then an empty column with a small
mark, then content: two layout changes and a flash on every link. Placeholders shaped like the thing
they replace give one layout instead of three. `Skeleton` (`components/ui/skeleton.tsx`) is the
primitive; `CardSkeleton` and `ListRowsSkeleton` sit beside it, `TableRowsSkeleton` goes inside a
real `<TableBody>`, and `MapHeroPageSkeleton` (`components/ui/map-hero.tsx`) and `FormPageSkeleton`
(`components/ui/page-skeleton.tsx`) assemble page shells from them. The page-level ones build on the
real `Card` and hero primitives rather than re-describing their padding, which is what guarantees
the header the same height before and after the record lands.
Left out: a GitHub-style top progress bar, a signal rather than a fix while pages blank, and a
stale-while-revalidate layer under `useInfiniteResource`, which would show previous rows
immediately. Both come after the layout stops moving.
## The 150ms delay is what keeps a skeleton from being worse than a spinner
A content-shaped placeholder that appears instantly trades a layout jump for a grey flash: against a
local API most loads finish under 100ms. `animate-skeleton` (`tailwind.config.mts`) is two
animations, a `skeleton-in` fade held at `opacity: 0` for 150ms by `both`, then `skeleton-pulse`
from 350ms.
The delay is on the opacity, not on whether the skeleton renders: it takes its full space from the
first frame while invisible, so a fast response swaps straight to the new page and a slow one has
already reserved its layout.
The chrome needs the delay too. `Skeleton` is only the grey bar; the `Card`s and `TableRow`s around
it are real and would paint a grid of empty ruled boxes instantly, so `animate-skeleton-reveal`, the
fade without the pulse, goes on those containers.
`motion-reduce:animate-none` drops both, leaving the skeleton visible from the start: no delay beats
a delay you cannot see coming.
## The click paints the destination's frame, and the page is what paints it
No route carries a `loading.tsx`. Under Cache Components a `<Link>` prefetches the destination's App
Shell, so the click renders the page itself, and the page's own first render is the frame — heading,
chrome and placeholders, in a `*-page-frame` component it draws around its rows.
`app/page-frames.render.test.tsx` derives the destination set from the route tree and holds every
one to it.
Nothing suspends on a navigation, so no Suspense fallback is committed and the throttle below is
never armed. `useSearchParams()` needs no boundary for the same reason `instant = false` sits on the
root layout: no route has a static shell to validate.
_Rejected:_ a `loading.tsx` per segment, which paints the same frame from a Suspense fallback and
buys it at ~300ms to data on a cold route; and painting it from a pending-navigation state above the
router, which costs every frame in the root layout's bundle.
## A Suspense fallback committed at the click holds the page behind it for ~300ms
React holds a boundary's content commit until roughly 300ms after its fallback committed, so a
fallback drawn at the click cannot be replaced before then. This is why no route draws its frame
from one.
What it costs depends on whether the page can fetch before committing. With Cache Components on, a
prefetched route mounts from the shell the browser already holds, so its own request goes out at the
click and the held commit costs nothing: the data is there when the content is. Without that, the
page cannot mount until the response arrives and the throttle elapses, and a held commit is a held
request — measured at about 190ms at +100ms.
An opacity delay on the placeholders is not the lever: at 0 the data figures do not move.
_Rejected:_ patching the throttle out of the vendored `react-dom`, which recovers the figures and
owns a fork of React.
## Loading skeletons: What renders for real, and what doesn't
The page skeletons draw the real back button rather than a bar: where it goes is known before the
record is, and it is the one control someone wants during the wait. List pages keep their
`<TableHeader>` and column labels and put skeleton rows in the body, so the table has its real width
from the first frame.
Each list page's branch reads `!isLoading && empty ? placeholder : table`, with `items.length === 0`
inside `<TableBody>` choosing skeleton rows, so "loading" and "loaded" differ by the contents of one
element rather than by which subtree exists.
`TableRowsSkeleton`'s bar widths cycle by `(row + column)` rather than being random, which would
trip hydration. Its row count is `itemsPerPage`, known before the first response, so a full page
does not grow when the rows land; it overshoots only on a short last page, and the first load is
page 1.
## Holding the shape means the header cannot claim a count it doesn't have
`totalCount` is 0 until the first response, and the list cards' count badges read straight off it;
over ten placeholder rows a badge saying "0 total dives" states something false. `CountBadge`
(`components/ui/count-badge.tsx`) shows a placeholder instead, keyed on `isLoading && count === 0`
rather than `isLoading` alone, so paging through a loaded list keeps the total it knows.
The pagination footer is the same class of problem, left unsolved: it renders nothing while
`totalCount <= itemsPerPage`, so it adds ~52px when the data lands. Reserving that space would be a
guess (a list of exactly ten items never paginates) and guessing wrong shifts the layout the other
way.
## The Home page's chart cards, and the enter animation
A `720 x 240` SVG at `w-full h-auto` collapsing to a spinner moves most of the page's height twice.
`ChartSkeleton` reserves the same `3:1` box plus, behind a `legend` prop, the 24px the gas chart's
`text-xs` legend occupies and the activity chart lacks.
`app/template.tsx` is a 150ms fade with no travel; a `fade-in slide-in-from-bottom-1` over 300ms
spent its animation sliding a `Loader2` and then cut hard to the content.
The root template is keyed at the first path segment, so the fade runs on `/dives` to `/home` and
not on `/dives` to `/dives/[id]` or a pager step. Widening it is deliberately not done: a client
wrapper keyed on `usePathname()`, or a `dives/template.tsx`, remounts the subtree on a pager step,
blanking the page and dropping keyboard focus (see "`dives/(detail)/layout.tsx` owns the dive fetch,
so a step keeps the page mounted").
## The placeholders are hidden from assistive tech, rows and all
`Skeleton` carries `aria-hidden`, but that hides only the bar: the `<tr>`/`<td>` and bordered row
`<div>`s around it stay in the accessibility tree, so a screen reader would be told `/dives` has
eleven rows, ten of them empty. `TableRowsSkeleton` hides each placeholder row and
`ListRowsSkeleton` its whole container, a busy wrapper around a hidden inner element, because
`aria-busy` says nothing to a reader already told to skip the subtree.
`page-skeleton.render.test.tsx` asserts that `getAllByRole("row")` finds none of them.
## Loading skeletons: Measured, not eyeballed
Placeholder heights come from `getBoundingClientRect()` on the real page with the API held back by a
patched `XMLHttpRequest.prototype.send`, comparing loading and loaded geometry of the same element;
a screenshot does not show a `h-5` bar against a 24px line box or a legend nobody accounted for.
`ListRowsSkeleton`'s bars are `h-5`/`h-4` against the real row's `text-base` over `text-sm`. Dive
and trip cards load into `BackdropCardSkeleton`, one box at the card's measured 238px,
`RECENT_DIVES_COUNT` or `RECENT_TRIPS_COUNT` of them on the Home page and a page of them on `/dives`
and `/trips`.
## The project instructions live in AGENTS.md, and CLAUDE.md is an import
`CLAUDE.md` holds a `@AGENTS.md` import and the few lines genuinely about Claude Code; the
instructions live in `AGENTS.md`, the cross-tool convention; Claude Code reads it only via the
import.
The forcing reason: `next dev` writes to these files. Next 16 ships
`node_modules/next/dist/server/lib/generate-agent-files.js`, called from `start-server.js` on every
dev-server start, gated on `agentRules !== false` in `next.config.js` and on `@vercel/detect-agent`
finding an agent env var such as `CLAUDECODE`. It upserts a block delimited by
`<!-- BEGIN:nextjs-agent-rules -->`, preferring `AGENTS.md` and falling back to `CLAUDE.md`.
Deleting the block only restores the precondition for the next start to add it back.
`agentRules: false` is rejected because the advice is real: `node_modules/next/dist/docs/` is the
shipped Next 16 documentation.
`CLAUDE.md` does not mention the parent checkout whose own `CLAUDE.md` loads alongside it: that
parent loads by ancestry regardless, and a standalone clone has none. The maintainer-facing reason
lives in the HTML comment at the top.
## The Prettier override is load-bearing
`.prettierrc.json` gives `AGENTS.md` `proseWrap: "preserve"`, against the `*.md` default of
`"always"` at 100 columns. Otherwise Prettier rewraps the managed block's long lines,
`hasCurrentAgentRules` compares the installed block against the expected text exactly, the
comparison fails, and the next dev-server start rewrites it unwrapped: `npm run format` and
`next dev` undo each other, and `format:check` fails after a dev server on a file nobody touched.
`preserve` keeps the file in the formatter for everything else and costs only hand-wrapping the
prose, noted at the top of `AGENTS.md`.
## A recorded fix is a location with a different marker, not a second map
The dive page draws three positions, each site's pin, the entry fix and the exit fix, and only the
first is a place somebody chose. A second component, or a `sites`/`fixes` pair of props, is the
wrong shape: everything about drawing them is identical and only the marker differs. So
`MappableLocation` carries one optional field, `variant?: "pin" | "fix"`, defaulting to the pin
every caller already draws, and the map stays ignorant of dives. A domain-shaped
`kind: "site" | "gps"` is rejected for the same reason `subject` is a string.
The fix inverts the pin's two colours rather than its size, shape or hue: same coral, same 12px,
`border-coral` with a `bg-background/80` centre against the pin's page-coloured ring over
`bg-coral`. One accent reads as one legend, and the tinted centre keeps the ring legible over a
coastline on Positron and Dark Matter.
## The drift between entry and exit is a line of text, not a line on the map
A segment between the two fixes cannot work: the map is capped at `MAX_FIT_ZOOM` (9) for reasons
unrelated to dives (see "A dive site's map opens further out than the picker that placed its pin"),
and at that cap a pixel is about 100 m of ocean, so a surface swim is a sub-pixel line between
overlapping markers.
`lib/geo-distance.ts` answers in text: `haversineMeters` between the pairs, `formatDistance` to
whole metres below a kilometre and one decimal above. Haversine needs no antimeridian special case;
179.9999°E to 179.9999°W is 22 m. Whole metres because a consumer GPS fix is good to about five, and
rounding happens before the unit is chosen, so 999.6 m renders "1.0 km" rather than "1000 m". Metres
is the only unit; the app has no unit preference to consult.
## The dive's location card renders on GPS alone
An imported file carries fixes whether or not the diver attached the dive to a site, so the card is
gated on a trip, a site, either coordinate pair, the entry type or the boat name. The dive's map is
the hero's, which draws the map's water where no site or fix has a position.
Both gates use `!= null` per coordinate, never truthiness: a dive off West Africa exits at longitude
0 and one in the Galápagos at latitude 0. `formatCoordinates` and the map's `placedLocations`
already guard this way; the pair is turned into a point once at the top of the component and read
from there.
The card is titled "Location" for every combination: a heading that changes between two dives reads
as two cards, and every block inside is labelled.
## The edit form submits the whole dive, because the read is the whole dive
`PATCH /dive` is a partial update, but the edit form sends everything it holds on every save;
`buildDiveUpdate` does not filter on `dirtyFields`. Trip, DiveSite, GearItem, GearSet and
GearServiceSchedule are hard-deleted, and the `ON DELETE` rules on every referencing FK do the
cleanup, so a read is the whole truth about the dive and echoing it back says exactly what the dive
holds.
"The diver cleared the trip" and "the client echoed a null it was handed" are the same PATCH on the
wire; the API cannot tell them apart. That argues not for filtering but for never letting a read
differ from the record, which is the API's rule: a read filter is not a local change.
`buildDiveUpdate` keeps one rule: `undefined` means "not sent", `null` means "the diver cleared it".
Collapsing them makes a trip impossible to remove.
## Seeding the edit form has to be faithful, and `mixtures` is where that bites
Sending the whole form is safe only if the form holds the dive. A dive with zero cylinders is a real
record (`DiveCreate.mixtures` is `default_factory=list`) and `patch_dive` replaces the list
wholesale on presence, so seeding `mixtures` with a synthetic `DEFAULT_MIXTURE` row writes an 11.1 L
cylinder of air to a dive whose only edit was the notes, and `compute_gas_use` derives an RMV from
it. The edit page seeds through an exported `diveToFormValues` beside `toDiveMixtureInput`, testable
at its seam, with no synthetic row.
The narrower fix, omitting `mixtures` in `buildDiveUpdate` when the dive had none and the field
holds one pristine `DEFAULT_MIXTURE`, is wrong: `DEFAULT_MIXTURE` is the `"11.1 L (AL80)"` preset,
and a value-equality guard makes exactly that cylinder unsavable. `MixtureFields` renders
`mixtures: []` with "No tanks recorded for this dive." and its Trash button has no `index > 0` gate,
so zero is reachable by hand.
## `dirtyFields` is maintained only when it is read during render
`formState` is a Proxy. React Hook Form maintains a key only once something has read it during
render, and `useFieldArray`'s `replace` checks that flag before recomputing dirty state. Read inside
a submit handler, `dirtyFields` is still `{}` after a file import has replaced every cylinder, so a
filter keyed on it drops `mixtures` and a save that reports success discards the import. Scalars set
through `setValue(..., { shouldDirty: true })` are marked either way, which hides the gap. The
subscription is `useFormState({ control })` plus the destructuring, in the render body; calling the
hook alone is not it.
`useSuggestedDiveNumber`, `new-dive-page-content.tsx` and `dive-form-fields.tsx` read
react-hook-form's own `isDirty` for unrelated UX and are unaffected.
## A deleted trip or dive site can hand its dives to another one on the way out
The delete confirmation for a trip or dive site offers to move its dives first: same button, same
dialog, one dropdown always on screen. `DELETE /trip/{uuid}?move_dives_to={uuid}` re-points the
dives and deletes the trip in a single transaction, so either the log ends up on the replacement and
this trip is gone, or nothing happened — the browser has no ordering to get right and no half-done
state to report.
Rejected: moving client-side by paging `GET /dives?trip_uuid=`, sending one `PATCH` per dive, then
deleting. A failure partway leaves some dives moved and the trip standing, a liveaboard costs forty
round trips, and any dive added between the last page fetch and the delete is stranded.
## Delete with move: The dialog says what deleting does, and asks the API for no count
The dialog states that deleting removes this trip from every dive logged on it and touches no dive.
That is true at any count, zero included, because the API hides soft-deleted trips, dive sites and
gear from every dive read (`get_trip_uuids_by_ids` and `get_dive_sites_for_dive(s)` filter
`is_deleted`). So the dialog asks the API nothing before wording itself: no count request, no
loading or "couldn't check" state, no disabled Delete.
The picker is unconditional, and its empty state is the plain delete — no synthetic "None" row, the
shape `TripCombobox` uses for a dive with no trip. Rejected: a "Move N dives to another trip first"
checkbox revealing the picker, which needs `GET /dives?trip_uuid=X&items_per_page=1` for
`total_count`, a timeout race against `apiClient` setting no axios timeout, and a failure state.
A confirmation that must ask the API a question before it can word itself has the wrong wording.
## Delete with move: The toast names the destination, and the call site is where both halves are known
"Trip deleted successfully. Its dives moved to Cebu 2026." is assembled at the four `onConfirm` call
sites from the name the dialog hands back beside the uuid, and `useDeleteResource.confirmDelete`
takes an optional `successOverride` for it. The sentence carries no count, so nothing crosses the
round trip: the name is known synchronously at confirm time.
Rejected: a count in the sentence. The count would come from the delete response and the name from
the picker, so a hook would have to carry the name across in a map keyed by the id being deleted —
two rows can be deleted at once, and responses return in any order.
Both deletes are typed `{ message: string }` in `lib/api/client.ts`; nothing here reads a delete
response. `successMessage` on `useDeleteResource` is a plain string, not `(result, id) => string`,
and the hook has no generic over the response.
## The confirm button reports only its own delete
`isDeleting` is `deletingId === pendingId`, not `deletingId !== null`: the latter is true while any
row is being deleted, so opening the dialog for a second trip during the first's request shows it
disabled, spinner and all. The dialog is closed for its own delete anyway, since `confirmDelete`
clears `pendingId` before it starts.
`deletingId` in `useDeleteResource` stays a single value across every call site: a second delete
overwrites it, so the first row's spinner stops and its trash button returns while its request is
still out. The cost is cosmetic and bounded — firing the same delete twice yields two success
toasts, both routes being idempotent. The fix, a `Set<string>` with an `isDeleting(id)` helper
across every caller, is wider than the symptom justifies.
## Delete with move: The picker, and what it deliberately does not do
It excludes the resource being deleted — the API answers that choice with a 422, so listing it
offers an error.
It is a bare `CreatableCombobox`, not `TripCombobox`: that carries name-lookup for a uuid handed in
by a form, and this field only holds what the diver picked from its own menu. No "Add new..." either
— a brand-new empty trip is not what "move these somewhere" means, and the create dialogs are one
page away.
`ConfirmDialog`'s `children` slot keeps this one dialog rather than a second layered on top. Its
`confirmDisabled` is separate from `isLoading` because an unfinished choice must still be
cancellable.
Title and description live in the component's own `COPY` table beside the placeholders and search
functions, not as props: the wording is kind-specific and the pages have no say in it.
`confirmMessage` on `useDeleteResource` is optional, and these callers omit it.
## An empty picker is an answer; a half-typed one is not
`CreatableCombobox` has three states: empty, chosen, and text resolved to neither.
`handleInputChange` re-fills the selection only on an exact match, and the blur commit
(`commitAction`) resolves a prefix to `{ type: "clear" }`, so Delete over "Ceb" would silently drop
the move.
Delete is blocked there, with a line saying so, not guessed: a prefix can match several trips. An
empty field stays a valid answer. It arrives through one optional prop, `onTextChange`, from an
effect on `inputValue` — `value` cannot tell mid-word from chose-nothing, and the dialog's debounced
(250 ms) `onSearch` wrapper loses to a fast click.
Disabling alone is no guard: `disabled:pointer-events-none` on `Button` lets the press reach
`DialogFooter`, whose default action blurs the picker, re-enabling the button before `click`
dispatches. `DialogFooter` in `ConfirmDialog` carries
`onMouseDown={(event) => event.preventDefault()}`, as `CreatableCombobox` does on its menu rows.
jsdom performs no default focus change; `confirm-dialog.render.test.tsx` pins only the
`preventDefault` call.
## The confirmation focuses Cancel, because its `children` may open a menu
Radix focuses the first tabbable descendant of `DialogContent` on open. With a field in
`ConfirmDialog`'s `children` slot that field is first, `CreatableCombobox` opens its menu `onFocus`,
and the menu is `absolute z-50` — it paints over the footer rather than pushing it down, so the trip
list covers Cancel and Delete, a click aimed at Delete picks a destination the diver never chose,
and every confirmation fires a trip or dive-site lookup, plain deletes included.
`onOpenAutoFocus` calls `preventDefault` and focuses `cancelRef.current` explicitly. Cancel is the
right target on its own terms: Enter should not be the destructive key. A slot component inherits
the focus behaviour of whatever it is given, so `ConfirmDialog` names its own focus target.
## `excludeIds` hides a row; it does not make the item unpickable
`CreatableCombobox` applies `excludeIds` in `visibleItems`, the rendered menu, while the exact-match
paths — `findExactMatch` on every keystroke, `commitAction` on commit — read the unfiltered result
list. So a hidden item is pickable by name: typing "Blue Hole" while deleting one of two dive sites
called "Blue Hole" resolves to the one being deleted and sends `move_dives_to` equal to the uuid in
the path, which the API answers with "A dive site cannot be moved onto itself."
The delete dialog filters the target in its own `search` wrapper, so it never enters the result
list, the exact-match lookup or the seen-options map, and passes no `excludeIds`. The component's
exact-match paths are not taught `excludeIds`: its only other consumers are append-only
multi-selects whose `keepOpenOnSelect` branch never reaches the blur commit. The test
`will not resolve a typed name to the target itself` catches a second single-select that passes
`excludeIds`.
## Delete with move: Per-target state resets during render, not in an effect
The chosen replacement belongs to the trip it was picked for and must be gone when the dialog opens
on another, or confirming would send the old destination for the new trip's dives. An effect paints
one frame of the previous answer first; a `useLayoutEffect` avoids the frame but trips
`react-hooks/set-state-in-effect`, an error here. The dialog compares the target against the one the
last render was for and resets during render — React's own
[adjusting state when a prop changes](https://react.dev/learn/you-might-not-need-an-effect) — and
React re-runs the component before anything reaches the screen.
The map of options the picker has seen is a ref, and touching a ref during render is its own lint
error, so it is never cleared. That is safe: an entry is only read for an id the current menu just
offered, and the search that offered it already wrote a fresh entry under that id.
## A gear set's members are sent on every save, like a trip's locations
`PATCH /gear-set` treats an absent `gear_item_uuids` as "leave the members alone" and any present
one, `[]` included, as a wholesale replace (`replace_gear_items_for_set`). `GearSetDialog` sends the
key on all three flows — edit, create, and saving a dive's gear over an existing set — and
`gear-set-dialog.render.test.tsx` asserts what each sends.
Echoing the picker back is safe because a set read carries every member: gear items are hard-deleted
and `gear_set_item.gear_item_id` is `ON DELETE CASCADE`, so a rename can destroy no hidden row.
`TripDialog` sends a trip's parts on the same rule ("Locations are always sent on edit, never
omitted"): the form knows the whole set, and `[]` means the diver emptied the field.
Rejected: omitting `gear_item_uuids` unless `dirtyFields` marks the picker touched. `dirtyFields`
answers for one flow only; the two seeded via `reset` (`initialItemUuids`, the set's own read) would
become renames. The absent-key path `get_gear_items_for_set` documents is exercised by no client.
## The create form proposes no cylinder, and the last one is removable
`emptyDiveFormValues` seeds no mixture, for `defaultValues` and `prefillFromLastDive`'s fallback
alike, the remove button in `mixture-fields.tsx` has no `index > 0` gate, and `buildDiveCreate`
sends `normalizeMixtures(data.mixtures ?? [])`, so an untouched gas card reaches `POST /dive` as
`mixtures: []` — which `DiveCreate.mixtures` (`default_factory=list`) and `dive-mixtures-card.tsx`
expect.
Gas alone goes unproposed: `diveModWarning` computes a MOD from whatever cylinders the form holds,
so a seeded cylinder of air raises an oxygen-exposure warning past ~56.7 m about gas the diver never
entered.
`DEFAULT_MIXTURE` stays for "Add tank" and the placeholders; `prefillFromLastDive` copies the
previous dive's cylinders and invents none. The ungated remove button also lets the edit form clear
a dive's cylinders (`DiveUpdate.mixtures` accepts `[]`) and carries an `aria-label` naming its tank.
`app/dives/new/page.render.test.tsx` stubs the API and asserts what `createDive` receives: `[]`
untouched, `[]` after add-then-remove, the cylinder when one is entered.
## A dive-level select carries the same three states, and the two submit paths disagree about the third
`water_type` and every other dive-level `<select>` (`DiveVocabularyField`) carry the
`""`/`null`/`undefined` tri-state the cylinder `role` field has — see "The 'cleared field resets to
default' React Hook Form quirk" and "The API sends `null` and the form schema wants `""`, so
`toDiveMixtureInput` converts at the boundary". `""` is the live cleared state and what
`diveToFormValues` seeds from `null` — never `undefined`, or react-hook-form re-displays the
default. `undefined` means untouched and is dropped from a PATCH; `null` means cleared and is sent.
`buildDiveUpdate` converts `""` to an explicit `null`: an edit may already hold a value, and
dropping the key would keep it while the toast says otherwise. `buildDiveCreate` omits it instead,
as with `trip_uuid`. Neither sends `""`, which `WaterType` (`StrEnum`) and `DiveCreate`
(`extra="forbid"`) reject with a 422.
Altitude is `min="-450" max="6500"`, mirroring `ck_dive_altitude_range`, not Visibility's `min="0"`
(the Dead Sea). Both prefill from the last dive like weight; `bottom_temperature` does not, being a
reading of the day.
## A start pressure of 0 is not a low reading, it is a missing one
`diveMixtureSchema` rejects a non-positive `start_pressure` and accepts an `end_pressure` of 0: a
dive cannot start on an empty cylinder, only end on one. Domains: `start_pressure ∈ (0, 350]`,
`end_pressure ∈ [0, start_pressure]`, plus blank. The message says blank means unknown, which a
diver will not guess.
`max(350)` mirrors the API's `gt=0, le=350` and `ck_dive_mixture_*_range` and, like `po2_limit`'s
band, catches unit errors — psi in a bar box, millibar, a summed sidemount pair. It sits on both
fields: the `end <= start` refinement returns early when start is blank.
`toDiveMixtureInput`'s `??` stays: coercing a stored 0 to `""` would hide the data the bound forbids
rather than show a message, and the API's read schema stays unbounded so the fallback is a form
error, not a 500.
Tests assert the message string; `dive.render.test.tsx` has a second harness rendering the real
`MixtureFields` so a rejected value reaches its `<FormMessage />`.
## Units convert at the edges: metric state, one formatter module
Everything stored or sent is metric. The `units` preference changes display and entry only, in
`lib/units.ts` (`formatDepth`, `unitWord`) and `UnitNumberInput`. `useUnits` reads `useAuth`,
defaulting to metric; render tests mock `@/contexts/AuthContext`.
`UnitNumberInput` renders feet and commits metres, so form state, `lib/dive-mixtures.ts`
(`METERS_PER_BAR = 10`) and `lib/validations/dive.ts` stay unit-blind; display-unit state would need
per-system Zod factories. `toCommittedMetric` inverts before rounding to 2 dp, so whole imperial
values round-trip (`units.test.ts`). `visibility` and `altitude` are `Integer`, so 50 ft reads back
49 ft. A typing draft defers correction to blur. `displayBound` converts metric `min`/`max` inward
(minimum up, maximum down); bound messages name both systems, computed from `PSI_PER_BAR`.
Charts divide by `PROFILE_CHANNELS[*].scale` (`toChannelSeries`/`toPressureSeries`) before
converting; depth and deco ceiling share one `CHANNEL_DIMENSION`. Not converted: `po2_limit`,
`surface_pressure_bar`, percentages, coordinates, duration, and tank volume (no rated-pressure
column), relabelled only; see "Cylinder presets are named AL/HP/LP, and every one of them is offered
in both systems".
## The trip form's map is always on screen, and its fields run name, parts, notes
`TripDialog` renders `LocationsMap` unconditionally; `showWhenEmpty`, an opt-in prop, draws the
whole world until the first place is picked. A frame appearing with the first place shoves the lower
fields down mid-edit, and an empty map says the field above wants a place, not free text — as
`MapField` does with `MapPicker`.
`showWhenEmpty` stays opt-in: a dive or a site is at one spot, and the world says nothing about
which, so their cards and heroes draw the map's water for one with no position. A trip's card and
hero opt in and show a trip with no place as the whole world, since a list mixing cards with and
without a map reads as two layouts. The empty frame's aria-label is
`Map of the world, awaiting ${subject}`, each `subject` being a definite noun phrase.
The empty view is `WORLD_CENTER` at `MIN_ZOOM` (0) from `lib/basemap.ts`, read by both maps.
Field order: name, parts and map, notes — a part carries its own dates, so there is no trip-level
date row, the place search answers with the map, and the growing block sits last-but-one. `/privacy`
names both forms.
## A trip part is added empty, and filled in where it sits
`TripPartsField`'s Add button appends a part with no place and no dates, and the row is where all
three are set. The place search is per row rather than once at the foot of the field: the half a
diver has is not always the place (a travel day is dates alone), and a wrong pick is then corrected
in place instead of removed and re-added at the end of an ordered list.
A per-row single-select wants the append-only field's Enter-only rule without its cleared input and
open menu, so `CreatableCombobox` takes `commitOnEnterOnly` — `keepOpenOnSelect` implies it. Without
it, typing "phil" and clicking Save files a place called "phil".
## A geocoder row's hint is its region, composed once
`GeocodeResult` carries `region` and `country`, and every geocoder row shows its region, since that
is what separates two Moalboals. A row reads as one line — the combobox joins name and hint with ",
" — so `unrepeated` (`lib/locations.ts`) drops a hint part equal to a whole part of the row's name.
The dive site search shows the bare name with `region, country` as the hint, through
`geocodeResultLabel` and `formatPlaceContext`.
A row with no name of its own reads as its `location` alone, which already holds every part a hint
would add. Read as one line, a row says what picking it saves.
The trip menu puts the API's `location` whole in the row's `name`, "Moalboal, Cebu, Philippines",
which is also what a pick saves: `handleSelect` writes `name` into the input and `commitOnEnterOnly`
matches Enter on it, so a bare name would put "Moalboal" in the field after a pick and make
"moalboal" + Enter pick a place.
## The label a place keeps is chosen on the way in
`geocodeResultToLocation` (`lib/locations.ts`) stores `GeocodeResult.location` unchanged as the
place's `name`, for a dive site and a trip part alike: the place, its region and its country, "Ko
Tao, Surat Thani Province, Thailand". A pin's answer has the same shape.
The API composes it, once, for both providers. A pin's answer holds no town to compose from, so a
join on the web would build the search path's name here and leave the pin's on the API's — one
string built in two places. The region stays because a name cut to the place and its country is
often ambiguous, and prefilled it costs the diver nothing.
A place carries no second, fuller name: where it has coordinates, its town, region and country can
be looked up again. One saved before keeps the name it was saved with until re-picked.
## The geocoder's attribution is a wire format, not display copy
`GeocodeResult.attribution` carries its licence link in the one markdown shape `parseAttribution`
reads, `[label](href)` — the shape `DEFAULT_TILE_ATTRIBUTION` uses — and the client parses it under
the place picker and the dive site map. The OSMF guidelines ask that the origin and licence be
reachable, "for example by making the text a clickable link", and a linked label fits the 325px a
375px phone leaves the dialog, where a bare URL wraps.
The client is safe in either merge order: `parseAttribution` on a string without markdown returns a
single text run, so an API serving the bare string renders as before.
`Attribution` (`components/attribution.tsx`) renders the parts for all four credits — two map
corners, two lines of fine print — as inline elements with no styling. It is the enforcement point
for `react/no-danger`: the value comes from an environment variable or whatever `GEOCODER_URL`
answers.
## The place-search credit holds its line open, and does not chase the dropdown
The open menu (`absolute z-50`, `max-h-60`) covers the picker's credit until a place is added. That
is no licence problem: the OSMF guidelines oblige the application, not each result. A sticky footer
inside `CreatableCombobox` is rejected — `scrollIntoView({ block: "nearest" })` would park the last
option under an overlay it cannot see, an anchor would sit inside `role="listbox"`, a link would
race the closing blur, and every other consumer gains a prop for one.
The credit must not materialise: it would shove the map and Notes down the dialog mid-edit. Its line
is held open with `min-h-4`, costing a blank 16px when a saved trip opens without a search. It
matches the tile credit's size and has no `Place search:` label, which only made it wrap. The two
credits stay separate: the basemap comes from `MAP_STYLE_URL`/`MAP_ATTRIBUTION`, place names from
the API's geocoders, and a self-hoster configures the two apart.
## Three roads to a position, so the geocoding lives in a hook above the map
A position arrives three ways — pin, place search, pasted latitude/longitude pair — and the dialog
holding the coordinates sees them all, so the reverse geocode and its guards live in
`hooks/useGeocodedLocation.ts`, used by `DiveSiteDialog` and `LocationSearchDialog` alike;
`MapField` renders map and credit.
**A reverse-geocoded place is a name and nothing else**, where a forward search fills the whole
place. The coordinates that come back are the _site's_ — it is the pin the diver just dropped that
was looked up — so taking them would file the wreck as the centre of the town around it, on the one
path where the two points are identical and nothing on screen could show it. The Location field
holds the whole object and shows its name, so a save sends back the centre and the extent a pick
brought with it; typing a name replaces the place outright, and clearing the box sends an explicit
`null`.
The guards — newest request wins, the reply checked against the fields as they stand on arrival,
`unknown` never clearing a field while `nameless` does, a nameless answer silent with nothing to
clear — are reasoned in "The map writes into the coordinate fields".
Pasting a pair places the site and triggers the lookup; typing digits does not: `useWatch` fires per
keystroke into a one-request-a-second rate limit.
The dialog stays mounted, so the hook takes `open` and resets on it in an effect carrying the
`react-hooks/set-state-in-effect` disable `useDialogApiError` uses (synchronising to an external
prop is the rule's escape hatch), bumping the request counter so a reply in flight is discarded.
## A dive site's place is searched for in its own dialog
The button beside Location opens `LocationSearchDialog`: Location as a `PlaceSearch`, the coordinate
pair and a map, all held apart from the site form until "Use location" writes the pair and the
place. It opens on what the form holds: a position is looked up as a dropped pin, and a name without
one is searched for through `initialQuery`.
`PlaceSearch` shows the dialog's place as its value and files Enter-committed text as a name-only
place. It sets `commitOnEnterOnly`, because a single-select's `handleInputChange` and blur pick an
exactly-typed name, and a pick here moves the pin.
Searched places are not rounded; only `MapPicker`'s `emit` rounds, for its own echo.
Its credit is its own, held open with `min-h-4`. The map is `h-40 sm:h-48` like `LocationsMap`, and
the `map-field.tsx` skeleton matches. `/privacy` §4.5 covers both forms.
## The species picker resolves a pick into a catalog row before form state sees it
Search returns catalog rows (`uuid`) and upstream-only rows (`aphia_id`); only the former attach.
`SpeciesMultiSelect` calls `POST /species/resolve` at pick time; a synthetic `aphia:` value resolved
on submit puts a non-uuid in a sighting's `species_uuid` and ties saves to upstream. Pending picks
are local rows (`aphia:` id in `excludeIds`); via `onPendingChange`, `DiveFormActions` disables
submit as "Adding species...", not `isSubmitting`. `appendSighting` reads an eagerly claimed ref,
not `value`: `keepOpenOnSelect` allows concurrent resolves. No free-text hatch: a global row has no
owner. Prefill (`new-dive-page-content.tsx`) skips species but lists them in `form.reset` as `[]`.
`speciesDisplayName` falls back from `common_name` to the binomial; `hintFor` shows `matched_name`
verbatim, any language. `"unknown"` is the API's rank sentinel from both `_wikidata_result` and
`_worms_taxon`; `speciesRankLabel` drops it and `speciesNameWithRank` (appending any rank but
"Species") keeps a live guard. Ranks vary by register; never key on one. The picker keeps the API's
order (`visibleItems`, no `sort`) and `hintFor`'s row-relative redundancy check.
## The species picker reads the diver's suggestions, and says why a row is first
The picker reads `GET /user/species/suggest` with the form's sites and held species: the species
logged at those sites by dives there, then the diver's others by last seen, then the catalog
search's rows. The tiers are merged on the API, so one request answers each query and the held
species are left out before the page is cut, which is what refills it after a pick. Every query is
asked, one letter included, since the diver's own species filter from the first; `minSearchLength`
stays 2 so a one-letter miss says "type a species name". `hintFor` appends `3 dives here` or
`last seen <date>`, since a diver reads rows rather than an order. Section headings were rejected
for the reason the people pins record: they would teach the combobox unselectable rows.
## The Home page's figures are the hero's, and a zero is left out
The logbook's figures sit in the Home page's map hero (`components/home/home-hero.tsx`), as a record
page's do, rather than in a card under it. `species_seen` and `dive_site_count` are the API's,
derived over live dives on every read or write; Destinations is the length of `/user/trip-places`. A
count at zero is left out rather than shown, since there is nothing to count yet. Depth is in whole
units and time in whole hours past the first: a career's totals, where the remainder is noise.
## What actually keeps a species search from leaving is the cache, not the catalog
`/privacy` §4.6 discloses the species picker's two providers. `search_species` runs `_local_search`
and `_remote_search` every time; a catalog hit changes what is offered (a `uuid` row), never whether
providers are asked. What the picker's route skips is the search itself: a query under two
characters, or one the diver's own species fill a page with, never reaches `search_species`. The
shared cache stops a search leaving: `_cache_key("search", query)` is keyed on the normalized query
alone, so instance-wide, and a complete answer is held thirty days (`_HIT_TTL_SECONDS`), a partial
fan-out one hour, hence "a month", not "always". The shared catalog stops a resolve leaving:
`resolve_species` returns early on `_species_by_aphia_id`. Resolving contacts both providers,
`AphiaRecordByAphiaID` then `_wikidata_by_aphia_id` (`haswbstatement:P850=<aphia_id>`, source of
`wikidata_qid` and the common name), so the AphiaID leaves only at pick time; `_wikidata_search`
sends the typed query with a bare `haswbstatement:P850`. Emptying `WORMS_API_URL`/`WIKIDATA_API_URL`
is not the escape hatch §4.4 offers for tiles: search degrades to the catalog and resolving a new
species fails. WoRMS is the taxonomy, Wikidata the common names; the section mirrors §4.5's three
points.
## Web config is read at runtime, and the browser is handed it
`NEXT_PUBLIC_*` literals are inlined at build, freezing the build machine's values.
`lib/runtime-config.ts` reads `SITE_URL`, `CONTACT_EMAIL`, `GOOGLE_CLIENT_ID` and the basemap
variables server-side; `contexts/ConfigContext.tsx` hands the browser its share. The `NEXT_PUBLIC_`
fallback is read as `env["NEXT_PUBLIC_" + name]`, never a literal (frozen to `undefined` at build);
unprefixed wins, blank means unset. Root metadata is `generateMetadata()`, not a `metadata`
constant; routes are dynamic anyway (`headers()`). `metadataBase` is `new URL(...)` per request, so
a malformed `SITE_URL` warns and falls back to `http://localhost:3000`, failing open like
`lib/api-base.ts` and `lib/basemap.ts`. `runtimeConfig()` reads lazily and memoizes; `src/proxy.ts`
memoizes its CSP sources so `basemapOrigins` warns once. `resolveBasemap()` applies defaults
(light-set-means-both) to configured values passed as data, refusing a style URL without
attribution. `useConfig()` has a real default, unlike `useAuth()`, for component tests. The basemap
lands in `connect-src`; `img-src` is `'self' data: blob:` plus this instance's API origin when
split-origin, no third party.
## The privacy page describes no usage analytics, because none exist
The Gravatar gate is gone with Gravatar itself; see "Avatars are this instance's own, and there is
no Gravatar fallback". What stands is the analytics half: the privacy page describes no usage
analytics, because none exist and the CSP forbids one structurally (`connect-src` names the API and
nothing else). Template claims like "Usage Data: pages visited, features used, time spent", "analyze
usage patterns" or an "Analytics Cookies" bullet do not belong there: a policy that overstates what
is collected is not the safe direction to be wrong, since it is the document a reader uses to decide
whether to trust the rest. The daily totals are not usage analytics in that sense: per-day counts of
the whole copy — accounts created by door, sign-ins, active accounts — naming nobody, and §2.2
admits the one-sign-up-day inference rather than calling them anonymous.
## HSTS is decided per request, and omits `preload`
`Strict-Transport-Security` is set per request in `src/proxy.ts`, not in `next.config.js`'s
build-time `headers()`, where the build machine decides a security header for someone else's domain.
Per-request buys the condition: the header goes out only over HTTPS, judged by the first entry of
`x-forwarded-proto` (the client-facing hop; proxies append) or else the request's own scheme. A LAN
instance on `http://192.168.1.4:3000` handed a two-year pin becomes unreachable once recorded.
`WEB_HSTS=off` leaves it to the proxy in front. `preload` is omitted: the browsers' list commits
every host under a domain for years, an operator's choice, not an application's. `includeSubDomains`
stays: scoped to the serving host, undone by letting max-age lapse. The header rides only responses
the matcher covers, documents rather than `_next/static`, enough since HSTS is recorded per host on
the first navigation. `next.config.js` keeps the headers identical for every deployment (`nosniff`,
`X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`): static fact versus instance decision.
## `WEB_NOINDEX` is two mechanisms, because `Disallow` is not `noindex`
With the flag set, `app/robots.ts` answers `Disallow: /` and `src/proxy.ts` adds
`X-Robots-Tag: noindex, nofollow` to every page. Doing only the first is the common mistake:
`robots.txt` asks a crawler not to fetch, which is no promise not to list. A URL learned elsewhere
can be indexed unfetched, and a page never fetched is one whose `noindex` is never seen, so the two
directives cover disjoint cases. `robots.ts` needs `await connection()`: Next prerenders a route
handler that reads nothing request-scoped, which would resolve `WEB_NOINDEX` on the build machine
and freeze the answer into the image, the trap `lib/runtime-config.ts` exists to avoid, arriving
through a file convention. The default is allow: no `robots.txt` reads as no restriction.
## `/healthz` is shallow on purpose
The container healthcheck in `Dockerfile` asks this route, which reports only that the process
serves HTTP. It checks neither Postgres nor Redis: the web container talks to neither, and a slow
database it never uses must not restart a container that renders fine; the API has its own readiness
probe. `await connection()`, because a handler with no request-time API is prerendered and served
from disk, and a health endpoint running none of the app's code is a strange thing to trust. The
`src/proxy.ts` matcher excludes it, like `api/v1/`: a policy about scripts and styles says nothing
about two words of text, and it keeps a fresh nonce off a path hit every thirty seconds. The check
is a `node -e` one-liner in exec form: the image ships neither `curl` nor `wget`, and with no shell
involved `process.env.PORT` is read by node at run time, so a moved port works.
## The image builds once per architecture, and a `v*` tag is checked against `package.json`
`.github/workflows/publish-image.yml` publishes `ghcr.io/opendiving/opendiving-web` on a `v*` tag or
a rebuild dispatch; `:edge` is its own section. `linux/amd64` and `linux/arm64` build natively,
never under QEMU; a `merge` job builds the manifest list. `docker buildx imagetools create` sets
`X.Y.Z`, `X.Y`, `X` and `latest` in one call; the major alias starts at `1.0.0`. Every tag push must
be plain `vX.Y.Z` equal to `v` + `package.json`'s version, before any push; `^v[0-9]` would pass
`vnext` as a `sha-` image. The extra-tag input refuses `^v?[0-9]`, which overwrites real aliases.
The api repo's workflow is this policy twice (hand-assembled tags, not `docker/metadata-action`);
change both together, `publish-release` included. `pr-title.yml`'s labelling job sets one of
`breaking`/`feat`/`fix` for `.github/release.yml`, removes the others, and skips fork PRs (read-only
token). The release is published, not drafted: `opendiving/opendiving`'s coordinator tags both
components before itself, its release being the hand-finished one; going live before the product's
guard is the accepted cost.
## Publish Image builds without a cache
A `type=gha` cache in `main`'s scope can be written by any step of any run on `main`, whatever its
token, and by a dispatch building any ref, so a layer planted by unreviewed dependency code would
ship in `:edge` and be deployed. What it saved, `npm ci` on an unchanged lockfile, took about as
long again to export. Rejected: `type=registry` on GHCR, which only this workflow can write but
which puts a `buildcache-*` tag at the head of the public package page and an untagged version under
it per build. A returning cache needs a store only this workflow writes, and
`no-cache-filters: runner`. For the same reason the smoke and measure scripts, which run on the host
beside the registry login, come from `github.workflow_sha` unless the build is a release; the
measure script still serves the built tree's styles.
## The install lives in the product repository, and this README points at it
An install is one compose file naming both `web` and `api`, so neither component repository can hold
it without the other carrying a copy. It lives in `opendiving/opendiving` with its docs, and this
README links there rather than paraphrasing: two copies diverge, and the wrong one is the one the
reader found first. `README.md`'s _Full self-hosting docs_ goes to `.../opendiving/tree/main/docs`;
`landing-page.tsx`'s `SELF_HOSTING_URL` and the support page's _Self-hosting quickstart_ land on
`https://github.com/opendiving/opendiving` itself, which carries pitch and commands. What stays here
the bundle has no reason to know: building this image yourself, and `NEXT_PUBLIC_API_URL` as a build
arg for split-origin deployments. The README says where the project's instance is named (the front
door, `opendiving.app`) rather than copying the address; a URL in three repositories goes wrong in
two. `SECURITY.md`'s "that includes the instance this project runs itself" only closes the reading
that the project's own server is fair game.
## The landing page can only claim what the instance can back up
This page is the unauthenticated view of somebody's server, so a claim not checkable against this
repository or the running instance makes a liar of the self-hoster. The coloured band carries the
vendor-shutdown argument (Movescount, Deepblu, Diveboard) and three facts (licence, no trackers,
export formats) in full `text-primary-foreground`, never `/70`: 4.90:1 against 3.4:1 on `bg-primary`
in dark mode, the `--coral-solid` constraint. No store badges; prose says no mobile app can be
installed yet. The hero sells the log, not the deployment: most visitors are divers, not
self-hosters. `<h1>` "The Ultimate Diving App" stays by the owner's call; `app/page.tsx`'s `title`
changes with it or not at all. Data ownership lives in the `#features` card "Yours to Keep", the
band (as the export) and `#self-hosting`. Nav is Features, Self-hosting and `Source`. The footer's
"Open source diving platform for the global diving community" states audience, not traction; leave
it.
## `scroll-padding-top` on `html`, because the sticky header eats anchor targets
The `sticky top-0 z-50` header hides the top 69px of any anchor target. One declaration in
`globals.css`:
```css
:root {
--header-height: calc(4.25rem + 1px);
}
html {
scroll-padding-top: var(--header-height);
}
```
On the container, not `scroll-mt-*` per target, so every anchor and focus scroll is covered; `html`
is what scrolls. Exactly the header's height (`py-4` twice, `size="sm"` button at `h-9`, 1px
`border-b`), rem-based to track the root font size. Overshoot is the bad direction: a flat `5rem`
exposes 11px of what sits above the target, the `bg-primary` band over `#self-hosting`; undershoot
only hides padding. `#features` (`pb-20`, no `pt-`) adds `scroll-mt-20`, safe only because the hero
above shares `bg-background`; on `#self-hosting` it would expose the band, so a section borrows
offset only when the one above shares its background. `volume-combobox.tsx` and
`creatable-combobox.tsx` scroll their own `overflow-y-auto` listbox
(`scrollIntoView({ block: "nearest" })`), which `html` cannot reach and `*` would break.
`header.tsx`'s mobile menu is its own `overflow-y-auto` panel below `top-(--header-height)`, and
scrolls itself.
## `ci.yml` and `code-quality.yml` run on a read-only token, with nothing left in `.git/config`
Both declare `contents: read` at workflow level, as `publish-image.yml` and `pr-title.yml` do.
Without a `permissions:` block a run inherits the repository default, read-write on every scope
unless narrowed, and nothing here writes. `actions/checkout` also leaves `GITHUB_TOKEN` in
`.git/config` unless told not to, and every job runs third-party code there. A read-only token does
not stop a step writing the Actions cache, which is why nothing that builds the image reads one.
`persist-credentials: false` on every checkout costs nothing, no job using git afterwards;
`fetch-depth: 0` on `code-quality` is independent and stays. A writing step asks on its own job,
restating `contents: read` beside what it needs, since a job-level block replaces the workflow's;
`pr-title.yml` shows it, `permissions: {}` at the top and the labelling job asking for two scopes.
`dependency-review` writes nothing and runs on the workflow-level grant with no block. The api
repo's `linting.yml`, `tests.yml` and `type-checking.yml` carry the same two lines.
## `SECURITY.md` has two channels, and both of them exist
The primary channel is GitHub's private vulnerability reporting, `security/advisories/new`: private
by construction, thread and advisory together. A repository setting (Settings → Code security), not
a file. `security@opendiving.app` is the second channel and a real inbox. An address must pass "has
a maintainer created it and agreed to read it", not "does it look plausible" (so
`lib/runtime-config.ts` leaves `CONTACT_EMAIL` unset, and `conduct@opendiving.app` in
`CODE_OF_CONDUCT.md` is conduct only). The support form's `security` category (`SUPPORT_CATEGORIES`)
is no third channel on any instance, even the project's own: an unauthenticated public form posting
to whatever address the instance configured, without advisory-thread privacy. No supported-versions
table and no SLA: `publish-image.yml` aliases one tag and nothing is backported, so "the latest
release" is the whole answer, and no rota backs a 48-hour acknowledgement or 90-day clock. Reporters
are asked to nudge after a couple of weeks and name their own deadline.
## The CSP nonce is 16 random bytes, not a stringified UUID
`src/proxy.ts` builds the nonce as
`Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64")`, not
`Buffer.from(crypto.randomUUID()).toString("base64")`. Not a weakness fix: a v4 UUID carries 122
bits and is unguessable, but CSP Level 3 asks for 128, and "why 122?" is a question that does not
belong in this app's most security-critical file. It is also 24 base64 characters against 48, on a
header naming the nonce twice per response. `crypto.getRandomValues` and `Buffer` exist in either
runtime, and Proxy runs on Node regardless: Next 16 defaults it there and forbids the `runtime`
export in a proxy file
(`node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`). Base64 padding
is legal in a nonce (`base64-value` ends in up to two `=`), which matters because 16 bytes always
yields `==`. `src/proxy.test.ts` reads the nonce with `/'nonce-([^']+)'/` and asserts two requests
differ; base64 contains no `'`.
## A pin is a promise to renew, and `.github/renovate.json5` is the renewer
Renovate, not Dependabot, which cannot read `.nvmrc`, group across ecosystems, or see a workflow's
`with:` inputs, where `setup-trivy` takes its version. No version is proposed before it is three
days old, and npm gets that cutoff as `--before` whenever Renovate regenerates the lockfile;
vulnerability fixes are exempt, Renovate's default kept on purpose.
`pinDigests: false` is load-bearing: the `Dockerfile` floats on its base tags so a Publish Image
re-run at an old `v` tag collects patched packages, which a digest pin would not. First-party
actions pin a major tag, third-party a SHA.
Node waits on `dependencyDashboardApproval`: the `24.x` in `ci.yml` and `code-quality.yml` is
invisible to the `github-actions` manager, and odd majors are not LTS.
Trivy is pinned: its database is pulled per run, so the binary's age limits only what it parses, and
`latest` runs whatever was published a minute ago. CI fetches nothing with `npx --yes`, which no
manifest pins.
## The scan that matters runs on a schedule, and both jobs use Trivy, not `npm audit`
`.github/workflows/vulnerability-scan.yml`'s PR job asks whether a change is vulnerable; the
scheduled job asks whether what people already run is, and only that drives the rebuild
CONTRIBUTING.md documents.
Both use Trivy: `npm audit` cannot see the image's Ubuntu packages, and one database serves both
jobs. `trivy fs` runs `HIGH,CRITICAL --ignore-unfixed` over production dependencies; an unfixable
finding leaves no move. `--scanners vuln` is explicit: a base-image CVE is already public,
notification rather than the disclosure `SECURITY.md` forbids.
Findings fail only the PR job; the scheduled one fails just when release tags exist and no alias
resolves. The scan set is the newest release plus `edge`, per `SECURITY.md`'s support policy. The
report is node where the api's is `python3`, the only deliberate divergence.
The PR job's `packages: read` and `docker login` authenticate Trivy's database pull from ghcr.io,
which rate-limits anonymous pulls. `declare -A TAGS_FOR=()` stays set under `set -euo pipefail`.
## The alert arrives where it can be acted on, and the scan replaces the whole set
The scheduled scan uploads SARIF through `upload-sarif`; an upload replaces its category's alert
set, so a cleared finding closes itself and a recurrence opens fresh. `sarif_file` takes a
directory, so every image's `.sarif` goes under `sarif/` and uploads once under the constant
category `published-images`: separate uploads overwrite each other, and a category per image holds
alerts open forever once an `X.Y.Z` alias leaves the scan set.
Each image is scanned twice: the first pass feeds the run summary, a second
`trivy image --ignore-unfixed` produces the SARIF, since `trivy convert` cannot filter on fix
availability. The markdown report stays because code scanning cannot say which of two remedies
applies; its footer builds absolute URLs from `github.server_url` and `github.repository`, since
page depth changes where a relative link lands. A third-party upload carries its own tool name and
category and does not collide with CodeQL default setup.
## `dependency-review` is a gate on the diff, and its licence list is derived, not edited
The action compares the PR's manifests against the base commit's and reports only what the change
introduces — not a second Trivy check. `fail-on-severity: moderate` is stricter than Trivy's HIGH
because a moderate advisory on a dependency being added now is cheap to decline.
The licence list is the union of every licence the installed tree ships: a changed dependency is
checked like an added one, so a narrower list goes red on an ordinary framework bump. Derive it
rather than editing it:
```bash
node -e 'const fs=require("fs"),p=require("path");const s=new Set();(function w(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){if(!e.isDirectory())continue;const q=p.join(d,e.name);if(e.name.startsWith("@")){w(q);continue;}const j=p.join(q,"package.json");if(fs.existsSync(j)){const m=JSON.parse(fs.readFileSync(j,"utf8"));s.add(typeof m.license==="string"?m.license:(m.license||{}).type||"UNKNOWN");}const n=p.join(q,"node_modules");if(fs.existsSync(n))w(n);}})("node_modules");console.log([...s].sort().join("\n"));'
```
It is not a compatibility check — the project is AGPL-3.0 and everything listed flows into it; the
gate makes an unreviewed licence a red check, and widening the list records the review.
`actions/dependency-review-action` and `github/codeql-action/upload-sarif@v4` pin a major tag as
GitHub's own. The job declares no `name:`, so its check is the job id `dependency-review`, named
exactly in the `main` ruleset's required checks.
## The sign-in email carries a code as well as a link, and the code is keyed on the request, not the address
`POST /auth/email/request` answers with a `request_id`; `CheckEmailCard` verifies the six-digit code
against it. Link and code share one `authentication_request` row; whichever arrives first consumes
it. A link signs in the device that opens it; a code crosses devices.
The `request_id` is the security-relevant part: six digits are contained only by the row's
five-attempt cap, and keying on the address would let anyone who knows it burn those attempts,
denying sign-in to email-only accounts. An id only the requesting browser holds is unreachable.
`AuthForm` holds address and id as one `SentLink` value; a resend replaces both and `CheckEmailCard`
clears any half-typed code.
Sanitize before bounding: a `maxLength` truncates a pasted `481 052` before `onChange` sees it.
`handleVerify` refuses a short code, which would spend an attempt. Routing is by the `redirectTo`
prop through `sanitizeRedirectPath`, not `localStorage`: the code never leaves the tab, so any
stored destination stays behind.
## The code field is Radix's one-time-password field, not one text input
`CheckEmailCard` renders `@radix-ui/react-one-time-password-field` via
`components/ui/one-time-password-field.tsx`. `validationType="numeric"` sanitizes every input path;
`autoComplete="one-time-code"` sits only on the current tab stop (`data-1p-ignore`/`data-lpignore`
elsewhere) so managers offer it once.
The root is a `role="group"` labelled by `<span id="signin-code-label">`, so tests use
`getAllByRole("textbox", { name: /^Character \d of 6$/ })`; `getByLabelText` returns the untypeable
group div instead of throwing. No submit button: `autoSubmit` and Enter's `form.requestSubmit()`
both reach `handleVerify`, whose `isCodeComplete` check alone guards against a short submission.
Every failure, 429 included, empties the boxes and refocuses the first via `restartCodeEntry`:
auto-submit fires on every change to a full field, so correcting in place burns attempts. A mount
effect shares `focusFirstCodeBox`, because `CheckEmailCard` replaces `AuthForm`, dropping focus to
`<body>`. The hint renders unconditionally; the group's `aria-describedby` names it. In flight,
keystrokes are dropped in `onValueChange` rather than the boxes made `readOnly` or `disabled`: the
second drops focus out of the group, and on iOS either closes the keyboard, which the rejection's
refocus, outside a tap, cannot raise again. Boxes are `flex-1 min-w-0`, since six 40px boxes
overflow a 320px viewport.
## `/signin`'s heading sits inside the card, and `titleAs` carries the level with it
`AuthForm` renders an icon/heading/blurb block inside its card only when a page passes `title`;
`/signin` is the only caller, since the landing page's hero already introduces the form. The heading
is an `h1` sized by classes (`CardTitle`'s `as` prop), because `/signin` has no other heading and
would otherwise fail axe's `page-has-heading-one`. `CheckEmailCard` replaces `AuthForm` rather than
rendering inside it, so a `titleAs` prop threads through: it defaults to `h3` for the landing page,
and `AuthForm` passes `h1` when given a title, so the page keeps its `h1` after a link is sent — a
state no URL scan sees. `auth-form.test.tsx` asserts the tag name in all four combinations, because
the rendered page looks identical when this is wrong.
## A 401 from a sign-in endpoint must not go down the refresh path
The response interceptor in `lib/api/client.ts` treats a 401 as an aged access token, refreshes, and
retries. For a signed-out visitor the refresh throws, so its "Refresh token missing." replaces the
API's own detail, `clearAccessToken()` runs, and `AUTH_SESSION_EXPIRED_EVENT` fires at someone who
never had a session. `SESSION_MINTING_PATHS` excludes the routes where a 401 means the credential in
the body was refused: `/auth/refresh`, `/auth/email/verify`, `/auth/email/verify-code`,
`/auth/google`, `/auth/complete`. `/auth/logout` stays out deliberately — it needs a live access
token to blacklist the pair, so refresh-and-retry is right there. Tests swap
`apiClient.defaults.adapter` and `axios.defaults.adapter` together, because `refreshAccessToken`
uses a bare `axios.post`, and keep the negative case (an ordinary request still refreshes once) so
the set cannot grow until nothing refreshes.
## Passkeys sign in twice over, and the browser's own capability is the only switch
`@simplewebauthn/browser` (v13) backs passkey sign-in. `POST /auth/passkey/options` mints a
challenge and returns its `flow_id`; `POST /auth/passkey/verify` takes `{flow_id, credential}` and
returns the same `AuthOutcome` as the other entry points. The key is `credential`, not `assertion`:
the API's request model is `extra="forbid"`, and the test in `lib/api/passkeys.ts` pins the body
shape. `AuthContext.signInWithPasskey(flowId, credential)` runs through `applyOutcome`, and
`verifySignIn` uses `authAPI`'s exported `captureSession`. `/auth/passkey/verify` is in
`SESSION_MINTING_PATHS` for the reason "A 401 from a sign-in endpoint must not go down the refresh
path" gives.
Nothing is configurable. `browserSupportsWebAuthn()` is `false` exactly where the feature cannot
work — a plain-HTTP LAN instance gets no `PublicKeyCredential` — so the UI hides on capability,
extending `GoogleAuthButton`'s returns-`null` precedent; there is no `PASSKEYS_ENABLED` knob. The
capability is read through `useSyncExternalStore` (`false` on the server), not an effect calling
`setState`, which `react-hooks/set-state-in-effect` rejects: the value differs between server and
client renders rather than changing over time.
## The form arms a ceremony nobody asked for, and that is the feature
`hooks/usePasskeySignIn.ts` runs one ceremony two ways. The explicit one is the "Sign in with a
passkey" button (click, modal sheet, real error), kept because the cross-device QR flow appears only
behind a deliberate ceremony. The conditional one arms on mount when
`browserSupportsWebAuthnAutofill()` resolves true, via
`startAuthentication({useBrowserAutofill: true})`. Nobody asked for it, so it reports nothing: a 404
from an API predating passkeys or a declined ceremony passes silently; the one silent re-arm after a
failed verify covers a challenge expired in an open page.
It arms in the landing hero too (`AuthForm` in `open` mode), one POST per signed-out page view; the
lever, if ever needed, is arming on first focus of the email input, not a lower ceiling. In `invite`
mode the hero's `InviteRequestForm` (plain `autoComplete="email"`) arms nothing unless a join link
or invite link puts `AuthForm` there; `/signin` covers the returning member. The email input's
`autoComplete="username webauthn"` is load-bearing; v13 will not arm conditionally without
`webauthn`.
## Passkey sign-in: Three things about the ceremony that are easy to get wrong
Cancelling: the ceremony is anchored to an input, so `WebAuthnAbortService.cancelCeremony()` runs in
the effect's cleanup, on unmount and at the swap to the "check your email" card; `autofill: !sentTo`
re-runs the effect there. Recognising an abort: match `code === "ERROR_CEREMONY_ABORTED"`, the
documented contract; `err.name === "AbortError"` works only because v13 copies the wrapped
`DOMException`'s `name`. A dismissed sheet (`NotAllowedError`) says nothing: the spec collapses
cancelled, timed out and nothing-matched into one error so a page cannot ask whether an account has
a passkey, and `cause` is unreachable with `lib` at `es2020`. Not touching the magic link's storage:
`signInWithPasskey` routes by the `redirectTo` prop and never calls `rememberPostAuthRedirect`, a
slot for flows that leave the tab; a test pins it. Starting the explicit ceremony cancels the armed
conditional one (v13 allows one at a time); re-arming after the modal settles is not built.
## The divider sits above the optional methods, and the header that has to keep quiet
`AuthForm` draws the single "Or" divider above the optional methods, gated on
`googleClientId || passkey.supported`, because neither `GoogleAuthButton` nor the passkey button can
know whether the other exists; the block disappears when an instance has neither. `next.config.js`'s
`Permissions-Policy` names camera, microphone and geolocation (`self`, for the site finder's _Use my
location_) and must not name `publickey-credentials-get`/`-create`, which stay at their default
`self`: naming features there is opt-in, so a directive that omits these two kills passkey sign-in
with a browser error pointing nowhere near the header.
## Signing is enforced locally, because GitHub cannot do it yet
No git setting prevents an inline `-c commit.gpgsign=false`; two hooks do.
`.claude/hooks/no-unsigned-commits.py` (`PreToolUse` on Bash) rejects a command only where git
reports the key it would disable as on, heredocs stripped first. `.githooks/pre-push` rejects
unsigned commits (`%G?` of `N`; GitHub-signed squash commits report `E`) and needs
`core.hooksPath .githooks` per clone. Both scripts are committed, hence the ignore pattern
`.claude/*` (a plain `.claude/` blocks re-inclusion); the registration is untracked
`.claude/settings.local.json`.
The single `except ValueError` is deliberate: a `python3` that cannot parse the file exits 1,
non-blocking to Claude Code — failing open. The push hook checks `git rev-list`'s exit status: the
remote's advertised sha can name an unfetched commit, and a bare `revs=$(...)` reads the 128 as an
empty range.
Rulesets: `required_signatures` on every branch except `main` (a signature-required `main` cannot
squash-merge another author's PR), a `main` ruleset requiring a squash-merged pull request, and a
`tags` ruleset on `v*`.
## Passkeys come from two places, and the enrollment nudge is the one that matters
Enrollment lives in `components/settings/passkeys-card.tsx` and the dismissible
`components/home/passkey-nudge-card.tsx`, both through `hooks/usePasskeyRegistration.ts`:
`usePasskeySignIn`'s explicit half with no `flow_id` (the bearer token names the owner) and a
client-picked name. A button precedes `credentials.create()` in both, the gesture Safari requires.
The nudge checks, in cost order, WebAuthn support, no dismissal in this browser, then the account's
passkeys, so steady state costs no request. Dismissal is per-browser in `lib/passkey-nudge.ts`: a
new browser is where the offer is relevant again. `restorePasskeyNudge` undoes it from the settings
card, shown only where a dismissal is stored. Its copy depends on the loaded list and the button
says "Undo": for a diver who has since added a passkey the offer cannot return. It is withheld only
while the list request is in flight, never on a failed load (removal needs no API), with
`nudgeWouldReturn` false when the count is unknown. `scripts/screenshots.mjs` pre-writes the
dismissal key via `addInitScript`.
## Passkey enrollment: The list is not gated on the browser's capability, only the Add button is
The settings card lists passkeys regardless of `browserSupportsWebAuthn()` and gates only the Add
button on it: a diver whose passkeys live on a phone must be able to revoke one from a laptop that
cannot create any, and a passkey nobody can see is one nobody can revoke — the reasoning behind the
API reading up to `_LIST_LIMIT` rows rather than stopping at the configured cap. The card removes
itself only when there is nothing to do: no passkeys and no way to add one, or a 404 from
`GET /user/passkeys`, which is an API predating passkeys rather than an error. Any other failure
shows the API's wording and a Try again.
## `onRegistered` runs outside the ceremony's own error handling
`usePasskeyRegistration` awaits the caller's callback after the try/catch around its three requests,
not inside it. The passkey is stored by then, so a list refresh that throws must not surface as
"couldn't add that passkey" — the diver would rerun the ceremony and meet the duplicate-credential
409 after being told the first attempt failed. A callback failure is logged instead.
## The client names the passkey, and the server only caps it
`lib/passkey-name.ts` reads the User-Agent into a coarse "Chrome on macOS", because only the browser
knows what it runs on and asking for a name before the biometric prompt puts a form in front of a
one-tap gesture. Two orderings are load-bearing and pinned by its tests: every Chromium browser also
says "Chrome", so Edge, Opera and Samsung Internet are matched first; and every browser on iOS is
the same WebKit authenticator, so the device alone is the name. An unreadable UA falls back to
"Passkey" rather than nothing, since the API's `name` is `min_length=1` and an empty suggestion
would fail a ceremony the diver has already completed.
## Deleting an account is a request with a date on it, and the date only exists once
`components/settings/delete-account-card.tsx` calls `DELETE /user` through `lib/api/users.ts`, and
the flow follows from one property of that endpoint: the purge date is composed server-side and
handed back exactly once. The grace window is `ACCOUNT_DELETION_GRACE_DAYS`, an operator knob
exposed on no config route, so the dialog can say a grace period exists but not name a day. The
account is dark the moment the call returns (`get_current_user` filters `is_deleted`,
`/auth/refresh` re-resolves the row), so nothing can be polled afterwards; the date arrives on one
response body and is carried from there to the screen that shows it.
The card, `/goodbye` and the API's confirmation email all say that signing in again before the date
brings the account back, and each carries a comment saying so — the failure mode is rewording one of
the three. The copy states what happens, in order: locked out now, everything erased later, the date
by email.
## `/goodbye` takes the date on the URL, because nothing else survives the trip
Deleting ends in `hardNavigate`, for `signOut`'s reasons: the session is over, the fetched dives and
`blob:` URLs should go with the document, and a full page load settles where the diver lands instead
of racing `useAuthGuard`'s bounce to `/signin`. That rules out React state; the access token and
refresh cookie are gone, so `sessionStorage` is the only alternative to a query parameter — a value
to clean up, invisible on reload, no less readable. The parameter is a date, not an identity.
`/goodbye` treats its input as untrusted: absent, unparseable and already-past `purge_after` each
get their own copy rather than "Invalid Date". "Already past" is reached two ways —
`ACCOUNT_DELETION_GRACE_DAYS=0`, and a reload or bookmark after the window ran out — and an elapsed
timestamp cannot tell them apart, so that branch says the date has passed; copy naming the
instance's configuration is false for the second reader.
## "Download my data first" is the archive, and it leaves the dialog open
`ConfirmDialog`'s `secondaryAction` slot carries the GDPR Art. 20 nudge and saves the archive, the
only export carrying the dive-computer files and certification scans. The dialog stays open behind
it — asking for your data mid-decision is not changing your mind.
The confirm is blocked while that export is being saved. A successful delete ends in a document
navigation, which kills every open request and object URL, so confirming mid-export destroys the
export on an account that is dark by then. The window outlasts the fetch: Firefox and Safari read
the blob after the synthetic click, so the card stays busy for a two-second settle — and in Firefox
for iOS, where the save is a navigation answered once the archive is built, for the ticket's
lifetime.
The type-your-username gate is `confirmDisabled`, compared case- and whitespace-insensitively:
friction, not a password.
## Account deletion: The token is dropped on success only
`usersAPI.deleteAccount` calls `clearAccessToken()` after the request resolves, never in a
`finally`. The server has blacklisted both tokens and cleared the refresh cookie by then, so keeping
the in-memory one would be a client pretending to hold a session the API has ended — but a rejected
call (rate limited, offline) changed nothing server-side, and clearing there would sign a diver out
of an account they still have. Same asymmetry as `signOut` after a failed `POST /auth/logout`.
## Signing in offers a deleted account back, and four entry points branch on `deletion_pending`
`AuthOutcome.status` has a third value, `deletion_pending`: the identity was verified, an account
exists inside its deletion grace period, and nothing was written and no session issued.
`AuthOutcome` is a union discriminated on `status`, so each token is reachable only under its own
status: an else branch reading `onboarding_token` after testing for `authenticated` alone is a type
error. The magic-link page, the six-digit code card, the Google button and `usePasskeySignIn` all
take their destination from that status too, instead of a `signedIn ? next : "/onboarding"` ternary
each.
## One function decides where an outcome lands
`destinationForOutcome` in `lib/auth-redirect.ts` maps a status to a path, and every entry point
passes the status it was given. The `switch` is exhaustive on `AuthStatus`, so a new status is a
type error in one file rather than a silent mis-route at each caller. It sanitizes `next` itself
with `sanitizeRedirectPath` — callers take it from a `?next=` prop or out of `localStorage`, and
making each remember is how one forgets. `next` is honoured only for `authenticated`: onboarding has
nothing to return to, and a restore drops it because the account is not back yet when the
destination is chosen.
## The entry points resolve with the whole outcome, not a status
`verifyEmailLink`, `verifyEmailCode`, `signInWithGoogle` and `signInWithPasskey` resolve with the
applied `AuthOutcome`. A status alone would do for three of them; the magic-link page chains
verify-then-restore inside one handler and needs the `restore_token` from that very call, before the
stashed `RestoreSession` exists. `restoreAccount(restoreToken)` therefore takes the token rather
than reading the stash, and `/restore` passes the one it was handed.
## `/auth/verify` chains both halves; the other three cannot
Only the magic link has a side-effect-free precheck: `GET /auth/email/verify/check` answers
`deletion_pending` as `valid: true` plus a flag, so the page's "not valid" branch stays in front and
the button can read _Restore my account_ before anything is spent. A click on that button is the
decision, so the page posts the verify and then the restore and lands on the Home page.
The chain is gated on the precheck's answer and the outcome's status, not either alone. A deletion
requested between precheck and click arrives at a button that said _Sign in_, and that click must
never quietly cancel a deletion — it falls through to `/restore`, where the offer is made properly.
A typed code, a Google dialog and a biometric gesture are commitments already made, so those three
can only offer the restore after the POST — which is `/restore`.
## `/restore` is `/onboarding`'s counterpart, down to what it cannot survive
Same shape as `/onboarding`: a verified identity that is not yet a session, an in-memory
`RestoreSession` never persisted, a redirect to `/` when opened without one, chrome-free, and
`isAuthenticated` sending an already-restored diver to the Home page. It additionally says that a
reload loses the offer, because `POST /auth/email/verify-code` claims its request row before
resolving the identity — a code spent on reaching this screen is spent, and someone who closes the
tab needs a fresh email.
`/auth/restore` is in `SESSION_MINTING_PATHS` in `lib/api/client.ts`: a 401 there is the endpoint
refusing the token in the body, not an expired access token, and the refresh path would replace the
API's explanation with "Refresh token missing." That explanation matters — "already permanently
deleted" and "this restore link has already been used" are different failures, only the first a dead
end — and is shown verbatim on both screens.
## The purge date is parsed in one place, because it arrives from two directions
`lib/purge-date.ts` holds the parse guard and the day format. `DELETE /user` hands the date to
`/goodbye` on the URL, and a `deletion_pending` outcome hands the same date to the restore screens;
both can receive something unusable — an edited query string, or the `null` the API sends for a row
with no clock to count from — and rendering "Invalid Date" to somebody reading for a date is the
failure both avoid.
## Avatars are this instance's own, and there is no Gravatar fallback
Avatars are uploaded to this instance's API, and there is no Gravatar fallback behind a flag. A
fallback would carry every cost a gate exists to bound: a third-party host in the CSP, a disclosure
on the privacy page, a hashing dependency, a `d=404` probe on every mount, and an environment
variable a released artifact must document. Initials (`getUserInitials`) are the only fallback.
`flag()` stays for `WEB_HSTS` and `WEB_NOINDEX`; its doc comment and `runtime-config.test.ts`
demonstrate the unrecognized-value warning on `WEB_NOINDEX`.
## Pictures: The digest is the whole client contract
`UserRead` carries `avatar_sha256` and `portrait_sha256`, each one nullable string answering three
questions: whether there is a picture, which version, and what to append as `?v=`. Each
`*_original_sha256` does the same for the original, and decides what "Adjust" opens: the original at
its crop, or without one the rendition, redrawn as a PNG and saved as a replacement. There is
deliberately no URL: the bytes are owner-only and the access token lives in memory
(`lib/api/client.ts`), so an `<img src>` could never load them. `UserAvatar` and `PortraitImage`
fetch through `hooks/useAuthedBlobUrl.ts` and render from an object URL. A check-in link's portrait,
at `/checkin/{token}/portrait`, is fetched with the token in the path as its only credential and
drawn from an object URL the same way. Radix's `AvatarFallback` renders until `AvatarImage` has
loaded, so in-flight, failed and no-picture are one state drawn as initials, with no broken-image
glyph. Staleness is handled by the URL: `?v={sha}` changes with the picture, and after a save the
form calls `refreshUser()`, which repaints every mounted picture in the same paint.
## The crop dialog's three traps
Never JPEG: `canvas.toBlob("image/jpeg")` composites transparency onto black. A card exports WebP
because its endpoint stores what it is given.
`react-easy-crop` injects its own `<style>` by default, which the nonce-based production CSP drops
(the dev CSP allows `'unsafe-inline'`). `disableAutomaticStylesInjection` plus
`import "react-easy-crop/react-easy-crop.css"` puts the rules under `style-src 'self'`; the
library's `nonce` prop would also work. `NonceProvider` does not cover this — it sets `get-nonce`
for `react-remove-scroll`.
The dialog must not scale on the way in. The cropper sizes itself from `getBoundingClientRect()`,
which reports the box after ancestor transforms, and nothing re-measures once `DialogContent`'s
`zoom-in-95` finishes (its `ResizeObserver` watches the layout box). The 0.95 cancels out of
`cropSize / mediaSize`, so the mask promises 95% while `croppedAreaPixels` returns 100%. The fix is
`data-[state=open]:zoom-in-100 data-[state=closed]:zoom-out-100` on this one `DialogContent`; an
explicit `cropSize` still derives media size from the scaled rect.
## Pictures: The `accept` list is load-bearing, not decoration
`accept="image/jpeg,image/png"` is what makes iPhone photos work. Since WebKit bug 267277, iOS
Safari transcodes a HEIC pick to JPEG only when the `accept` list restricts image types and excludes
HEIC. `accept="image/*"` hands over raw HEIC, which the API keeps no original of; adding
`image/heic` is worse, since Safari 17+ then delivers the original and has a documented bug
converting picked PNGs to HEIC. The constant lives in `lib/picture.ts` with the reasoning attached
so nobody simplifies it inline. A Files-app pick bypasses `accept` entirely; `readPictureType` and
the API sniff bytes regardless, so this is not a security surface.
## The caller decodes the file before the cropper ever sees it
`react-easy-crop` has no failure callback: `CropperProps` carry `onMediaLoaded` and `onCropComplete`
and nothing for the other outcome, so a source that never decodes — a Files-app HEIC walking past
`accept` — leaves the dialog with an empty frame, `croppedAreaPixels` never arriving and Save
disabled forever. `PictureField` and `CertificationCardFiles` therefore decode the object URL
themselves and mount `ImageCropDialog` only on success, toasting otherwise;
`mediaProps={{ onError }}` would catch it a frame later with a half-open dialog to unwind. A card's
second decode, inside `cropToBlob`, hits the browser cache.
Failures raised in the browser are `ImageCropError` (`lib/image-crop.ts`), because
`getApiErrorMessage` reads an axios response's `detail` and returns its `fallback` for everything
else — never a plain `Error`'s `message` — so the caller shows `message` for those and
`getApiErrorMessage` for the rest.
## Avatars: Onboarding offers the picture, and uploads it once the account exists
The profile-completion form carries an optional profile picture beside its two fields. There is no
account to upload it to until `POST /auth/complete` answers, so the form holds the crop and
`completeProfile` sends it in `onCreated`: after the session is captured, before the account is
read. The first read then already carries the picture, and `/onboarding`, which moves on the moment
`user` is set, does not move on without it. A failed upload leaves the account made — the diver is
told and lands on the Home page anyway. A pick replaces the Google picture the API imports; without
one, a Google sign-up keeps it.
## Signing is a maintainer's setting, and the hook checks before it blocks
Contributors do not sign (`CONTRIBUTING.md`, _Pull requests_), and `core.hooksPath .githooks` sits
under _For maintainers_. No hook reaches a stranger's clone; `main`'s provenance is the squash
commit GitHub signs itself, so the merge method stays squash-only — rebase-merge would copy branch
commits up unsigned.
The Claude hook blocks a signing-disabling command only when `git config --type=bool --get` prints
`true` for that command's key (`commit.gpgsign` or `tag.gpgsign`), in the hook's cwd, not
`$CLAUDE_PROJECT_DIR`. Since a deleted config silences it, the patterns also match key- and
section-named removals (`--unset`, `--unset-all`, `unset`, `--remove-section`, `--rename-section`)
and every quoting of `false` or empty, with word ends `(?![^\s;&|()<>])`. Every gate failure fails
open, as would an exception escaping `main()`.
The script is committed; its registration lives in `.claude/settings.local.json` — see _"The skills
are repo content; what wires up the hook is not"_. Both hook files are byte-identical with
opendiving-api's, whose `DECISIONS.md` carries the long form.
## The PR template restates CONTRIBUTING, at the moment those asks are answerable
`.github/PULL_REQUEST_TEMPLATE.md` adds no demand. What changed and why, the pair of links tying a
cross-repo change to its [opendiving-api](https://github.com/opendiving/opendiving-api) PR, a
`DECISIONS.md` section when something bit you, screenshots when anything visual moved — all already
in CONTRIBUTING's _Pull requests_ and _Two things that will bite you_. The template puts those
questions where they are answered. Its wording tracks CONTRIBUTING's, since two phrasings of one ask
read as two; when one moves, move the other in the same PR.
Prompts, not checkboxes: a checklist trains ticking, and a box nobody verifies buys nothing. Bold
questions deleted when they do not apply still catch the omission that recurs: a web change landing
with its API sibling unlinked.
`npm run format`'s `**/*.md` glob reaches into `.github/`, so the template is wrapped at 100
columns; hand-wrapping it for the textarea is undone by the next format run and flagged by
`format:check` in `code-quality.yml`.
## The issue forms are structured, short, and point at the other repository twice
`.github/ISSUE_TEMPLATE/` holds a bug form, a feature form and `config.yml`. They are YAML forms
because the two questions a self-hosted bug report is useless without — version and install method —
can be required, where a prose template gets them deleted. Everything else is cut: four required
fields on the bug form, one on the feature form; a form long enough to be abandoned collects
nothing.
The install-method options mirror README's three ways in, which run different code: the published
image is same-origin through `app/api/v1/[...path]/route.ts`, a self-built image may carry
`NEXT_PUBLIC_API_URL` baked in, and `npm run dev` is neither. No dive-computer or format-support
form lives here; that funnel is opendiving-api's.
`config.yml` sends questions to `opendiving/opendiving`'s Discussions — one searchable space, on the
repository a stranger meets first — and security reports where [SECURITY.md](SECURITY.md) does.
`blank_issues_enabled` stays `true`: the forms are the paved path, not a gate.
## `lint-and-build` uses no matrix and `pr-title.yml` runs on `synchronize`, so both can be _required_
A ruleset binds a required check by name and by the events it runs on; a passing run displays
neither.
`lint-and-build` in `ci.yml` uses no matrix: GitHub appends matrix values to the check-run name
unless the job's `name` interpolates them, so a one-value `strategy.matrix.node-version: [24.x]`
reports as `lint-and-build (24.x)` and a ruleset requiring the bare name matches nothing — and sits
at "Expected — waiting for status" forever rather than failing. `unit-tests` does shard through a
matrix, with the shard and total in its `name`, so its checks are `unit-tests (1/2)` and
`unit-tests (2/2)`.
`pr-title.yml` runs on `synchronize`, not only `opened` and `edited`. A push cannot change a title,
but a required check must have passed on the head SHA, so without it a second commit leaves the
check stale and the PR hanging. `label` re-runs harmlessly and is skipped on forks; only
`semantic-title` belongs in a required-checks list.
`.github/renovate.json5` and the Renovate section state that the `24.x` in `ci.yml` and
`code-quality.yml` — step names and `node-version:` inputs — is unreachable by the `github-actions`
manager.
## The Prettier check fails the build, and the axe scan stays a report
`code-quality.yml`'s Prettier step carries no `continue-on-error`: it gates with ESLint,
`tsc --strict` and `npm run build`, because the tool that reports formatting also fixes it —
`npm run format` is the entire remedy. `scripts/axe-report.mjs` stays a report because axe has
untriaged findings; it fails only when it cannot scan. Prettier runs first, so a formatting failure
hides the checks below it — accepted over `if: always()` plumbing. No depcheck, madge or bundle
analyzer: depcheck reports only `postcss.config.js`'s named-not-imported plugins, madge resolves no
`@/` import without `--ts-config`, and `@next/bundle-analyzer` has no executable.
The gate depends on `.prettierrc.json`'s `proseWrap: "preserve"` for `AGENTS.md` (see "The Prettier
override is load-bearing"); if that override goes, this step returns to advisory in the same change.
`npm run ci` does not run `format:check`: the script mirrors `ci.yml`, and `format:check` lives in
`code-quality.yml`; `CONTRIBUTING.md` names it blocking.
## Entry units are a per-device override; the account preference stays the display authority
A per-dimension unit toggle on the dive form's number boxes changes only what a box displays and
parses; the account's `units` preference renders everything saved.
The record is `override ?? account` in `localStorage` (`entry-units.ts`, keys from
`ENTRY_DIMENSIONS`): setting a dimension to the account's system removes its key, so absence means
"follow the account".
The store subscribes, unlike the chart keys: the gear-set dialog opens inside the dive form and
renders weight; `writeEntryUnits`/`clearEntryUnits` notify. Writes happen only in the toggle
handler. `signOut` clears the key after the `authAPI.signOut()` try/catch: an inherited psi label
parses 200 as 13.79 bar.
Every box with a unit carries a toggle, and a dimension's toggles share one setting: flipping
Maximum depth flips Average depth, and every tank's pressures flip together. The gear-set dialog
keeps its own weight toggle on the same store. `MixtureGasHint`/`MixtureSetWarning` follow entry
units; `/gear`'s list stays on `useUnits()`. `UnitNumberInput` discards its draft on a flip during
render. Tests must install `useStorage(memoryStorage())`.
## The toggle sits in the label row without being laid out in it, and a flex wrapper is wrong twice
`EntryUnitLabelRow` leaves the `FormLabel` inline in an ordinary block and takes the toggle out of
flow, absolutely positioned and vertically centred. A `flex items-center` wrapper is wrong twice.
`<label>` is `display: inline`, and a flex or grid parent blockifies it: the box shrinks from the
17px content area its font metrics give it to the 14px line box `leading-none` declares, and the
18px toggle sets the row height. And `FormItem`'s `space-y-2` is a margin-bottom, which an inline
box ignores — wrapping the label in anything block-level collects 8px it never had, so the wrapper
carries `mb-0`. jsdom does no layout, so the render tests pin structure only: the label is not a
flex or grid item, the toggle is out of flow, and the row cancels the margin.
## The privacy page describes this app, and there is still no cookie banner
`/privacy` needs no GDPR cookie banner; it needs to be true. It describes this copy of OpenDiving:
no password login, public profiles, forums, photos, public ratings, security audits, Data Protection
Officer or `privacy@opendiving.app` address, none of which exist. A policy that overstates what is
collected is not the safe direction to be wrong in.
It is written to the union of two jurisdictions. EU ePrivacy Art. 5(3) has two exemption limbs
(WP194 §1: Criterion A, transmission; Criterion B, strictly necessary for a service the user
requested). UK PECR Schedule A1 ¶¶3–7 has five; ¶5 (statistical), ¶6 (appearance/functionality) and
¶7 (emergency) have no EU counterpart, and ¶6(1)(d) demands a simple, free means of objecting.
`localStorage` is in scope: EDPB Guidelines 2/2023 ¶¶35–39 hold that storage is storage whatever the
medium. ¶44's "does not leave the device" reading covers only the access limb; the write is still
storage.
## Privacy page: "We" is the operator, and §1 says so before anything else
"We" on `/privacy` means the operator of this copy, not the OpenDiving project. §1 states it first:
the software is something anyone can run, this page describes this copy, and under GDPR Art. 4(7)
the operator determines purposes and means and is the controller. The project runs no servers,
receives nothing, and has nothing it could be asked to hand over. Every commitment on the page,
including §4.7's "protect our rights, property, or safety" and §9, is read as the operator's.
Nextcloud (<https://nextcloud.com/gdpr/>) and Mastodon's per-instance policy
(<https://docs.joinmastodon.org/entities/PrivacyPolicy/>) take the same voice.
## Privacy page: The exemptions, key by key
Criterion B (WP194 §2.2) is conjunctive: a requested service, and no function without the storage.
- `refresh_token`: session authentication is strictly necessary (WP194 §3.2); a persistent token is
exempt only behind a "remember me" tick. This passwordless app has none; the owner's call is the
muted `AuthForm` note and disclosure-first, which WP29 does not endorse.
- `opendiving:post-auth-redirect` fits Criterion B (WP194 §3.1; UK Sch. A1 ¶4(2)(e)(ii)); its
24-hour lifetime is reasonable under §2.3, the flow crossing a mail client.
- User-chosen preferences (`theme`, chart-series and chart-period keys, `opendiving:entry-units`,
the passkey nudge) sit on WP194 §3.6, which exempts UI customisation only short-term; §10 is the
prominent information longer storage needs, and the §10.3 device-memory switch is the control.
- `opendiving:entry-units` stays a preference, not an identifier, so a user-id stamp is rejected.
- The `-view` keys' anchor timestamp derives from dive dates and is personal data; §10 says so.
## No banner is required, and "not required" is the phrasing that is citable
Exempt storage needs no consent: the ICO allows storage "without the subscriber's or user's consent"
in the exempt cases, and CNIL lists trackers "non soumis au consentement". What survives is
transparency under GDPR Arts. 12–13, because a session token is personal data, and the ICO says
those duties apply "even if you are making use of a PECR exception".
The conclusion is phrased "not required", never "prohibited"; the second is not citable and is the
same overclaim the page exists to avoid. CNIL Recommandation del. 2020-092 Art. 5 ¶49 recommends
informing users of exempt trackers "dans la politique de confidentialité", which is what §10 is.
## The objection condition, and the device-memory switch that meets it
UK Sch. A1 ¶6(1)(d) requires "a simple means of objecting, free of charge, to the storage or access"
— to the storage, not the value, so rewriting a preference is not objecting and a key with `setItem`
and no `removeItem` fails it. The §10.3 device-memory switch is the means: it removes every
remembered preference and stops it returning, and `lib/device-memory.ts` classifies each key as
covered or excluded. Under ¶6(2) one offer "in respect of the initial use" serves every key and
visit.
"Clear your site data" is not the means: the ICO says an operator "must not solely rely on browser
settings", the means must be the service's own, clearing cannot single out a key, and it deletes the
`refresh_token` cookie and signs the diver out. §10 keeps it only as contrast. The condition is
UK-only; ¶4 has no objection limb and the EU none.
## Privacy page: Why §10 names keys rather than categories
§10 lists every browser-storage key by name, not category labels like "Essential Cookies" and
"Preference Cookies". A named key tells a reader what is stored, for how long and whether they can
stop it, and lets a maintainer check the page against the browser's storage inspector, the source
and a test. §4.4–4.6 enumerate flows rather than "third party services" for the same reason. A
category cannot go stale visibly; a list of keys can, which is the point. The count belongs on the
page, where `app/privacy/page.test.tsx` pins it, not here.
## The landing page's "No trackers and no analytics" is still true, and here is the reading
`components/layout/landing-page.tsx` makes the claim and it holds: the software ships no tracking or
analytics technology — not disabled, absent, with no such dependency in the build. Sign-in and map
functionality that contacts a third party is function, not tracking, and each is disclosed on
`/privacy` rather than denied; Google sign-in on a Google-enabled instance is disclosed in §4.12.
§2.2's daily totals leave it standing: the operator's bookkeeping, counted on the server and naming
nobody.
§10 does not claim "no third-party cookies". The app sets none, but the operator picks the tile
provider through `MAP_TILE_URL`, and that provider's servers answer the image requests §4.4
discloses and may set cookies of their own, so §10 cross-references §4.4 instead of promising what
the software cannot keep on every instance.
## The rule this project holds itself to, stricter than the law's floor
This project declines the law's analytics exceptions. Two clauses: analytics, A/B testing or
advertising storage ships only with a prior-consent flow and a §10 change in the same PR; every new
browser-storage key owes §10 a row in that PR, enforced by `src/lib/storage-keys.test.ts`.
Four checks: (1) every `opendiving:`-prefixed literal under `src/` appears verbatim in
`app/privacy/page.tsx`; (2) modules with real `setItem`, `localStorage[key] =` or
`document.cookie =` assignments equal a literal list of eight filenames — a tripwire, since
resolving `setItem(CONST)` by regex masks keys, misaccuses files or passes vacuously; (3) no
production module mentions `indexedDB`, `serviceWorker` or `cookieStore` (`sessionStorage` is
excluded: comments mention it, and (2) catches its writes); (4) `lib/device-memory.ts` classifies
every key found as switch-covered or excluded, `theme` by hand.
Stated gaps: `theme` (next-themes' default, no `storageKey`), run-time-assembled keys, comments
naming a write form. The server-set `HttpOnly` `refresh_token` cookie (`lib/api-proxy.ts`) is
disclosed by hand in §10.1.
## A join link on the Google attempt record is sign-in, not analytics storage
`via` rides `opendiving:google-sign-in-attempts` beside the verifier, and the consent clause of the
rule above does not reach it. On an invite-only copy it is what admits the visitor, part of the
sign-in they asked for; on an open one, where it only counts, the owner ruled for §10.2's disclosure
over a consent step. A member's invite-link token rides the same field, fitting the slug's shape,
and only ever admits. No new key, so `storage-keys.test.ts` is unchanged.
## Privacy page: §6.3 enumerates every email, and the enumeration is exhaustive on purpose
§6.3 lists everything a diver receives, in three groups — mail following an action on this site,
security notices, the scheduled emails — with support-form mail parenthesised as mail about you,
sent to `CONTACT_FORM_EMAIL`. Any new `send_*` function in the api owes this section a line;
`app/privacy/page.test.tsx` pins the first group's count against its list, and the scheduled group's
paragraphs against the sentences counting them and their switches.
"You" in a claim about who receives mail is an identity assumption: `POST /auth/email/request` is
unauthenticated, `send_email_change_confirmation_email` goes to the address typed, and
`DELETE /user` acts on a bearer token alone, so only the deletion confirmation is bound to the
account's address and none is certain to reach the actor. `send_email_changed_notification` goes to
the old address and cannot be silenced. A message added to a group inherits every claim the group's
prose makes, not just the numeral; the invitation has its own reason for being unpreventable. "Three
kinds" in the opening counts groups.
## What the CSP actually buys, and why the storage-key rule is the guard
The CSP does not forbid analytics; the storage-key rule is the guard, and the CSP only narrows the
quiet ways to break it. `connect-src` lists `'self'`, the API origin and the basemap hosts
(`src/proxy.ts`), so a cross-origin beacon is blocked and a same-origin one is not. `script-src`
carries `'strict-dynamic'`, so a bundled analytics script loads with no violation. `img-src` is
`'self' data: blob:` — plus the API origin on a split-origin build — so a `data:` or `blob:` pixel
passes. A shorter list is not a stronger guarantee.
## Privacy page: The numbering in §4 is load-bearing, and conditional sections sit last
This file pins privacy sections by number and by quoted content — §4.4 map tiles, §4.5 geocoder,
§4.6 species cache, §4.7 Legal Requirements, §4.8 invitations, §4.9 check-in links, §4.10 linking a
person, §4.11 API keys, §4.12 Google sign-in, then the weather and proposing — so a reword at the
right number falsifies a pin silently. A stale privacy page is worse than a vague one.
Conditional sections sit last. §4.12 exists only when `GOOGLE_CLIENT_ID` is set, so its absence
leaves no gap. The weather service's section follows it, present only where `/config` says the
instance fetches dive conditions, and numbered 4.13 or 4.12 by whether Google's is there. Proposing
for OpenDiving Commons comes after both, present only where `/config` says the instance is the
Commons' primary, its number computed from which of the two precede it, 4.12 to 4.14; the terms name
it by its heading, since that page reads neither. The invitations disclosure is unconditional —
`REGISTRATION_MODE` flips with a restart, and a section that came and went would change under a
reader for no stated reason — so it holds a fixed number and hedges in prose ("where this copy is
invite-only"); the check-in links, linking and API keys sections are unconditional because every
copy offers them. §4.8's join-link paragraph alone comes and goes, with `join_links`: a copy without
join links says nothing of them. `page.test.tsx` asserts §4.8 to §4.11 always present and §4.12
present only when configured. `git grep '4\.12'` misses the page's and `lib/google-oauth.test.ts`'s
regex literals; use `git grep -n -E '4\\?\.(9|1[0123])|4\.\{'`, which also finds the weather
heading's computed number.
## The ICO's `localStorage` suggestion, read and answered rather than passed over
The ICO's PECR guidance asks operators to consider "automatically removing objects in localStorage
where appropriate"
(<https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/how-do-we-comply-with-the-pecr-rules/>).
That is a should; the binding duty is to justify each key's duration against its purpose. §10.2
does: a preference lasts "until you change it, or until you tell this browser to stop remembering",
the device-memory switch being the second half. A preference removed on request rather than on a
timer answers the suggestion without discarding a choice the diver still wants, so chart-view state
carries no TTL.
## Privacy page: §7 keeps the API's 30 days, and §13 promises nothing about delivery
Two sentences at the page's edges stay as they are. §7's "Personal information is permanently
deleted within 30 days" is byte-identical to what ships, because documents in the API repo,
including a config default, are written against that number. §13 promises nothing about delivery:
`CONTACT_EMAIL` in this repo is display-only while the API's `CONTACT_FORM_EMAIL` decides where a
submission goes, and the two can disagree. §13 links the support page as "how to reach whoever runs
this copy" without asserting a form works, and renders neither a project-owned address nor the
public issue tracker — a diver filing an erasure request in public, to people who are not the
controller, is the outcome it must not inherit.
## The terms page has two speakers, and the headings are the mechanism
`/terms` describes this software, not a platform: no forums, passwords or `legal@opendiving.app`,
and a licence on what a diver enters only where they dedicate it to OpenDiving Commons, on the
instance that edits it. §2: you keep your own list of dive sites, your own and saved Commons records
alike. §8: the page changes with the software; no notice mailing exists. §3 lists every sign-in
method, Google hedged in prose ("where this copy offers it") rather than gated on
`GOOGLE_CLIENT_ID`, because the sentence draws a security consequence from completeness. §13 splits:
operator for this copy, project for a software defect. §7 makes AGPLv3 §13's source offer the
operator's.
The mechanism is the heading. §1 defines "the OpenDiving project" and "the operator of this copy",
makes every section the operator's unless its heading says otherwise, and names the three that do:
7, 9 and 10. Bare "OpenDiving" never acts, so a self-hoster can grep for their sentences. §12 keeps
its deferral to wherever the Service is operated and only names its speaker.
## Terms page: Why this page speaks with two voices when no comparable project ships one that does
`/privacy` takes one voice because everything on it is the operator's. `/terms` cannot: one operator
voice would convert the project's liability disclaimer and indemnity into promises by an operator
who never agreed to them. The comparators avoid the problem by having the operator write the page —
Mastodon's `config/templates/terms-of-service.md` speaks as the Server Operator and names Mastodon
GmbH only to disclaim affiliation; Discourse's `tos_topic` in `config/locales/server.en.yml` speaks
as `%{company_name}`; Gitea's `contrib/sample-page/tos.html.sample` is a placeholder sample.
OpenDiving ships the page, so the project has a speaking part they lack.
The operator's as-is paragraph sits at the end of §8, not under §9, whose heading claims the project
as speaker. It carries no monetary cap, arbitration clause or jurisdiction — the parts an operator
would object to having invented for them — and says the operator may replace it with their own
terms.
## AGPLv3 §§15–16 do not reach the diver, which is what makes §9 load-bearing
"The AGPL already disclaims warranty, so terms §9 is spare boilerplate" is wrong. The AGPL's
disclaimers run to licensees: §0 addresses "each licensee" as "you" and says mere interaction with a
user is not conveying; §16 limits liability only "TO YOU" for use of the Program. A diver on someone
else's instance receives an HTTP response, not a copy — not a licensee. §13's source offer binds
modifying operators and is not a transfer; §17's waiver rescues §§15–16 where they already apply and
extends them to nobody (<https://www.gnu.org/licenses/agpl-3.0.en.html>). So the AGPL covers the
project against the self-hoster and leaves the diver uncovered, and terms §9 is the only instrument
closing that gap. §9 therefore carries its speaker in the heading, a third-party-beneficiary
sentence, and "adds to, and does not narrow, sections 15 to 17 of the AGPLv3".
## Terms page: Four things here are judgement, not settled law
This project has no lawyer; these are reasoning, not authority. The AGPL reach conclusion is a
textual reading no court has decided — "probably does not reach", not established. §9's "diving
accidents or injuries" bullet is the least enforceable anywhere (UK UCTA 1977 s.2(1) voids such
exclusions outright); it stays because it costs nothing and evidences no assumed duty, and nothing
is built on it. Third-party-beneficiary enforcement varies by jurisdiction and works only if the
diver assented and the operator shipped the page unedited, neither of which the project controls.
§5's narrow grant is chosen over "no licence is granted to anyone", which is more honest today,
because a future social feature then widens an existing clause rather than introducing licensing
from scratch — a product argument, not a legal one.
## The standing rule: a feature that shows one diver's content to anyone else brings its own grant
Any feature that makes one diver's content visible to anyone else ships its own §5 grant and its own
privacy-page section in the same PR. Today's grant is no wider than running the Service takes —
store your entries, show them back to you, include them in an export you ask for — and widening it
for features that do not exist overstates what a legal page covers. The three plausible futures need
different things, and only one is a copyright question: shared dives need an operator display grant,
probably per act of sharing; a dive-center view of certifications is a lawful-basis question under
GDPR Art. 6, possibly Art. 9; aggregate statistics need no content licence at all.
## `--warning` is a shared token, not the safety notice's private one
`--warning` is the app's caution colour: `text-warning` on an alert exposure figure and on the dive
form's and dive page's inline warnings, and `ui/badge.tsx`'s `warning` variant on `bg-warning`,
which `lib/course.ts` returns for `incomplete` and `provisional` courses. `lib/gear-service.ts`'s
`due_soon` uses `--coral` instead ("overdue" is `destructive`). The terms page's safety notice is
therefore not load-bearing for the token; it stays on its own merits, because a dive log disclaiming
safety advice should not look like a footnote, and reads "software for logging dives".
## The footer's column labels are not headings, because the footer is shared chrome
`layout/footer.tsx` renders under every page, so no fixed heading level for its column labels is
correct — it depends on the page's tree. The columns are group labels over link lists, so each is a
`<nav aria-labelledby>` whose label is a `<p>`: reachable by landmark, outside the outline.
`footer.render.test.tsx` pins zero headings from the component.
`CardTitle` (`ui/card.tsx`) takes `as` (`"h2" | "h3" | "h4"`, default `h3`), tag only;
`support/page.tsx` passes `as="h2"` because its cards are top-level sections.
CI misses both: `scripts/axe-report.mjs` scans `<main>`, the footer is its sibling, and
`heading-order` is `best-practice`, outside the tags it runs. Sweep with that script's setup instead
— `window.axe.run(document, {})` with no `runOnly`, `page.emulateMedia({ colorScheme })` per theme.
In a worktree, drop `NEXT_PUBLIC_API_URL` from the copied `.env` (CORS) and rewrite the magic link's
port.
## The chrome-free routes share `standalone-shell.tsx`, which carries their one `<main>`
`AppShell` alone renders a `<main>`, and the routes in `NO_CHROME_ROUTES` (`app-shell.tsx`) never
reach it. `components/layout/standalone-shell.tsx` is their one wrapper, with the `<main>` inside
and `className` merged onto it, so a new chrome-free screen cannot omit the element. The `<main>`
wraps the wordmark too, because `region` wants every scrap of content in a landmark and one link is
not worth a `<nav>`; `standalone-shell.test.tsx` pins document text equal to landmark text.
Loading frames (`PageSpinner`, or `null` until the in-memory session resolves) carry no landmark
deliberately: `PageSpinner` also renders inside `AppShell` as `variant="inset"`, where its own
`<main>` would be a second one.
Left open: `page-has-heading-one` while `/auth/verify`, `/settings/confirm-email` and
`/auth/google/callback` show only a spinner line, and `/goodbye`'s home link (`link-in-text-block`,
dark `color-contrast`). Let a `router.replace` settle before trusting `color-contrast`; a worktree
production build needs `API_INTERNAL_URL=http://localhost:8000`.
## `CardTitle` takes `as="h2"` on every card that is a section of its page
`CardTitle` takes `as="h2"` on every card that is a section of its page — settings, dive detail,
gear, Home. The test is "is this card a section", not "is this page failing". The landing page's
feature cards keep `h3` because they sit under `<h2 class="sr-only">What OpenDiving Does</h2>`. Read
a promoted card all the way down: `delete-account-card.tsx`'s inner heading is an `<h3>`, since an
`<h4>` under `<h2>` is the same jump again.
Reading the axe report: it names only the first offending heading per page, so one violation is the
top of a stack — dump `document.querySelectorAll("h1,h2,h3,h4,h5,h6")` beside it. Scan public pages
signed out: signed in, `/` and `/signin` redirect to `/home`, so an authenticated pass reports th