# Client surfaces

Sources of truth: `main/web/src/pages/`, `main/web/src/lib/`, `main/web/src/components/`,
`main/app/routers/`, `main/app/models/`, `import/`.

(`import/progress.json` and `import/items.json` change at every checkpoint by design; a newer
commit there does not date this page, and a checkpoint commits them together with it so the
strict doc gate reads the pair as one change.)

A client surface is a page built for Adam, Mark or rian to steer the build itself: reviewing
what shipped, deciding scope, tailoring the launch quote, or checking the plan. It is distinct
from the shopper storefront (home, products, savings, exclusives, awards, airports, category at airport, product
pages), which is documented in ARCHITECTURE.md. Client surfaces share one comment mechanism, need
the `client.view` permission (`ACCOUNTS.md`), and are excluded from search indexing.

## Surfaces

Three pages (the consolidation of 2026-09-12, `.logs/planning/streams/R-accounts.md` "T8
proposal"): `/discuss` is the client's material in four tabs, `/issues` is the running list,
`/plan` is the build. The old addresses (`/todo`, `/structure`, `/quote`, `/plan#items`) redirect
with their fragments intact: the server answers a 301 whose Location carries the tab in the
query and no fragment (`app/routers/discussion.py` `pages_router`, `MOVED_PAGES`), so a browser
inherits the request's own fragment; in-app, `pages/MovedTo.tsx` appends `location.hash`. The
tab rides in `?tab=` (the site's convention for a shelf in the address); a comment anchor alone
(`#t-structure-…`) selects its tab from the subject type, and a section anchor from the old
structure page (`#urls`) scrolls once the tab has mounted.

`import/` also carries files that are not client material: `import/proposals/<date>-<level>.json`
are merge proposals a session filed for the owner to approve on `/collectors` (Merge, Desk;
`RUNBOOK.md`); nothing on the client pages reads them.

### The client's material (`/discuss`)
`DiscussionPage` (`web/src/pages/DiscussionPage.tsx`) is the shell: the tab strip, then the tab's
own page, lazily loaded.

**The review** (the default tab). Renders a video walkthrough block, the "big picture" lines
(`BIG_PICTURE` in `web/src/lib/review.ts`), the feature board itself, and a short list of open
decisions plus a general comment thread. The feature board shows every entry in `BOARD`
(`web/src/lib/review.ts`), grouped by `scope` (launch, after, later, tbd) and filtered by build
`status` (live, partial, building, planned). Each card carries a status chip, a scope chip, a
description, a "done" line and a "next" line, a set of priority buttons (effort tiers for built
features, timing tiers for unbuilt ones), and its own conversation. Tapping a priority button
calls `useSetFeaturePriority`, which upserts a row in `feature_priorities`
(`app/routers/discussion.py` `POST /api/discussion/feature-priorities`, table `FeaturePriority` in
`app/models/discussion.py`), one row per `feature_key`, latest choice wins. The open-decisions and
resolved-items lists read `discussion_items` (`GET /api/discussion`, table `DiscussionItem`), and
the "big picture" stats line reads live catalogue counts via `useStats`. This tab is for Adam.

**Your to-do list** (`?tab=todo`, while the `clientTodos` flag is on). `TodoPage`
(`web/src/pages/TodoPage.tsx`): the items and questions for Adam and Mark (`app/routers/todos.py`,
`app/models/client.py`), each with its conversation; the items that take a file accept a hand-in
through the upload seam (`app/services/uploads.py`).

**Site structure** (`?tab=structure`). `StructurePage` (`web/src/pages/StructurePage.tsx`), backed
by `web/src/lib/structure.ts`. A site-structure proposal: a live strip of counts from
`/api/stats`, a one-page site map (`SiteMap` component, fed by `MAP_HUBS`/`MAP_CROSS`/`MAP_PRODUCTS`
in `structure.ts`), proposed URL patterns and build thresholds (`URLS`, `THRESHOLDS`), a
field-by-field breakdown of what each page type shows and where each field comes from (`PAGES`,
`ORIGINS`), a schema.org plan (`SCHEMA_PLAN`), prose sections (`SECTIONS`, including what the
review settled and what is still open), and the questions with what was settled under each
(`QUESTIONS`). Every section and question carries its own conversation, keyed `structure:<key>`;
a key is never renamed or removed, because comments attach by exact key. This tab reads no
backend route of its own; all its content is static copy in `structure.ts`, measured against the
live database and re-measured by hand rather than queried live (a dated snapshot; the file's
own header says re-measure rather than edit). For Mark and Adam.

**Launch proposal** (`?tab=quote`). `QuotePage` (`web/src/pages/QuotePage.tsx`). A tailorable
soft-launch quote: the always-included core lines (`CORE` in `web/src/lib/quote.ts`), a set of
optional lines (`OPTIONS`) each with a switch, and a few presets that toggle a whole set of options
at once. Every line shows its list price and its introductory price (`priceOf`, computed from
`DISCOUNT` in `quote.ts`, never hardcoded). Some options depend on others (`REQUIRES` in
`quote.ts`): switching a line on pulls in its dependency, switching a dependency off switches its
dependants off too. Toggling a line calls `useSetQuoteSelection`, which upserts `quote_selections`
(`app/routers/quote.py` `POST /api/quote/selections`, table `QuoteSelection`), one row per
`item_key`. Submitting the form at the bottom calls `useSendQuoteRequest`, which inserts a
`quote_requests` row (table `QuoteRequest`) freezing the chosen items and total at that moment, so
a later change to the selections does not alter a quote already sent. A collapsible "later"
section lists features not in reach for soft launch (`LATER` in `quote.ts`, each under a frozen
`key`), each with its own conversation. For Adam.

### The running list (`/issues`)
`IssuesPage` (`web/src/pages/IssuesPage.tsx`) over `pages/plan/ItemsTab.tsx`: what only rian can
decide, what he must do or check, and the issues the build must address, read from `GET /api/items`
(`import/items.json` merged with rian's recorded state in `owner_item_states`), each with its
conversation (`item:*`). Its writes are the recorded writes of `ACCOUNTS.md`: decide, done,
dismiss, archive (put away for good, open or closed, with a note) and reopen, each with the
actor from the session. The filter at the top (open and recent, archived, everything) keeps
the archived set and every closed item reachable on the same page. For rian (`plan.view`); a
client who types the address gets the not-found shell.

### The plan page (`/plan`)
`PlanPage` (`web/src/pages/PlanPage.tsx`). A live view of the build plan: the promises to the
client with their state derived from the tasks that deliver them, a day-by-day roadmap, waves
across the top, one lane per stream, tasks as status-coloured chips, with percent-done and
days-to-delivery stats. It polls `GET /api/plan` (`app/routers/plan.py`) on an interval set in the
page. That route reads `import/progress.json` fresh on every request and computes all tallies
server-side so the file itself never carries a count that can go stale. `progress.json` is mounted
read-only into the container and is edited on the host at session hand-back
(`main/scripts/plan-set.py`, under a file lock; `import/items.json` the same way through
`items.py`), so both trackers change at every checkpoint by design and carry no documentation of
their own; its top-level keys are `updated_at`, `updated_by`, `delivery`, `cannes`, `note`,
`waves`, and `streams`, and its `note` field documents the status vocabulary (todo, doing, done,
blocked, deferred). Unlinked from navigation and marked noindex: for rian, and "not hidden from
the client so much as not put in front of him."

### The demo settings page (`/settings`)
`SettingsPage` (`web/src/pages/SettingsPage.tsx`), backed by `web/src/lib/settings.tsx` and
`web/src/lib/features.ts`. Two groups of switches, described fully under "Curator settings and
flags" below. This page is for whoever is running or demoing the site (rian or Adam), not a
shopper.

The account page (`/account`, `web/src/pages/AccountPage.tsx`, any signed-in account) carries
the password change, sign-out, and the one notification preference: whether the digest mails
this person (`ACCOUNTS.md` Notifications; `GET/POST /api/notifications/preferences`).

## Comments

Every comment belongs to a **thread**, one conversation per subject (`app/models/discussion.py`
`Thread`, unique on `(subject_type, subject_id)`; `app/services/discussion.py` is the one write
path and the subject resolver). The subject vocabulary is frozen and never renamed: `decision:<card
id>` (the open-decision cards on `/discuss`), `feature:<board key>`, `page:discuss` (the catch-all
thread at the bottom of `/discuss`), `structure:<key>`, `quote:<key>` and `quote:later-<slug>`,
`todo:<id>`, `item:<running-list id>` (the running list on `/plan`, `plan.view` only), and
`topic:<slug>`, a thread that belongs to no section: started from the side panel's footer with a
title and a first comment (`POST /api/discussion/topics {title, body}`, `client.participate`; the
title becomes the label `Topic › <title>` and the slug, a second topic with the same words gets
`-2`), listed under Topics on the review tab (`/discuss?tab=review#t-topic-<slug>`). `GET
/api/discussion/people?subject_type=` (`client.view`; the running list's type is 404 to a
client) is the mention directory for a subject type as `{handle, display_name}` rows, the same
one the write path resolves `@name` against, so what a composer offers is exactly what will
ring: every comment box mounts `web/src/components/MentionScope.tsx` (`DfpComposer`), which
watches the textarea the pack renders and, on `@`, lists this site's own people filtered as
you type, arrows and Enter choosing, the handle inserted through the element's native value
setter so the pack's state follows; the pack itself is untouched, and the wrapper goes the day
the pack ships a picker (the request is on the running list). A thread is
created by its first comment and carries the page's breadcrumb for the subject as `label` (the raw
record of what the page called it). Every card renders with the DOM id `t-<type>-<id>`, so a deep
link is `/<page>#t-<type>-<id>#c-<comment id>` (`deep_link` in the service; the page each subject
lives on is `PAGE_OF` there and nowhere else).

For one release a comment also carries the legacy key it was written under (`item_id` or
`feature_key`, exactly one, by check constraint), so the pre-thread reads keep working:
`GET /api/discussion/feature-comments` lists the keyed threads' live comments, `POST` there and the
card's `POST /api/discussion/{item_id}/comments` still accept the old keys and write through the
service, so every comment gets its thread. The new shape is `GET /api/discussion/threads` (every
thread the caller may see, with its live comments; a page joins its own subject list onto it and
lists the rest as starters), `POST /api/discussion/comments` `{subject_type, subject_id, body,
label?}`, `POST /api/discussion/comments/{id}/delete` and `POST /api/discussion/threads/{id}/resolve`.
`backfill threads` (migration #6) gave every existing comment its thread; a later schema-only
migration makes `thread_id` NOT NULL and drops the legacy columns.

The author comes from the session, never a typed name: `author_id` is the signed-in account (the
effective one under View As) and `author` its display name as a snapshot. A comment is editable by
its author under `client.participate` and by anyone under `discussion.curate`; an edit stamps
`edited_at`, which is the only "edited" signal (`updated_at` moves on any update, the account
link-up included, and mislabelled fresh comments). Every comment carries small tools (`CommentTools` in
`SubjectThread.tsx`): Link copies its address (an own-site comment address pasted anywhere
renders as "Adam's comment, 7 Sep", `web/src/lib/links.ts`); Quote puts its first lines and a
link in the reply box; Quote into a topic opens the panel's starter with them, so two questions
in one comment become two conversations; and a curator's Move… re-files it under an existing
topic or a new one (`POST /api/discussion/comments/{id}/move`; `ACCOUNTS.md` Recorded writes):
the words stay, the card says "moved here", and a link to the comment still opens the right tab
because the page looks the comment up across every thread before it reads the anchor. A
resolved card carries its closing word in its status line: the decision lives where it was
made, and the Resolved pill brings the settled conversations back into view. The same two may remove a comment: soft
(`deleted_at`), so it leaves every read and stays in the record; a thread whose every comment is
removed is a starter again. Resolving a thread belongs to the person who opened it or a curator;
resolving a decision card is an event the people in its thread hear about (the notifications,
`ACCOUNTS.md`).

The look is the vendored **caddie-ui** pack (`web/src/vendor/caddie-ui/`, byte-identical to its
canonical copy in the caddie workspace; `scripts/vendor-check.sh` compares it against that copy or
`web/src/vendor/MANIFEST-caddie-ui.sha256`, and the pack's own stamp script is the one way it
changes; its `--cu-*` tokens read DFP's palette through the plain names mapped in
`styles/tokens.css`). One DFP adapter mounts it, `web/src/components/SubjectThread.tsx`: the
pack owns the card, the replies column, the composer and the starter; the adapter owns what a
subject is here and wires `api/discussion.ts` (one `GET /api/discussion/threads` read shared by
every page, the writes on their own routes) to the pack's callbacks. The first comment is the
card's root and the rest are replies; a subject nobody has spoken about is a starter whose click
is the composer; edit, remove and resolve draw from the `mine` flag and the curate capability and
are decided by the server. The `Lightbox` mounts once in `App.tsx`; the pack's `Body` renders a
comment (markdown-lite, never `innerHTML`, images only from the app's own attachment prefix).
A bare https address in a comment is shown as a short link (`web/src/lib/links.ts`: "Google
Doc", "Google Sheet", "Google Slides", "Google Drive file", else the host and a shortened path;
a document's own title is not readable, the client's documents answer 401 to anyone but the
people they were shared with), wrapped for the renderer at read time and unwrapped again on
edit, so the stored words never change and a link a person wrote with their own label is left
alone. A comment can carry a picture or a PDF (`app/routers/attachments.py`): paste, drop or the
paperclip uploads at once through `POST /api/attachments/<type>/<id>`, the composer holds the
file and appends its markdown on Send, and the body carries `![name](/api/attachments/...)`.
The file's type is decided by its bytes through the uploads seam (`app/services/uploads.py`;
images and PDF only, nothing script-capable), JPEG and PNG are re-encoded without metadata, the
stored name is random and the only shape the serving route resolves, the served type comes from
the route's own table with `nosniff`, 10 MB and fifty files per subject are the caps, and
visibility is re-checked through the owning subject on every read (a running-list subject is
404 for a client). Files live under the workspace `uploads/attachments/` directory, never the
instance and never a static mount; the pack's renderer loads images from this prefix only.

The header's profile circle (`AccountMenu.tsx`) opens the account menu, whose first item is a
Discussion button; the comment bubble on the circle wears the unread count (a quiet grey bubble
when there is none) and opens the **one side panel** (`web/src/components/DiscussionPanel.tsx`,
the pack's `SidePanel`; the standard's §6; rian's design of 14 Sep) on For you; the bell is not
mounted (`ACCOUNTS.md` Notifications). The panel: every thread the account may see, docked right
and pushing the page aside, closed by a click anywhere outside it or Escape (the pack's close
control is hidden). Three tabs pick the
set (For you: the viewer started it, replied, was named, was asked something or flagged
something there, its number what needs dealing with; Current and Later: the same list for
everyone, open first then resolved, counting the open ones; an AI tab for the owner's running-list
conversations), a view chooser narrows it (All; Open & Unread, the default; Unread; Flagged;
Requests; My asks; Decisions; Archived; `ACCOUNTS.md` Read state, the ask, the flag, the marks),
a search box filters as you type, and a card the person is working in keeps its place until they
move on. Every card, in the panel and on the pages alike, is `DfpThreadCard.tsx`, DFP's
own (the design goes past the pack's card; once it settles it belongs in the pack): the
location (truncated, whole on hover, and in the panel the way to the subject), the commenter
and the time; on the right the link icon (a tap copies the address, a hold or a right-click
opens copy, quote in reply, branch to a new conversation, and move for a curator) and the
indicators (you flagged something here, something was asked of you, you asked someone); the
body four lines deep until clicked, with Got it and Flag on hover; replies, edit and remove in
the comment's footer; Request (Confirm: move to Later, Confirm: resolved, Please reply, or Other; the receiver's
button is the action and the request completes when they do it; the asker rewrites or withdraws
it), Reply, Resolve (with "record as a decision made"), Mark as Later or Current, Reopen and a
curator's Archive in the topic's footer. On the pages the card is a summary: those buttons open
the panel at the conversation with the right box. The fill says read (filled)
or unread (unfilled); a decision has a green outline; an archived card a darker fill and
outline. Opening a conversation either way moves the viewer's read stamp; a thread the person
touched stays listed until the tab or the view changes. Every client surface mounts the thread
adapter, and nothing else draws a comment: `/discuss` (the board cards,
the decision cards, the page's own thread), `/structure` (every section, the map, the URL
patterns, each page type, the markup plan, each question, the catch-all), `/quote` (every line;
the after-launch lines under keys frozen in `quote.ts`, `LATER[].key`, and pinned by a test),
`/todo` (every item) and `/plan`'s running list (every item, `plan.view`). The legacy
`feature-comments` routes stay on the server for one release; no page reads them.

## Curator settings and flags

`DemoSettings` (`web/src/lib/settings.tsx`) is a browser-local preferences object, persisted to
`localStorage` under the key `dfp.settings`, provided through `SettingsProvider`/`useSettings`.
Its fields:
- **`curator`** (default off): shows the add-item panel, the resolve/reopen toggle on each card,
  and the "Resolved" section on `/discuss`, and only for an account holding `discussion.curate`
  (the owner's by absence at launch): the switch is a browser preference, the permission is the
  server's. Checked in `DiscussionPage.tsx` only; no nav link anywhere in the app is gated by
  `curator`.
- **`photosOnly`**: hides products with no photo, for showing the site to someone without gaps.
- **`showDataNotes`**: shows or hides the data-honesty explanatory notes (price-is-an-observation,
  photo provenance) intended to stay on for real visitors.
- **`samples`**: a per-key map (`sampleOn(settings, key)`, absent = visible) controlling whether an
  unbuilt/sample surface is shown at all: the announcement bar (`announce`) and one entry per
  teaser feature in `FEATURES` (`web/src/lib/features.ts`: airport guides, price tracker, reviews,
  guides, news, newsletter, map, alerts). Turning a sample off hides its nav link, its footer
  "coming soon" link, and its `/feature/:slug` teaser card everywhere at once. The `editorial` key
  still exists in the settings page but gates nothing: the home page's sample editorial block was
  replaced by the real `LatestArticles` (nothing until an article is published), and the
  newsletter band is the real `SubscribeForm`, shown regardless of the `newsletter` sample.

`flags` (`web/src/lib/flags.ts`) is a separate, server-controlled mechanism: build-time feature
switches read once from `window.__DFP_FLAGS__`, which the backend injects into the served HTML
shell at startup (`app/main.py`, sourced from `Settings.spa_flags` in `app/config.py`, itself set
by `.app.env` variables `feature_my_airports`, `feature_featured_home`, `feature_rich_cards`,
`feature_teasers`). In local dev, with no injected script, every flag defaults to on. Unlike
`DemoSettings`, flags are not user-togglable in the browser; they gate entire routes and features
per deployment (`myAirports` gates `/savings` and `/trip`, `teasers` gates `/feature/:slug` and
whether the nav even lists teaser links, `featuredHome` and `richCards` gate storefront widgets
not covered by this doc).

## Positioning rules

- House style holds on every surface, client and shopper alike: no em dash in string content,
  never "cheap" or "free" in what the client reads, nothing implying the code is the client's
  (`tests/test_house_style.py` walks the SPA sources; `structure.ts` and `review.ts` carry the
  rule in their headers).
- **Which reads and writes each account may make** is declared in one place,
  `app/services/access.py`, and enforced by its middleware (`ARCHITECTURE.md` step 2,
  `ACCOUNTS.md`). The client pages and their reads need `client.view`; comments and comment
  edits, priorities, quote selections and requests, to-do complete, reopen and uploads need
  `client.participate` (both on the seeded `admin` level Adam and Mark hold); adding, editing or
  resolving a decision card needs `discussion.curate`; `/plan`, `/issues` and the running list's
  reads need `plan.view` and its actions `items.act`; `/sources` and the kill switch need
  `sources.manage`. The four owner-tier permissions are held by no level at launch, so they are
  the owner's by absence. The SPA draws each control from the `capabilities` block of
  `GET /api/bw/me`; the server refuses the route regardless of what the SPA draws.
- The routes `/discuss`, `/settings`, `/issues` and `/plan` are all marked `noindex, nofollow`
  (`app/services/seo.py`, `STATIC_HEADS`); the moved addresses answer a 301 before any head.
- The client pages are linked from the account menu's Build Discussion group
  (`web/src/lib/clientPages.ts`, each shown only to the accounts its route admits) and from
  nowhere in the storefront's navigation or footer.
- `/plan` and `/issues` are for rian and deliberately not surfaced to the client rather than
  hidden from him. An account without `plan.view` that types either address gets the not-found
  shell, on the server and in the SPA: a client is not told the page exists.
- The client pages are also placed by environment (`SITE_ROLE`, `ACCOUNTS.md`): the build
  conversation and its tabs, the demo settings, the picture review, the plan and the running
  list are development surfaces that answer 404 on a live host, while the data sources and the
  collectors are live features, and the `developmentSurfaces` flag keeps the account menu from
  drawing a link into an address the server would refuse.
- No navigation link in the app is conditioned on `curator`; that flag only changes controls
  rendered inside `/discuss` itself (add-item panel, resolve/reopen buttons, the Resolved section).
- Nav and footer entries for the sample/teaser features are conditioned on both the `teasers`
  build flag (`web/src/lib/flags.ts`, checked in `SiteHeader.tsx`) and the per-key `samples`
  setting (`sampleOn`, checked in `SiteHeader.tsx`, `SiteFooter.tsx` and `HomePage.tsx`); either
  one being off hides the corresponding link.
- The whole site, storefront and client surfaces alike, is members-only until the go-live flip
  (`SITE_ACCESS`, `ACCOUNTS.md`): an anonymous visitor sees the sign-in page and nothing else.
  The app's own login is the only barrier once the server's gate lifts from the dev host (the
  owner-only `srv-gw id-gate` step in `RUNBOOK.md`); no client surface gains a second hostname
  (`agents.md`).

## Sponsor placements (under review, 11 Sep)

Ten placements are built into the pages and framed to belong where they sit
(`web/src/lib/sponsors.ts` `SPONSOR_POSITIONS`): native tiles in the home savings grid, the
airport shelf and the product page's related grid, each drawn like a product card from a
responsive display ad's assets (`NativeCreative`: square image, headline of 30 characters,
description of 90, brand name of 25, call to action) and taking one card's place, so the grid
shows one more card while it is off (the airport shelf pages 23 bottles instead of 24, the
pages staying contiguous); presented-by billboards on the home and airport pages; strips on the
product page and after an article; a sticky 300 by 600 skyscraper in a rail beside the article
text (off, the text centres; left out below 1100px); and 300 by 250 rectangles beside the
introductions of Travel exclusives and Award winners. The server-rendered bodies draw every page
as it is with no creative; creative arriving in `SPONSORS` changes the airport and article
layouts in the browser only, which the mirrors (`seo.py`) must then follow. A
placement renders nothing until it has creative, or until a reviewer switches it on from
`/settings` "Sponsor placements", which shows a labelled placeholder of the exact size (the
native tile: a sample ad at the real limits) in this browser only; each row's "See it on a
sample page" switches it on and opens a real page of its kind at it (`#sponsor-<id>`: the
home page, the airport with the most products, the top featured product, the newest article). Adam picks the sparse set from those; the chosen ones get their sizes on
his `/todo` row "Sponsor creative" (waiting until then). Creative arrives as versioned files
under `public/sponsors/`, never overwritten in place. `/settings` also carries the accent
picker for the same review.
