# Notes on AGENTS.md, from building Lodestay

Written while building `templates/booking-desk/` (a guest booking places to stay,
nine screens, top-nav shell, a real map) against AGENTS.md and alongside the
claims-desk, talent-desk and hearth notes. Only what cost real time or forced a
judgement the file does not cover.

## What held up unusually well

- **§6.4's top-nav contract.** Four destinations, one row, one active item with a
  persistent underline: the whole shell decision took one paragraph and never
  came up again. The interesting consequence is the one §6.2 forces — a consumer
  booking app *wants* to put "where, when, how many" in the header, and the rule
  that app-scope search is a palette rather than a filter stops it. Moving the
  scope into the search screen is what made §5.3 legible: the scope moves the
  figures, the filters below narrow the list, and the two resets never touch
  each other. The rule looked pedantic and turned out to be the design.
- **§2's "every figure names the array it comes from"** applied to money. One
  `quoteFor` and one `paymentPlan` meant the checkout preview, the trip's
  payments table, the "what it cost" panel and the refund the cancel dialog
  quotes are the same functions called with different arguments. The fixtures
  call them too, so no booking in the kit carries a typed amount.
- **§8.1's refusal to store derived state.** A place's rating is the mean of the
  reviews it carries, and null below three of them. Writing a review therefore
  moves the rating on the search card, the place screen and the map card with
  nothing written anywhere — and on the boat, which has two reviews, it moves the
  place from "New place" to having a rating at all.

## Gaps and frictions

### 1. §3's "Submit disabled until dirty" fights a prefilled checkout

A booking checkout is the honest case where every field already has the right
value: the dates and the party size come from the scope the guest was searching
with. Followed literally, the primary action is disabled forever, and the
obvious workaround — enabling Submit on first paint — breaks the rule for every
other form in the product.

I made the form ask one thing it genuinely does not know ("what brings you",
required, unset) so that dirty-and-valid is reachable honestly. That is the
right answer here, but it is a design decision the rule silently forces, and a
less careful author will just switch the rule off.

**Suggested addition to §3:** "A form whose fields are all prefilled from a
previous screen still needs one field the product does not already know, or it
is not a form — it is a confirmation, and its action is a button with a confirm
dialog."

### 2. §8.3 has no format for a date that is not an event

Three of these notes now say something about §8.3, from three directions. This
product's version: a stay has *calendar dates* (check-in, check-out, a payment
falling due) which are not events at a time of day. `formatWhen` renders a
balance due in six days as "Sat 15:00", which is both true and useless, and it
put two spellings of the same date on one screen — the alert said "Sat 29 Aug",
the table said "Due Sat 15:00".

I declared in `metrics.js` that stay dates are calendar dates with one absolute
spelling, and left §8.3's distance rules to the timestamps (messages, holds,
payments actually taken). It works, but the file should say it rather than each
product rediscovering it.

**Suggested addition to §8.3:** "A calendar date — a due date, a deadline day, a
date of stay — is not a timestamp. It gets one absolute spelling, product-wide,
and the distance table governs event times only. A column that mixes the two
picks the calendar spelling."

### 3. "Disable with the reason stated" needs a temporal case

§1.2 splits actions into omit / disable-with-reason. This product has a third
shape: a control that is unavailable *for the next eleven minutes* because
another guest is holding the dates. Omitting it is wrong (the guest would think
the place had gone), and disabling it with the plain reason is nearly right —
what makes it work is that the reason contains a **clock**: "the hold expires at
19:02", and the countdown beside it.

**Suggested addition to §1.2:** "Where the precondition is temporary, the reason
names when it lifts. A disabled control whose reason has no clock reads as
permanent."

### 4. A route that is a step in a flow, not a place

`/stays/:id/book` is registered because a checkout is a screen the guest can be
sent back to and its breadcrumb has to say which place it belongs to. But
nothing in §5's breadcrumb rules distinguishes a *record* parent from a *flow*
step, and the trail reads "Stays › Tiled terrace above Alfama › Checkout" — the
last segment being a verb-ish noun in a list of nouns. It is fine, and it took a
while to be sure it was fine.

**Suggested:** one line in §5 saying that a flow step is a legitimate route with
a record as its parent, and that its label names the step rather than the
record.

### 5. §5.1's column-stack rule needs the grid case named

Twelve equal-weight place cards are an auto-fit grid in every product that has
ever shipped; the rule as written allows the exception only for "equal-weight
metric tiles". I read the intent (do not lay out mixed-weight content cards in an
auto-fitting grid) and used a grid for the search results and the saved lists.

**Suggested rewording of §5.1's first bullet:** "…the exception is a set of
equal-weight items of one kind — metric tiles, search results, a saved list —
which may auto-fit."

### 6. Practical: a map inside a hidden pane is a 0×0 map

Not an AGENTS matter, but it will bite anyone adding a map to a responsive
screen in this system. Below 1024px the list/map switch starts on the list, so
the map pane is `display:none` when the component mounts; Leaflet reads its size
once, believes it is 0×0, requests a single tile and puts every marker at a
negative offset. A `ResizeObserver` alone did not recover it in the preview
iframe. What works is watching the element's own box — observer plus a cheap poll
— and calling `invalidateSize` (and re-fitting the bounds) the first time it has
one.

### 7. §7.5's "four screens" and the route table disagree about the dead link

Password reset is four screens, and three of them are literal routes. The fourth
— the expired-or-used link — is not a screen of its own: it is the *same* route
as set-new-password, with a token that no longer works, and which of the two
renders is derived from the token's expiry against the clock. Registering
`/reset-password/expired` to make it reachable would be inventing a screen, and
§5 forbids exactly that.

What it costs: a designed state nobody can open. The reader would have to hold a
token and wait an hour. I put the second token in the fixture and let the
preview menu list both sides of the one route — the harness is where a
demonstration need belongs, not the route table.

**Suggested addition to §7.5:** "The fourth reset screen is a state of the third,
selected by the token. Do not register it. If it has to be demonstrable, the
fixture carries a dead token."

## Not a rule, but a currency note

`lib/ui-runtime.js` is domain-free by design, and its `formatMoney` still
hard-codes USD. The `lib/` copies may not be locally edited, so a product
outside the dollar has to carry its own spelling of money in `metrics.js` —
which is exactly the duplicate §4 is trying to prevent. Either `formatMoney`
should take a currency, or `lib/` should hold a formatter factory and the
product should hold the currency constant beside its policy numbers.
