# Phase: Guest-Table Standardization + Contextual Navigation

Commit: `feat(ui): standardize guest tables and add contextual navigation`
Base commit: `8ea779d fix(client): preserve event access preference and multi-event booking`

## 1. Scope

This phase is view/template/CSS-only. Direct inspection of the full route tree,
every view directory, and all three theme dashboards confirmed the work could
be completed entirely within `.ejs` templates and theme CSS — no route,
controller, model, middleware, schema, session, or authorization change was
required or made.

## 2. Guest-table standard

Target column order for a **normal event guest-management table**:

```
Name | Seats | Code | RSVP | Checked in
```

"Invitation Type" is removed from the table display only. The underlying
`guests.invite_group` database column, the `guestModel`/`admin.controller.js`
bulk-add parsing logic, and the "One guest per line: `Name, seat count`
(optionally `, invitation type`)" instructional hint on the Add-Guests form
are all **unchanged** — a value typed as the third comma-separated field is
still stored exactly as before; it's simply no longer rendered as its own
table column.

### Tables standardized

| File | Before | After |
|---|---|---|
| `views/admin/themes/botanical-bloom/dashboard.ejs` | Name, Invitation type, Seats, Passcode, (copy button), RSVP, Checked in | Name, Seats, Code (passcode text + existing Copy Code button merged into one cell), RSVP, Checked in |
| `views/admin/themes/lavender-romance/dashboard.ejs` | Guest, Invitation type, Seats, Passcode, RSVP, (copy button) | Name, Seats, Code (passcode + copy button merged), RSVP, **Checked in (new)** |
| `views/admin/themes/lady-gianna/dashboard.ejs` | Guest, Invitation type, Seats, Passcode, RSVP, (copy button) | Name, Seats, Code (passcode + copy button merged), RSVP, **Checked in (new)** |

Lavender Romance and Lady Gianna did not previously render a "Checked in"
column at all. The underlying data was already available on every `guest`
object passed to these templates — `models/guest.model.js#findByEvent`
already does `LEFT JOIN gatepasses gp ON gp.guest_id = g.id` and selects
`gp.checked_in` for every theme, unconditionally. Adding the column here is
therefore presentation-only: `guest.checked_in ? 'Yes' : 'No'`, the exact
same expression Botanical Bloom already used.

The "Copy Code"/"Copy code" buttons were not deleted — they were moved
inside the new "Code" cell (next to the passcode text) instead of occupying
their own trailing column, since the task's five-column target has no slot
for a standalone action column here and the button is a convenience action
tied directly to the code value, not an Edit/Delete-style operational
action. `class="copy-btn"` and `data-copy="<%= guest.passcode %>"` are
unchanged, so the existing shared clipboard script (inline `<script>` in
each dashboard, unmodified) continues to work identically.

No `<th></th>` empty header cell remains; each table now has exactly five
header cells.

### Justified exception — not touched

`admin.controller.js#exportGuestList`'s Excel export (`GET
/admin/events/:id/export`) includes an `'Invitation Type'` column
(`{ header: 'Invitation Type', key: 'inviteGroup', width: 25 }`). This is
generated in a controller (out of this phase's allowed scope regardless) and
serves a distinct reporting/download purpose separate from the on-screen
guest-management table — the task's own exception clause covers this case
explicitly. It is unchanged.

No other view in the repository renders `invite_group`/"Invitation type" —
confirmed by a repository-wide search; the three theme dashboards above were
the only guest-management tables that needed normalizing.

## 3. Navigation map (full audit)

Every route, every view under `views/public`, `views/client`, `views/admin`,
`views/guest`, `views/scan`, all three theme dashboards, and the guest
gallery/cameos/other-details/invitation templates were inspected. Most pages
already had adequate explicit navigation using existing routes; only three
pages had none at all.

| Page/context | Had navigation already? | Safe Home | Safe Back | Gap? |
|---|---|---|---|---|
| `views/public/home.ejs` | N/A — is the site home | — | — | No |
| `views/client/login.ejs` | Yes — cross-link to register + `/` | `/` | `/client/register` (cross-link) | No |
| `views/client/register.ejs` | Yes — cross-link to login + `/` | `/` | `/client/login` (cross-link) | No |
| `views/client/dashboard.ejs` | Yes — is client home, links to `/` | `/` | — | No |
| `views/client/book-event.ejs` | Yes — "Back to dashboard" | `/client/dashboard` | `/client/dashboard` | No |
| Client event portal (`/client`, `/client/edit`, `/client/assets`, theme dashboard in client mode) | Yes — header title link to `/client` (the portal itself, per task's own instruction: use the existing selected-event portal route, not `/client/dashboard`) | `/client` | `/client` | No |
| `views/admin/login.ejs` | **No** | `/` | — | **Yes — fixed** |
| `views/admin/events-list.ejs` | N/A — is admin home | — | — | No |
| `views/admin/event-form.ejs` | Yes — "Back to events" | `/admin/events` | `/admin/events` | No |
| `views/admin/inquiries-list.ejs` | Yes — "Back to weddings" | `/admin/events` | `/admin/events` | No |
| `views/admin/inquiry-detail.ejs` | Yes — "Back to inquiries" | `/admin/inquiries` | `/admin/inquiries` | No |
| `views/admin/inquiry-approved.ejs` | Yes — "Back to inquiry" + "Back to all inquiries" | `/admin/inquiries` | `/admin/inquiries/:id` | No |
| `views/admin/clients-list.ejs` | Yes — "Back to weddings" | `/admin/events` | `/admin/events` | No |
| `views/admin/client-detail.ejs` | Yes — "Back to clients" | `/admin/clients` | `/admin/clients` | No |
| `views/admin/edit-event.ejs` | Yes — "Back to `<event>`" using server-verified `basePath` | admin/client event dashboard | same | No |
| `views/admin/assets.ejs` | Yes — header nav, title link to `basePath` | admin/client event dashboard | same | No |
| Theme dashboards, admin (non-client) mode | Yes — "All weddings"/"All events" link | `/admin/events` | — | No |
| Theme dashboards, admin-preview mode | Yes — "Return to Admin" using server-rendered `returnToAdminUrl` (`/admin/events/:id`, verified id from the controller) | — | `/admin/events/:id` | No |
| `views/scan/scanner.ejs` | **No** | `/` | — | **Yes — fixed** |
| `views/guest/error.ejs` | **No** | `/` | — | **Yes — fixed** |
| `views/guest/themes/*/invitation.ejs` (3 themes) | N/A — is the guest's own hub; links out to gallery/cameos/other-details | — | — | No |
| `views/guest/themes/*/gallery.ejs` (3 themes) | Yes — "Back to the invitation" → `/invite/:id` | — | `/invite/:id` | No |
| `views/guest/themes/*/cameos.ejs` (3 themes) | Yes — "Back to the invitation" → `/invite/:id` | — | `/invite/:id` | No |
| `views/guest/themes/*/other-details.ejs` (3 themes) | Yes — "Back to the invitation" → `/invite/:id` | — | `/invite/:id` | No |

Guest navigation never exposes an admin/client destination anywhere in the
audit — confirmed both by direct template inspection and by a live check
(rendered `/invite/1` and `/invite/1/other-details` contain no `/admin` or
`/client` link).

### Changes made

- **`views/admin/login.ejs`** — added `<p><a href="/">&larr; Back to
  homepage</a></p>` after the login form. The bare, unstyled page had no
  link anywhere.
- **`views/scan/scanner.ejs`** — added a `Home` link (`<a class="scanner-home"
  href="/">&larr; Home</a>`) below the manual-entry form, with minimal
  matching CSS. No check-in logic touched.
- **`views/guest/error.ejs`** — added a `Back to homepage` link (`/`) below
  the existing message, with a small CSS adjustment (`flex-direction:
  column; gap: 1rem;`) so the two lines stack correctly inside the existing
  centered flex layout.

All three use a plain `<a href="/">` — no browser-history reliance, no
unvalidated return-URL parameter, no JavaScript redirect.

## 4. Files changed

```
views/admin/themes/botanical-bloom/dashboard.ejs   — guest table columns
views/admin/themes/lavender-romance/dashboard.ejs  — guest table columns
views/admin/themes/lady-gianna/dashboard.ejs        — guest table columns
views/admin/login.ejs                                — added Home link
views/scan/scanner.ejs                               — added Home link
views/guest/error.ejs                                — added Home link
PROMPT-REPORT.md                                     — new, this file
```

No other file was modified. `db/schema.sql`, `db/migrate.js`, `config/*`,
`models/*`, `controllers/*`, `routes/*`, `middleware/*`, `utils/*`,
`server.js`, `package.json`/`package-lock.json` were not touched.

## 5. Local test evidence

`.env` confirmed pointed at `localhost:5433` before any write. Baseline
counts recorded before testing: `clients` 0, `booking_inquiries` 0, `events`
7, `client_access` 0, `guests` 11, `gallery_photos` 0, `cameo_photos` 0.

- Homepage and every changed template rendered `200` with no EJS error
  output.
- Guest table header confirmed as exactly `Name | Seats | Code | RSVP |
  Checked in` on live rendered HTML for all three themes (Botanical Bloom
  on an existing local event, Lady Gianna on an existing local event with a
  disposable test guest added, Lavender Romance on one disposable local
  test event with a disposable test guest, since no local Lavender Romance
  event previously existed).
- Confirmed "Invitation Type" does not appear anywhere in any of the three
  rendered guest tables (only remaining occurrence anywhere on those pages
  is the unrelated, unchanged Add-Guests instructional hint text).
- Confirmed the existing Copy Code/Copy code buttons still render (with
  their original `data-copy` values) in normal admin and real client-portal
  rendering, and correctly do **not** render in admin-preview mode (the
  existing `isPreview` guard, unmodified).
- Confirmed "Checked in" values render correctly (`Yes`/`No`) using the
  already-available `guest.checked_in` field, including on the two themes
  that previously had no such column.
- Confirmed the real client portal (via a disposable local token) renders
  the same standardized table with the copy button active, and its existing
  header-title Home link (`/client`) is untouched.
- Confirmed admin-preview mode still renders zero `<form>` elements and no
  actual copy-button element (only the pre-existing, inert `<script>`
  querySelector text matched a naive search — no rendered `<button
  class="copy-btn">` exists in that mode).
- Confirmed the three new navigation links (`admin/login.ejs`,
  `scan/scanner.ejs`, `guest/error.ejs`) all render and point to `/`, by
  directly fetching each page (the error page triggered via a nonexistent
  event id, `GET /invite/999999` → `404`, page still renders with the new
  link).
- Confirmed unauthenticated redirects are unchanged: `GET /admin/events`
  (no session) → `302 /admin/login`; `GET /client/dashboard` (no session)
  → `302 /client/login`.
- Confirmed guest navigation remains event-scoped and exposes no
  admin/client link (`GET /invite/1` and `GET /invite/1/other-details`
  checked directly).
- Confirmed admin events/inquiries/clients/edit/assets pages all still
  render `200` and their pre-existing navigation is unchanged.
- Confirmed all three theme dashboards render without error, in normal
  admin mode, real client mode, and admin-preview mode.
- Mobile CSS: no layout mechanism was changed on any page. The guest tables
  have one fewer column than before (Invitation Type removed), which can
  only reduce horizontal table width/overflow risk, not increase it; the
  existing `overflow: auto` table-wrapper divs (`.lr-table-wrap`,
  `.lg-table-wrap`, and Botanical Bloom's unwrapped table, all pre-existing)
  are unchanged. The scanner and error-page additions are single inline
  links with no fixed widths. No browser at a specific mobile viewport was
  used to visually confirm this — see Limitations.

### Cleanup

Disposable test records created during this phase: 1 test guest on an
existing Lady Gianna event, 1 disposable Lavender Romance event (with 1 test
guest and 1 manually-inserted `client_access` test-token row, removed via
the event's cascade delete). All deleted after testing. Final counts
confirmed to match the baseline exactly: `clients` 0, `booking_inquiries` 0,
`events` 7, `client_access` 0, `guests` 11, `gallery_photos` 0,
`cameo_photos` 0. No pre-existing business record was changed.

## 6. Plain Cards — audit only, no implementation

### `config/themes.js`

`AVAILABLE_THEMES` is a flat array of objects: `{ slug, label, eventType,
swatchColors, tagline, googleFonts }`. `eventType` scopes which event types
may offer the theme (`themesForEventType()` filters by it).
`DEFAULT_THEME_BY_EVENT_TYPE` maps `wedding` → `botanical-bloom`, `birthday`
→ `lady-gianna`. Every theme is fully self-contained — its own CSS file
under `public/themes/<slug>/theme.css`, its own guest invitation template
set under `views/guest/themes/<slug>/`, and its own admin/client dashboard
template under `views/admin/themes/<slug>/dashboard.ejs`.

### `config/eventTypes.js`

`EVENT_TYPES` is a flat array: `{ slug, label, titleLabel, dateLabel }`
(the last field added in the immediately prior release). A type only
appears in any selector once at least one theme in `AVAILABLE_THEMES`
declares that `eventType` — today only `wedding` and `birthday` are active;
the other seven slugs exist in the taxonomy and the database CHECK
constraint but have no theme yet, so they're inert everywhere in the UI.

### `config/accessModes.js`

`ACCESS_MODES` is a flat array of exactly three entries: `open`,
`recognized`, `closed`, each `{ slug, label }`. `DEFAULT_ACCESS_MODE =
'closed'`. This governs guest-list/passcode/RSVP/QR gating behavior
(`guest.controller.js`), not theme selection — orthogonal to Plain Cards.

### Theme selection and storage in the booking → event lifecycle

A client chooses a theme (optional) on the booking form
(`views/client/book-event.ejs`, `<select name="preferredTheme">`), scoped
client-side/server-side to `themesForEventType(eventType)`. It's stored as
`booking_inquiries.preferred_theme` (nullable, no CHECK — themes can be
added later without a migration, per the column's own existing design). At
approval, `models/inquiry.model.js#approve()` resolves the final theme: the
client's preference if it actually belongs to the event's type, otherwise
`DEFAULT_THEME_BY_EVENT_TYPE[eventType]` — never a cross-type theme. The
resolved value is written to `events.theme` (`NOT NULL`, no CHECK). From
that point, `events.theme` is the single value every downstream template
selection keys off.

### Guest invitation view selection

`controllers/guest.controller.js#renderInvitation`:
```js
const view = `guest/themes/${event.theme}/invitation`;
```
A direct, unvalidated string interpolation of `events.theme` into the EJS
template path. Safe today only because `events.theme` can never hold an
arbitrary string — every write path (`admin.controller.js`'s
`resolveTheme()`, and `inquiry.model.js#approve()`'s equivalent inline
logic) validates the value against the `AVAILABLE_THEMES` registry before
it's ever persisted. A **new** theme therefore requires, at minimum, a
matching `views/guest/themes/<slug>/` directory to exist before any event
can be given that theme — there is no generic/fallback guest template.

### Does invitation copy currently support arbitrary client-authored text?

Yes, already, independent of theme. `events` already carries several free-text
fields editable by admin (and, for the non-admin-only ones, by the client
through their own portal): `invitation_message`, `itinerary`,
`contact_details`, `footer_note`, `subtitle`, `decline_message`,
`accept_button_text`, `decline_button_text`. Every guest invitation template
across all three themes already renders these fields when present, with a
generic fallback line when empty (e.g. "The schedule for the day will be
shared here soon."). No theme currently accepts genuinely open-ended
free-form "card text" beyond these named fields — there's no single
"write your own invitation" textarea; it's several purpose-labeled fields
composed into the template's fixed layout.

### Smallest likely future scope for an Open Event "Plain Card" theme

**Presentation-only work** (no data/model/schema change needed):
- A new `views/guest/themes/plain-card/invitation.ejs` (+ optionally
  `gallery.ejs`/`cameos.ejs`/`other-details.ejs`, or these could
  legitimately reuse another theme's if the product wants Plain Card to
  differ only in the hero/invitation layout) and a new
  `public/themes/plain-card/theme.css`.
- A new `views/admin/themes/plain-card/dashboard.ejs` admin/client
  dashboard template, following the existing per-theme dashboard pattern.
- A new entry in `config/themes.js`'s `AVAILABLE_THEMES` array
  (`slug: 'plain-card'`), with an `eventType` — likely a new, more
  general-purpose type such as `other`, or scoped to whichever type(s)
  the product wants "plain" for.
- All of the above is exactly the same shape of work as adding the existing
  three themes already was — no new architectural pattern required.

**Data/model/schema needs, if any**: none apparent from current
inspection, *provided* the product accepts using the existing free-text
fields (`invitation_message`, `itinerary`, `contact_details`, `subtitle`,
etc.) as Plain Card's content surface, the same way every existing theme
already does. If the product instead wants a genuinely open-ended
"client writes their own full card text" experience distinct from the
current field-by-field model, that would need a new schema column and is
explicitly **not** scoped here — flagged as a product decision below.

**Product decisions still required** (none of these were decided or
implied by this audit):
- Which `event_type`(s) Plain Card should be offered for — a new
  general-purpose type, or an existing one (`other`, `wedding`, etc.)?
- Whether "Open Event" in the phase name means Plain Card is gated to
  `access_mode = 'open'` events specifically, or is theme-independent of
  access mode (today, theme and access mode are two fully independent
  fields on `events` with no relationship between them).
- Whether Plain Card reuses the existing per-theme dashboard pattern
  (a full bespoke `lg-admin-*`/`lr-*`/`bb-*`-style dashboard) or is meant
  to be visually minimal on the *admin/client* side too, not just the
  guest-facing card.
- Whether the existing named free-text fields are sufficient "plain card"
  content, or a genuinely freeform authoring surface is wanted.

No Plain Cards route, view, CSS file, config entry, event type, database
field, or client-editing form was added. This section is audit and
recommendation only.

## 7. Confirmations

- No schema, migration, package, backend, session, or authorization change
  was made.
- No production write occurred at any point in this phase.
- No Plain Cards implementation occurred.
- No secret, password, hash, raw token, cookie/session ID, environment
  value, or database URL was printed or exposed at any point.

## 8. Limitations / not visually browser-tested

- No browser-rendering or screenshot tool was available in this session.
  All verification was performed via direct HTTP requests against the
  local server and inspection of the returned HTML (header/column order,
  presence/absence of specific text, presence of specific classes and
  attributes) — not a visual/pixel render at any viewport width.
- Mobile layout was reasoned about (column count reduced, no layout
  mechanism changed, existing overflow wrappers untouched) rather than
  visually confirmed at 320–768px.
- The Lavender Romance guest table's live local test used one disposable
  event created for this purpose, since no Lavender Romance event already
  existed in the local database; Botanical Bloom and Lady Gianna used
  pre-existing local events.

# Phase: Plain Card Theme — Full Implementation and Release

Commit: `feat(theme): add plain card event experience`
Base commit: `7f5f81e feat(ui): standardize guest tables and add contextual navigation`

Product decisions (from the prior audit) approved by the project owner before
this phase began: Plain Card is a **universal** theme available for **every**
existing event type, and all seven previously-inactive event types
(`bridal-shower`, `baby-shower`, `engagement`, `anniversary`, `graduation`,
`corporate`, `other`) become active/selectable as a direct result.

## 1. Scope

New theme only. No schema, migration, route, controller, model, middleware,
session, or access-mode-logic change. The one edit outside brand-new Plain
Card files is additive registration data in `config/themes.js` (described
below) — no existing theme's entry, no function signature, and no other
config file was touched.

## 2. Theme registration

`config/themes.js`'s `AVAILABLE_THEMES` gained nine new entries, one per
existing `config/eventTypes.js` slug (`wedding`, `birthday`, `bridal-shower`,
`baby-shower`, `engagement`, `anniversary`, `graduation`, `corporate`,
`other`), each `{ slug: 'plain-card', label: 'Plain Card', eventType: <type>,
swatchColors, tagline, googleFonts }` — identical across all nine except
`eventType`. This is safe under the existing registry architecture: every
consumer either filters via `themesForEventType()`/`.filter()` (naturally
partitions by type) or does a global `.find(slug)` (exactly two call sites —
`admin.controller.js`'s and `clientAccount.controller.js`'s `themeLabel()`
helpers — both reading only `.label`, identical across every duplicate).
Verified live: `GET /admin/events/new` now lists all nine event types and
shows "Plain Card" as a theme option under each.

**Required accompanying fix**: `DEFAULT_THEME_BY_EVENT_TYPE` previously only
mapped `wedding`/`birthday`. Registering Plain Card made the other seven
types selectable in the UI for the first time, and both
`admin.controller.js#resolveTheme()` and `models/inquiry.model.js#approve()`
fall back to `DEFAULT_THEME_BY_EVENT_TYPE[type]` whenever no valid theme is
submitted, writing directly into the `NOT NULL` `events.theme` column. Left
unfixed, approving or manually creating an event of one of those seven types
without an explicit theme choice would have thrown a Postgres NOT NULL
violation. Fixed by adding all seven missing keys, each mapping to
`'plain-card'` — an additive entry in the same map, in the same file already
in scope, not a new function or a logic change.

## 3. Files created

```
public/themes/plain-card/theme.css                    — new
public/themes/plain-card/invitation-background.png    — new (copied from plain-card-approved/)
public/themes/plain-card/portal-background.png        — new (copied from plain-card-approved/)
views/guest/themes/plain-card/invitation.ejs           — new
views/guest/themes/plain-card/gallery.ejs              — new
views/guest/themes/plain-card/cameos.ejs               — new
views/guest/themes/plain-card/other-details.ejs        — new
views/admin/themes/plain-card/dashboard.ejs            — new
```

## 4. Files edited

```
.gitignore          — added `plain-card-approved/` (reference package excluded from version control)
config/themes.js    — 9 new AVAILABLE_THEMES entries + 7 new DEFAULT_THEME_BY_EVENT_TYPE keys
PROMPT-REPORT.md    — this section
```

No other file was touched. `db/schema.sql`, `db/migrate.js`, every
`controllers/*`, `routes/*`, `models/*`, `middleware/*`, `utils/*`,
`server.js`, `package.json`/`package-lock.json`, `config/accessModes.js`, and
every file under the three existing themes (Botanical Bloom, Lavender
Romance, Lady Gianna) were not modified.

## 5. Prototype-to-production mapping

Source: `plain-card-approved/` (invitation.html, portal.html, styles.css, two
background PNGs) — the approved visual reference package, never itself
modified, moved, or made a runtime dependency; it stays git-ignored and
untracked.

- **Visual tokens** (`--ink`, `--paper`, `--gold`, `--gold-soft`, `--muted`,
  `--white`, `--cut`, the 4-corner invitation chamfer, the 2-corner
  portal-shell chamfer, both breakpoints at `900px`/`680px`) copied verbatim
  into `public/themes/plain-card/theme.css`.
- **Invitation background**: the prototype's static image became
  `event.card_image || '/themes/plain-card/invitation-background.png'`,
  rendered via three stacked, non-conflicting layers (`.background-fallback`
  gradient, `.background-image` conditional URL, `.background-overlay`
  readability gradient) — reusing the same unconditional, pre-passcode-gate
  `event.card_image` exposure pattern already established by all three
  existing themes (verified in `botanical-bloom/invitation.ejs`).
- **Portal background**: intentionally **not** event-derived — hardcoded as
  `url("portal-background.png")` (relative to the served CSS path,
  `/themes/plain-card/portal-background.png`) directly in `.portal-background`,
  since the admin/client portal chrome is not guest-facing invitation content.
- **Fake elements removed/replaced**: the prototype's non-functional
  `#gatepass-toggle` JS button is suppressed (`.gatepass-toggle { display:
  none; }`) and replaced with real POST forms to the existing
  `/invite/:id/rsvp` route; the hardcoded placeholder QR/`GUEST CODE`
  strings are replaced with the real `qrDataUrl`/`guest.passcode` values
  used by every existing theme; every prototype `href="#"` link is replaced
  with a real internal route; the prototype's five palette-swatch buttons
  and "Change" background-picker link (no backing data model exists) were
  deliberately **omitted entirely** rather than implemented as dead UI.
- **Guest secondary pages** (gallery/cameos/other-details): no prototype
  reference existed for these. Built by extending Plain Card's own
  ivory/gold/serif token language into a new, simple `.pc-secondary-page`
  treatment, mirroring the same relationship every existing theme already
  has between its invitation and its own secondary pages.
- **Long-content safety**: `h1`, `.invitation-message`, `.portal-title`, and
  mini-card heading `clamp()` minimums were lowered and `line-height`
  raised from the prototype's originals so long couple names/messages wrap
  rather than clip; `.event-details`, `.card-footer`, `.portal-topbar`,
  `.portal-nav`, and `.portal-invites-heading` were all given `flex-wrap:
  wrap`.

## 6. Access-mode behavior — implementation evidence

All three modes verified live against real local test events, not just read
from code:

- **Open** (`access_mode = 'open'`): `GET /invite/:id` bypasses the passcode
  overlay entirely and renders the invitation directly (`YOU ARE INVITED`
  present, `Enter your passcode` absent). No RSVP form (`name="response"`),
  no gatepass/QR markup, and no guest-management section at all on the
  dashboard (`event.access_mode !== 'open'` wraps the entire section) —
  confirmed by a zero match count for `portal-invites-section` on a live
  open-mode dashboard render.
- **Recognized** (`access_mode = 'recognized'`): passcode-gated
  (`Enter your passcode` present pre-verification); after verifying and
  submitting a real RSVP accept, the invitation correctly transitions to the
  `state === 'confirmed'` view ("You're confirmed"); no gatepass/QR section
  ever renders (`qr-frame` absent). Dashboard guest table renders exactly
  `Name | Seats | Code | RSVP` (no "Checked in" column).
- **Closed** (`access_mode = 'closed'`): passcode-gated; after RSVP accept,
  a real gatepass row is created and the invitation renders a genuine
  `<img src="data:image/...">` QR code (not a placeholder). Posting that
  gatepass's real `qr_token` to the existing `/scan/verify` endpoint
  returned `{"result":"checked_in", ...}`; re-fetching the guest's
  invitation afterward correctly showed the "already checked in" message.
  Dashboard guest table renders exactly `Name | Seats | Code | RSVP |
  Checked in`, and the checked-in guest's row correctly showed `Yes`.

## 7. Role/capability and preview evidence

- **Admin (normal)**: dashboard for a live Plain Card event rendered `200`;
  Add-Guests form, Download Excel link, and the Danger Zone (status-toggle
  and delete forms, with the same `confirm()` dialog text pattern as the
  existing themes) all present.
- **Client (portal, non-preview)**: nav includes Assets, Other Details, My
  Events, and a real `POST /client/logout` form; Publish form shown for a
  draft event.
- **Admin-preview**: fetched `GET /admin/events/:id/client-preview` for a
  Plain Card event — confirmed **zero** `<form>` elements anywhere on the
  page and **zero** Copy-code buttons, alongside the expected read-only
  `Admin preview` banner and `Return to Admin` link. Matches the existing
  `isPreview` conditional pattern used by all three prior themes.
- **Booking → approval fallback**: registered a test client, submitted a
  booking inquiry for `eventType: 'anniversary'` (a formerly-inactive type)
  with no preferred theme, then approved it as admin — the resulting event
  received `theme: 'plain-card'` via the `DEFAULT_THEME_BY_EVENT_TYPE`
  fallback, with no database error. Also directly created events via the
  admin form for `engagement` (no theme submitted → fallback), `wedding`
  (theme explicitly set to `plain-card`), and `corporate` (no theme
  submitted, `access_mode: open`) — all three succeeded.

## 8. Local test evidence

`.env` confirmed pointed at `localhost:5433` before any write. Baseline
counts recorded before testing: `clients` 0, `booking_inquiries` 0, `events`
7, `client_access` 0, `guests` 11, `gallery_photos` 0, `cameo_photos` 0.

- Every Plain Card guest page (invitation, gallery, cameos, other-details)
  rendered `200` with no EJS error, across open/recognized/closed test
  events, both before and after passcode verification.
- Existing themes re-checked for regression: `GET /themes/{botanical-bloom,
  lavender-romance,lady-gianna}/theme.css` all `200`; an existing live
  Botanical Bloom event's dashboard and invitation both still rendered
  `200`.
- `GET /admin/events/new` confirmed all nine event types and a
  correctly-labeled "Plain Card" option for each.

### Cleanup

Disposable test records created: three admin-created test events (`engagement`,
`wedding`, `corporate`, covering all three access modes plus both fallback
and explicit theme selection), one guest added to each of the recognized and
closed test events (one carried through a full verify → RSVP-accept →
gatepass/QR → scanner-check-in cycle), one test client account, and one
booking inquiry approved into a fourth test event. All events deleted via
the existing admin delete route (cascading their guests/gatepasses); the
disposable client and booking-inquiry rows — for which no admin delete route
exists — were removed directly via SQL, consistent with prior-phase cleanup
practice for records with no UI delete path. Final counts confirmed to match
the baseline exactly: `clients` 0, `booking_inquiries` 0, `events` 7,
`client_access` 0, `guests` 11, `gallery_photos` 0, `cameo_photos` 0. No
pre-existing business record was changed.

## 9. Responsive design — evidence and limitation

No browser-rendering or screenshot tool was available in this session (only
`DesignSync`/`WebFetch` were reachable via tool search, and neither can
render or screenshot `localhost`). All responsive claims are therefore
structural, not visual:

- Every breakpoint present in the approved prototype's `styles.css`
  (`900px` portal-workspace collapse with `.portal-preview-panel { order:
  -1; }`, `680px` fine-grained mobile rules) was preserved verbatim in
  `theme.css`.
- Long-content wrapping (`overflow-wrap: break-word`, lowered `clamp()`
  minimums, raised `line-height`) was added to every heading/body text
  element identified as fixed-height-risk during the audit.
- Flex-wrap was added to every horizontal row-layout element identified in
  the audit as a potential narrow-viewport overflow risk.
- No pixel-level visual confirmation at 320/375/414/768/900/1024/1280/1440px
  was performed — this limitation is disclosed honestly rather than implied
  otherwise.

## 10. Confirmations

- No schema, migration, package, route, controller, model, middleware,
  session, security, access-mode, RSVP, gatepass, QR, scanner, or
  upload-flow change was made. The only backend-adjacent edit is the
  additive `DEFAULT_THEME_BY_EVENT_TYPE` registration data described in
  Section 2, inside the already-in-scope `config/themes.js`.
- No production write occurred at any point in this phase; no production
  migration was run (none is needed — no schema change was made).
- `plain-card-approved/` was never modified, moved, staged, or committed,
  and never became a runtime dependency — confirmed ignored via `git
  status --short` before and after this phase's changes.
- No secret, password, hash, raw token, cookie/session ID, environment
  value, or database URL was printed or exposed at any point.
- Admin-preview mode confirmed strictly read-only for Plain Card, matching
  the existing three-theme pattern.

# Phase: Plain Card Completion Patch

Commit: `fix(theme): complete plain card guest experience`
Base commit: `aa4abb4 feat(theme): add plain card event experience`

Six specific gaps identified by the project owner after Plain Card's initial
release, fixed here. All changes are view/CSS-only inside files already
established as in-scope (Plain Card's own theme.css/views, plus two lines in
the shared `admin/assets.ejs` font map — see item 2). No route, controller,
model, middleware, schema, session, or access-mode logic was touched.

## Files changed

```
public/themes/plain-card/theme.css              — new CSS: bb-admin/edit-page
                                                    theming, QR modal, itinerary,
                                                    invitation-link input styling
views/guest/themes/plain-card/invitation.ejs    — itinerary section, personalized
                                                    message, QR overlay/modal
views/admin/themes/plain-card/dashboard.ejs      — Copy Invitation Link panel
views/admin/assets.ejs                          — added Plain Card font entry
PROMPT-REPORT.md                                — this section
```

## 1. Closed-event QR overlay

`guest.controller.js#renderInvitation` already computed everything needed
(`state === 'qr'` only for a verified closed-event guest with a non-expired
gatepass, plus `qrDataUrl`/`checkedIn`) — no controller change was made or
needed. In `invitation.ejs`, the QR `<img>` that used to render permanently
inside the card now renders inside a `hidden`-by-default `.pc-qr-modal`,
opened only by a new "Show my QR pass" button that exists only inside the
`state === 'qr'` branch. A small inline script (guarded by
`if (!trigger || !modal) return;`, so it no-ops on every other state) toggles
the `hidden` attribute, listens for `Escape`, and returns focus to the
trigger button on close — no page navigation ever occurs, so closing leaves
the guest at the exact scroll position they were at. No new QR, gatepass,
token, or verification mechanism was created; the exact existing
`qrDataUrl`/`gatepass.checked_in` values are reused. Verified live: the QR
image and "Show my QR pass" button are completely absent from the HTML for
open events, recognized events, and an unverified visitor to a closed event
(confirmed via direct fetch with no session/passcode) — the data simply
isn't in the response, not just hidden by CSS.

## 2. Plain Card styled edit page

`views/admin/edit-event.ejs` and `views/admin/assets.ejs` are already shared,
unmodified-in-form templates reused by every theme (confirmed by direct
inspection of `client.controller.js`/`admin.controller.js`) — their form
action, method, `enctype`, input names, hidden fields, and redirects are
identical regardless of theme. Every existing theme already styles this
shared markup purely through its own `theme.css`, via the generic
`.bb-admin-*` class names `admin-shared.css` supplies the layout/geometry
for (see that file's own header comment) — Plain Card's `theme.css` simply
had no such rules yet. Added a `.bb-admin-*` color/font block (matching
exactly the same selector set the other three themes' own blocks use) plus
a new `.bb-btn` base definition, using Plain Card's existing ivory/gold/
Playfair Display token language. **Zero changes to either shared template.**
Also added a `'plain-card'` entry to `assets.ejs`'s own hardcoded
`assetsGoogleFontsBySlug` map (previously fell back to Botanical Bloom's
fonts) — a one-line, additive, in-scope view change so the now-themed
`.bb-admin` headings actually load Plain Card's own font family; flagged
here explicitly since it's a minor addition beyond item 2's literal
"edit page" wording, in the same file family, with no risk to the other
three themes (their own map entries are untouched). Verified live: both
pages render `200` for a Plain Card event with `Playfair Display` in the
loaded fonts and `class="bb-admin"` on `<body>`; the edit form's POST target
(`/admin/events/:id`) is unchanged; an existing Botanical Bloom event's edit
and assets pages still render `200` with their own unchanged fonts.

## 3. Copy Invitation Link

Added a new panel to the Plain Card dashboard only,
`<% if (!isPreview && event.status === 'live') { %>`, containing a readonly
input and a button using the exact same `class="copy-btn" data-copy="..."`
convention (and the same already-existing inline clipboard `<script>`) every
other theme's own "Copy Link" button already uses — no new JavaScript
mechanism was added. The URL is `<%= baseUrl %>/invite/<%= event.id %>`,
built from `baseUrl` (`${req.protocol}://${req.get('host')}`), which every
dashboard render path (admin, client, and admin-preview) already receives
from the controller — no controller change was needed. `baseUrl` correctly
reflects `https` in production via the existing `app.set('trust proxy', 1)`.
The always-present readonly input (`onclick="this.select()"`) itself serves
as the safe manual fallback if the Clipboard API is unavailable. Verified
live: present and correct on a live event's normal dashboard; absent from
`GET /admin/events/:id/client-preview` (admin-preview); absent from a draft
event's dashboard.

## 4. Restore existing authorized actions

Audited against the live dashboard and found **already correct** — no code
change was needed for this item. Add Guests (guarded `!isPreview`), the
Assets link (`<%= basePath %>/assets`, guarded to not appear in preview),
and Cameos management (handled by the same shared `assets.ejs` Cameos
section every other theme also links to via its own single "Assets" link —
none of the three existing themes have a separate dashboard-level Cameos
link either) were all already present from the initial Plain Card release.
Verified live: `GET /admin/events/:id` for a normal admin session shows the
Assets link, Other Details link, and the Add-Guests form; `GET
/admin/events/:id/client-preview` shows none of them (only "Return to
Admin").

## 5. Personalized guest message

First inspected `guests` table columns directly
(`id, event_id, name, seat_count, passcode, email, rsvp_status,
responded_at, created_at, invite_group`) and `models/guest.model.js` in
full — confirmed **no per-guest custom-message field exists** anywhere in
schema or model. Per the task's own instruction for this case, implemented
the generated message from existing guest data only, with correct
singular/plural grammar, added to `invitation.ejs`:
`"Hi <name>, 1 seat is reserved for you."` / `"Hi <name>, 2 seats are
reserved for you."`, followed by `"Show your QR pass at the door."` only for
closed, verified, accepted guests (`state === 'qr'`). This text is only ever
rendered inside `state !== 'open'` branches with a resolved `guest` object —
never shown to open-event visitors (no guest identity exists there) or to
unverified recognized/closed visitors (that's `state === null`, the passcode
overlay, an entirely separate branch). `event.invitation_message` and
`event.footer_note` are untouched. No schema or model change was made.
Verified live: a 1-seat guest sees "1 seat is reserved for you."; a 3-seat
guest sees "3 seats are reserved for you."; a closed, accepted, 1-seat guest
sees the full "...reserved for you. Show your QR pass at the door." copy.

## 6. Itinerary and QR placement

Added a `.pc-itinerary` section to `invitation.ejs`, reusing the exact same
`(event.itinerary || '').split('\n')...filter(Boolean)` parsing
`other-details.ejs` already uses, rendered between the event-details block
and the RSVP/QR state block ("lower invitation area"), cleanly omitted when
`event.itinerary` is empty. The "Show my QR pass" button (item 1) sits
inside the state block immediately below the itinerary section, never
replacing or hiding it, and the QR itself only ever appears in the overlay
modal — it never occupies the itinerary's layout space. Verified live: an
open event with itinerary text shows both the itinerary list and (since open
events have no RSVP/QR at all) nothing else in that area; a closed, accepted
guest sees the itinerary followed by the "Show my QR pass" button, with the
modal itself only appearing after the button is clicked (client-side, not
independently verifiable via a plain HTTP fetch, but the modal's `hidden`
attribute and the QR `<img>`'s presence only inside that hidden container
were confirmed in the raw HTML response).

## Local test evidence

`.env` confirmed pointed at `localhost:5433` before any write. Baseline
counts recorded before testing: `clients` 0, `booking_inquiries` 0, `events`
7, `client_access` 0, `guests` 11, `gallery_photos` 0, `cameo_photos` 0.

Created four disposable Plain Card test events (open/recognized/closed, plus
one throwaway draft to confirm the Copy Invitation Link's draft-hiding
rule), two disposable guests each on the recognized and closed events (a
1-seat and a 3-seat guest, to test singular/plural grammar), and set
itinerary text via the existing edit-update route (creation itself doesn't
accept `itinerary` — an existing, unrelated, unmodified limitation of
`createEvent`). Ran the full guest lifecycle (verify → RSVP accept → QR/
gatepass) for both closed-event test guests. All items above were verified
against live HTTP responses, not just read from code. Existing Botanical
Bloom edit/assets pages re-checked for regression — unaffected. All four
test events deleted via the existing admin delete route afterward. Final
counts confirmed to match the baseline exactly: `clients` 0,
`booking_inquiries` 0, `events` 7, `client_access` 0, `guests` 11,
`gallery_photos` 0, `cameo_photos` 0.

## Confirmations

- No schema, migration, package, route, controller, model, middleware,
  session, security, access-mode, RSVP, gatepass, QR, scanner, or
  upload-flow change was made — all six items were resolved entirely within
  Plain Card's own theme.css/views plus one additive font-map entry in the
  already-shared `admin/assets.ejs`.
- No production write occurred at any point in this phase; no production
  migration was run (none needed).
- No secret, password, hash, raw token, cookie/session ID, environment
  value, or database URL was printed or exposed at any point.
- Admin-preview mode re-confirmed strictly read-only after adding the Copy
  Invitation Link feature (zero forms, zero real copy buttons — only the
  inert shared `<script>` text matched a naive search, no rendered
  `<button class="copy-btn">` element exists in that mode).
- The other three existing themes were not modified and were re-verified
  to render without regression.

# Phase: Custom Invitation Theme — Implementation from an External Design Drop

Commit: `feat(theme): add custom invitation event experience`
Base commit: `3176e73 fix(theme): complete plain card guest experience`

Note on file naming: the task instructions asked for findings to be written
to a new `input_N.md`. No such file convention exists anywhere in this
repository — `input_20`, `input_13`, etc. are phase labels used only in code
comments, from a numbering scheme that predates this file. This section is
added to `PROMPT-REPORT.md` instead, consistent with how every prior phase
in this project has actually been documented.

## Source and audit

Source: `custom-invitation-theme/` (theme.css, invitation.ejs, gallery.ejs,
cameos.ejs, other-details.ejs, dashboard.ejs, README-integration-notes.md) —
an external design drop, explicitly built against *assumed* variable/route
names per its own README, never a runtime dependency, added to `.gitignore`
(same rule as `plain-card-approved/`).

No audit of this package existed anywhere in the prior conversation — one
was performed from scratch before any file was touched, by reading the
package in full and cross-checking every assumption against the real
`guest.controller.js`, `admin.controller.js`, `event.model.js`,
`db/schema.sql`, and `utils/assetExists.js`. This surfaced issues well
beyond simple renames:

- `event.card_image` / `event.background_image` are plain TEXT URL strings
  in the database, not `{ url, public_id }` objects — the package's
  `event.card_image.url` pattern was wrong, as was its use of `assetExists()`
  (a *local filesystem* check for bundled static assets, via
  `fs.existsSync` — never appropriate for a Cloudinary URL) to gate whether
  an image had been uploaded. Fixed to plain truthy checks
  (`if (event.card_image)`), matching every existing theme.
- `event.itinerary` is plain newline-separated text (parsed with
  `.split('\n')`), not an array of `{ time, label }` objects — a real
  data-shape mismatch, not a rename. Rather than changing how `itinerary` is
  stored (shared by all four other themes), each line is now parsed as
  `"TIME - LABEL"` — the exact convention already established by
  `edit-event.ejs`'s own placeholder text and every real itinerary anyone
  types into this app — falling back to a label-only row when a line has no
  `" - "` in it.
- The guest-facing templates assumed a `basePath` variable for routes like
  `<%= basePath %>/verify` — `guest.controller.js` never passes `basePath`
  to guest pages; real routes are absolute (`/invite/:eventId/verify`,
  `/rsvp`, `/gallery`, `/cameos`, `/other-details`). Fixed throughout.
- `gallery.ejs`/`cameos.ejs` assumed `photos`/`cameos` are always arrays,
  with no passcode-prompt branch — the real controller returns
  `photos: null` until a passcode is resolved (session or `?passcode=`
  query fallback), exactly like every existing theme's own gallery/cameos
  page. Added the missing null-state branch to both files; also renamed
  `cameos` to `photos` in `cameos.ejs` (the controller uses one shared
  variable name for both routes).
- The dashboard assumed `event.event_date` (real field: `wedding_date`), a
  GET `/guests/new` page (real: a bulk-textarea POST to `<basePath>/guests`),
  a basePath-relative guest-export link (real: a fixed, admin-only
  `/admin/events/:id/export`, no client equivalent), and one shared
  `<basePath>/status` toggle for both admin and client (the client side is
  actually only ever a one-way `/client/publish`, with no draft-toggle and
  no delete — matches every other theme's own client-status section). All
  corrected.
- No CSRF middleware exists anywhere in this app — the package's
  `csrfToken` guards were dead code, removed.
- The theme's own CSS was missing the `--bb-admin-radius` custom property
  that `admin-shared.css` reads for border-radius on the shared edit/assets
  pages (every other theme defines it) — added.
- A real bug introduced during my own correction pass, caught by local
  testing (not by inspection): moving the `welcomeBack` decline-banner check
  to use the controller's own precise flag (instead of re-deriving it from
  `guest.rsvp_status === 'declined'`, as the original package did) initially
  left that check outside the `state === 'card'` branch — `welcomeBack` is
  only ever passed by the controller when `state === 'card'`, so visiting
  the invitation in `state === 'confirmed'`/`'qr'`/`'expired'` threw a
  `ReferenceError` and produced a raw 500. Reproduced live (`GET
  /invite/:id` returned "Something went wrong." for an already-accepted
  recognized-mode guest), then fixed by moving the check inside the
  `state === 'card'` branch where the variable is always defined. Re-tested
  and confirmed working afterward.
- What the package *already got right*, confirmed by inspection (not
  reworked): the QR-in-modal mechanic (hidden by default, opened by a
  button that only exists in `state === 'qr'`, closed by button/Escape,
  built from the real `qrDataUrl`), the accept/decline submit-button pattern
  (`name="response" value="accepted|declined"` on two buttons in one form —
  valid HTML, works correctly against the real `submitRsvp` handler once the
  form's `action` URL was fixed), `cameo_photos`' `image_url`/`title`
  fields, and the overall admin/client/preview dashboard structure
  (`isClient`/`isPreview`/`basePath`).

## `background_image`, `invitation_heading`, `dress_code_note`,
`accessibility_note` — product decisions

The README flagged these as needing explicit sign-off before implementation
rather than guessing. Before touching any file, the project owner confirmed:
add `background_image` with its full real scope (schema column pair, a
dedicated upload/delete route mirroring gallery/cameo's own pattern, and a
generic field on the shared `assets.ejs` — not the smaller "just a template
field" scope the README's own wording implied), and add all three new
TEXT fields (`invitation_heading`, `dress_code_note`, `accessibility_note`)
to the schema and the shared edit form.

**Judgment call**: the task instruction said to add `custom-invitation` to
`DEFAULT_THEME_BY_EVENT_TYPE` "for each" of the nine event types, which read
literally would also reassign `wedding`/`birthday`'s own deliberately-chosen
flagship defaults (`botanical-bloom`, `lady-gianna`) — predating Plain Card
— to `custom-invitation`. Those two were left untouched when Plain Card
became the default for the other seven types in the prior release; the same
precedent was kept here. `custom-invitation` replaces `plain-card` as the
fallback only for the seven types that had no prior deliberate default
(`bridal-shower`, `baby-shower`, `engagement`, `anniversary`, `graduation`,
`corporate`, `other`). Flagged here for the project owner to correct if
`wedding`/`birthday` were actually meant to change too.

## Files changed

```
db/schema.sql                                  — 5 new additive columns on events
models/event.model.js                          — 3 new update() fields; setBackgroundImage/clearBackgroundImage
controllers/admin.controller.js                — 3 new updateEventCore fields; upload/deleteBackgroundImage(Core)
controllers/client.controller.js               — uploadBackgroundImage/deleteBackgroundImage wrappers
routes/admin.routes.js                         — 2 new background-image routes
routes/client.routes.js                        — 2 new background-image routes
views/admin/edit-event.ejs                     — 3 new shared text fields
views/admin/assets.ejs                         — new Background Image section; custom-invitation font entry
config/themes.js                               — 9 new AVAILABLE_THEMES entries; 7 new DEFAULT_THEME_BY_EVENT_TYPE keys
.gitignore                                     — added custom-invitation-theme/
public/themes/custom-invitation/theme.css      — new (corrected copy)
views/guest/themes/custom-invitation/*.ejs     — new, 4 files (corrected copies)
views/admin/themes/custom-invitation/dashboard.ejs — new (corrected copy)
PROMPT-REPORT.md                               — this section
```

No route/controller/model file outside the additions listed above was
touched. `server.js`, `package.json`/`package-lock.json`, `db/migrate.js`,
`middleware/*`, `utils/*`, `config/accessModes.js`, and every file under the
four existing themes (Botanical Bloom, Lavender Romance, Lady Gianna, Plain
Card) were not modified.

## Local test evidence

`.env` confirmed pointed at `localhost:5433` before any write. Baseline
counts recorded before testing: `clients` 0, `booking_inquiries` 0, `events`
7, `client_access` 0, `guests` 11, `gallery_photos` 0, `cameo_photos` 0.
`npm run db:migrate` applied the 5 new columns locally — verified via
`information_schema.columns` (all nullable, no default) and a row-count
check confirming zero rows were touched.

Created four disposable Custom Invitation test events (open/recognized/
closed on `wedding`, plus a `corporate` event created with no explicit
theme to confirm the new `DEFAULT_THEME_BY_EVENT_TYPE` fallback), set
itinerary text (including one line with no `" - "` separator, to test the
label-only fallback), `invitation_heading`, `dress_code_note`, and
`accessibility_note` via the shared edit form, and set a test
`background_image` value directly via SQL (Cloudinary isn't configured in
this local environment — consistent with every prior phase — so the actual
upload call couldn't be exercised end-to-end; the display path was
confirmed via a manually-set value, and the delete route was confirmed to
run and clear both columns correctly even when the Cloudinary API call
itself fails, exactly mirroring gallery/cameo's existing error handling).

Verified against live HTTP responses, not just read from code:
- Open event: full invitation renders directly (no passcode gate), shows
  the background image, parsed itinerary, and heading; no RSVP/QR markup.
- Recognized event: passcode gate present when unverified; full
  pending → declined (welcomeBack banner) → reconsider → accepted →
  confirmed lifecycle exercised on one guest; no QR/gatepass markup at any
  point.
- Closed event: same lifecycle through to a real gatepass — the "Show my QR
  gatepass" button and hidden modal (containing a real base64 QR `<img>`)
  render only in `state === 'qr'`; posting the real `qr_token` to the
  existing `/scan/verify` endpoint returned `{"result":"checked_in", ...}`,
  and the admin dashboard's guest table correctly showed "Yes" for Checked
  in afterward.
- Gallery/Cameos: unverified access to a recognized/closed event's
  secondary pages now correctly shows the passcode-prompt form instead of
  crashing; open-event access shows the (empty) grid directly.
- Dashboard: admin, client (via a disposable test `client_access` token),
  and admin-preview modes all rendered correctly; preview mode confirmed
  strictly read-only (0 `<form>` elements, 0 rendered `copy-btn` buttons —
  only the inert shared `<script>` text matched a naive search); client
  mode correctly hides Delete/Export/All-events and shows only the one-way
  Publish control for a draft event.
- Shared `edit-event.ejs`/`assets.ejs` regression-checked on an existing
  Botanical Bloom event and a fresh Lady Gianna event, plus fresh disposable
  Lavender Romance and Plain Card test events — all four rendered `200`
  with their own unchanged fonts/styling; the three new shared text fields
  and the new Background Image section correctly appear on all of them too
  (an accepted, explicitly-approved side effect of making these generic
  shared fields, not a regression).

### Cleanup

Disposable test records: four Custom Invitation test events (deleted via
the existing admin delete route, cascading their guests/gatepasses), one
disposable Lavender Romance and one disposable Plain Card regression-test
event (deleted the same way), and one manually-inserted `client_access` test
token (cascade-deleted along with its event). Final counts confirmed to
match the baseline exactly: `clients` 0, `booking_inquiries` 0, `events` 7,
`client_access` 0, `guests` 11, `gallery_photos` 0, `cameo_photos` 0. No
pre-existing business record was changed.

## Confirmations

- The only backend changes made are the ones explicitly authorized before
  implementation began: 5 additive, nullable `events` columns; the
  `background_image` upload/delete route pair (mirroring gallery/cameo's
  existing pattern exactly); and 3 new fields on the existing, already-open
  `updateEventCore`/`update()` text-field list. No existing column, route,
  or function signature was changed or removed.
- No production write occurred during local testing; production migration
  handling follows this project's established practice — the project owner
  switches their own local `.env` to production, the hostname is verified
  via URL parsing (never printed), then `npm run db:migrate` is run. No raw
  connection string was requested or handled in this conversation.
- No secret, password, hash, raw token, cookie/session ID, environment
  value, or database URL was printed or exposed at any point.
- Admin-preview mode confirmed strictly read-only for Custom Invitation.
- The four existing themes were not modified and were re-verified to render
  without regression, including their own shared edit/assets pages.

# Phase: Cross-Theme Navigation/Theming Audit + Custom Invitation Fixes

Commit: `fix(theme): fix custom invitation navigation, theming, and in-page modals`
Base commit: `3aafa9b feat(theme): add custom invitation event experience`

## Step 1 — Audit (all five themes, no fixes made during this step)

| Theme | Page | Reflects theme styling? | Has navigation? | Guest secondary-page behavior |
|---|---|---|---|---|
| Botanical Bloom | Dashboard | Yes | Yes — All weddings/View Client Portal present; `#guest-list` and `#wedding-details` anchors both exist | — |
| Botanical Bloom | Edit Details / Assets | Yes (full `.bb-admin input/textarea/select` styling) | Yes (shared Back link) | — |
| Botanical Bloom | Gallery/Cameos/Other Details | Yes | Yes (Back to invitation link) | Separate page |
| Lavender Romance | Dashboard | Yes | Mostly — All weddings/View Client Portal present; own nav links "Wedding Details" straight to `/edit` instead of a same-page anchor, so the shared Assets page's `#wedding-details` anchor link lands with nothing to scroll to (minor, pre-existing, not unique to this theme) | — |
| Lavender Romance | Edit Details / Assets | Yes | Yes | — |
| Lavender Romance | Gallery/Cameos/Other Details | Yes | Yes | Separate page |
| Lady Gianna | Dashboard | Yes | Mostly — All events/View Client Portal present; uses `#event-details` instead of `#wedding-details` (same minor anchor mismatch as Lavender Romance); also has its own inline itinerary/details edit form on the dashboard itself, in addition to the full Edit Details page | — |
| Lady Gianna | Edit Details / Assets | Yes | Yes | — |
| Lady Gianna | Gallery/Cameos/Other Details | Yes | Yes | Separate page |
| Plain Card | Dashboard | Yes | Yes — All events, View Client Portal, My Events, Log out all present (the most complete of the four); same minor `#wedding-details` anchor mismatch as above | — |
| Plain Card | Edit Details / Assets | Yes | Yes | — |
| Plain Card | Gallery/Cameos/Other Details | Yes | Yes | Separate page |
| **Custom Invitation** | **Dashboard** | **Partial** — own `ci-admin-*` chrome was fine, but had **no header nav at all**: no All events/View Client Portal (admin), no My Events/Log out (client), and no `#guest-list`/`#wedding-details` anchor targets | **No** | — |
| **Custom Invitation** | **Edit Details / Assets** | **No** — the only theme with **zero** `.bb-admin input/textarea/select` CSS rules (confirmed by direct count: every other theme has 3–4 such rules, this one had 0). Every text field, including the Itinerary textarea, fell back to raw unstyled browser defaults | Yes (shared Back link still worked) | — |
| Custom Invitation | Gallery/Cameos/Other Details | Yes (own `ci-*` styling) | Yes (Back to invitation link existed) | Separate page — same universal pattern every other theme already uses (not an inconsistency; changed anyway per explicit instruction, see below) |

**Headline finding**: the guest-facing "separate page vs modal" behavior is **100% consistent across all five themes** — every one of them, including Custom Invitation as originally shipped, links Cameos/Gallery/Other Details out to their own routes. This is not a Custom-Invitation-specific bug; it is the established, universal pattern. The dashboard navigation gap and the missing form-control styling, however, **are** unique to Custom Invitation, confirmed by direct comparison against all four other themes.

## Step 2 — Fixes

### 1. Guest-facing navigation → in-page modals (Custom Invitation only)

Per explicit instruction, Custom Invitation's Cameos/Gallery/Other Details tiles now open as in-page modals from the invitation itself (same mechanism as the existing QR gatepass modal), instead of navigating away. **The other four themes were left untouched** — their separate-page behavior is their own established, intentional design (confirmed universal in Step 1), not something this audit found broken.

Implementation:
- `controllers/guest.controller.js`: added `secondaryContentFor(event)`, gated to `event.theme === 'custom-invitation'` only (returns `{}` for every other theme — zero behavior change elsewhere), mirroring the exact existing pattern `galleryPreviewFor()` already uses for Lady Gianna's gallery preview. Fetches gallery/cameo photos up front and threads them into every `renderInvitation` branch that reaches real content — never for `state === null` (nothing past the passcode gate leaks pre-verification, same security boundary as always).
- `views/guest/themes/custom-invitation/invitation.ejs`: the three tiles are now `<button data-ci-modal-open="...">` instead of `<a href="...">`; three new hidden modals (Gallery, Cameos, Other Details) render inline with real content (reusing the existing `.ci-gallery-grid`/`.ci-cameo-grid` styles and the exact copy-address script other-details.ejs already had). The QR modal's bespoke open/close script was replaced with one generic script driving all four modals uniformly (open via `data-ci-modal-open`, close via a button, backdrop click, or Escape).
- `public/themes/custom-invitation/theme.css`: generalized `.ci-qr-backdrop`/`.ci-qr-modal` into reusable `.ci-modal-backdrop`/`.ci-modal-card` classes (plus a `.ci-modal-card-wide` variant for the grid-based panes) and a `.ci-modal-close` button style.
- The standalone `/invite/:id/{gallery,cameos,other-details}` routes and their dedicated `.ejs` files are untouched and still work as real pages — kept as a working fallback/direct-link target, at zero extra cost.

### 2. Dashboard theming + navigation (Custom Invitation only — the only theme Step 1 flagged)

- Added a `.bb-admin-header`/`.bb-admin-nav` header to `views/admin/themes/custom-invitation/dashboard.ejs`, matching Plain Card's pattern exactly: admin mode gets Assets/Other Details/All events/View Client Portal; client mode gets Assets/Other Details/My Events/a real `POST /client/logout` button; preview mode gets only "Return to Admin".
- Added `id="guest-list"` and `id="wedding-details"` anchor targets so the shared Assets page's existing header links actually scroll somewhere on this theme's dashboard (previously they didn't).
- Added the full `.bb-admin input/textarea/select` (plus header/table/link color) CSS block to `theme.css` — the missing rule identified in Step 1. This is what actually resolves item 3 and item 4 below.

### 3. "Edit Details bug" — root cause and fix

Reproduced directly: submitted the real rendered edit form (every field, admin and client, exactly as a browser would) against a live Custom Invitation test event. **No save ever failed, no data was ever corrupted, no error page was produced** in either admin or client mode — I could not reproduce a hard failure. What I found instead, and what Step 1's audit directly explains: Custom Invitation was the only theme with zero styling on the shared edit form's input/textarea/select fields (confirmed by comparison: every other theme has 3–4 such CSS rules, this one had 0). Every field — including Itinerary — rendered in raw, unstyled, browser-default appearance, visually inconsistent with the rest of the themed page. This is the most plausible explanation for a page that "looks broken," consistent with the instruction's own "if Step 1's findings explain it, say so." Fixed by adding the missing CSS block (same fix as item 2 above) — no controller, model, or route change was needed or made.

One separate, pre-existing, unrelated-but-noted issue found during this reproduction attempt: `edit-event.ejs`'s admin-mode theme `<select>` renders a `selected` attribute on **every** `custom-invitation` `<option>` (one per event type, all sharing the same `value`), since the comparison `event.theme === t.slug` doesn't also check `t.eventType`. This produces invalid HTML (multiple `selected` options) but — traced through in detail — does not change what actually gets submitted, since every duplicate shares an identical `value` string; `<select>.value` reads correctly regardless of which specific duplicate option the browser's HTML parser treats as "selected". Not fixed in this pass (confirmed harmless, pre-existing since Plain Card introduced the first multi-type theme, out of this task's reported scope) — flagged here for visibility.

### 4. Itinerary "missing" from the edit form

The `itinerary` column is, and was always, a plain `TEXT` field (confirmed directly in `db/schema.sql`/`models/event.model.js`) — the exact same shared field every other theme already edits via the same `<textarea name="itinerary">` on `edit-event.ejs`. No second/different field exists or was ever needed. The field was never actually missing; per Step 1's finding, it almost certainly just didn't *look* like part of the form because of the missing CSS (item 2/3 above). Fixing the styling makes the existing, already-functional field visible and consistent with the rest of the page — no new field, no schema change.

## Regression check (all five themes)

Local test events created and verified live for all five themes (Botanical Bloom and Lady Gianna via existing events; disposable test events for Lavender Romance, Plain Card, and three Custom Invitation events covering open/recognized/closed): every dashboard and every guest invitation rendered `200` with no error. Custom Invitation specifically verified: modal triggers and markup present in open/card/qr states; QR data URL and gatepass flow still work exactly as before; preview mode still renders zero `<form>` elements; client mode shows the new My Events/Log out controls; admin mode shows All events/View Client Portal and a working `#guest-list` anchor.

Baseline counts before testing: `clients` 0, `booking_inquiries` 0, `events` 7, `client_access` 0, `guests` 11, `gallery_photos` 0, `cameo_photos` 0. All disposable test events deleted afterward; final counts matched the baseline exactly.

## Files changed

```
controllers/guest.controller.js                     — secondaryContentFor() helper, theme-gated
public/themes/custom-invitation/theme.css            — bb-admin input/textarea/select styling; generic modal classes
views/admin/themes/custom-invitation/dashboard.ejs    — header nav, #guest-list/#wedding-details anchors
views/guest/themes/custom-invitation/invitation.ejs   — modal triggers/markup, generic modal script
PROMPT-REPORT.md                                      — this section
```

No route, model, schema, or other theme's file was touched.

## Confirmations

- The only controller change is a theme-gated helper that returns `{}` (no-op) for every theme except Custom Invitation — confirmed zero behavior change for the other four themes via live regression testing.
- No schema, migration, or production write of any kind was needed or made in this phase.
- No secret, password, hash, token, cookie, or database URL was printed or exposed.
- Admin-preview mode re-confirmed strictly read-only after all changes.

# Phase: Custom Invitation Consolidated Fix Pass (with real visual verification)

Commit: `fix(theme): fix custom invitation contrast, layout, and dashboard`
Base commit: `0de301a fix(theme): fix custom invitation navigation, theming, and in-page modals`

The task explicitly required visual proof, not just HTTP-status checks, for
items previously reported fixed and found still broken. No browser/
screenshot tool was available via this session's own tools, but this
machine has Chrome installed locally — `playwright-core` (an older release,
1.48.0, compatible with this machine's Node 18) was installed into a
scratch directory outside the repo and driven against the real local
Chrome executable. This gave real rendered screenshots and real
`getComputedStyle()` readouts for the rest of this phase — genuine visual
verification, not inference from source code.

## Item 10 — button text contrast (previously reported fixed, wasn't)

Computed-style inspection of every `.bb-btn`/`.ci-btn` element on the live
dashboard found the exact bug: `<a class="bb-btn ci-btn">Manage Assets</a>`
rendered with `color: rgb(33,31,26)` on `background-color: rgb(33,31,26)`
— identical values, invisible text. `<button>` elements with the same
classes were unaffected (`color: rgb(251,250,246)`, correct). Root cause:
a prior phase's `.bb-admin a { color: var(--ci-ink); }` rule (added to
theme the shared edit/assets pages) has higher CSS specificity
(class+element, 0,0,1,1) than `.bb-btn`'s own color rule (single class,
0,0,1,0) — so it silently overrode `.bb-btn`'s intended light text on
every `<a>`-tag button, while never touching `<button>`-tag ones. Fixed
with `.bb-admin a.bb-btn, .bb-admin a.ci-btn:not(.ci-btn-outline) { color:
var(--ci-paper-soft); }`. **Re-verified after the fix**: the same
computed-style check now shows `color: rgb(251,250,246)` on all three
previously-broken buttons (Manage Assets / Edit details / Export Excel),
and a full-page screenshot confirms all button text is visibly legible.

## Item 3 — Edit Details page theming (previously reported fixed, wasn't)

Confirmed via the rendered page itself (not just the CSS source) that
`custom-invitation/theme.css` is genuinely in the page's `<link>` tags and
`class="bb-admin"` is genuinely on the rendered `<body>` — both correct.
A full screenshot of the edit page shows a properly themed ivory page with
serif headings, visible form-field borders/backgrounds, and (after the
item-10 fix) a legible "Save Changes" button. This appears to have
actually been fixed by the prior phase's CSS additions; the "still broken"
report may predate that deploy, or may have been about the then-real
button-contrast bug (item 10) rather than theming as such. Reported here
only on the strength of an actual rendered screenshot, not a repeat claim.

## Item 2 — Page One 5% inset

Changed `.ci-card-stage img`/the no-image placeholder from `inset: 0`
(edge-to-edge) to `inset: 5%` (`width/height: 90%`). Verified via
`getBoundingClientRect()` on the live rendered page: stage spans
8px-1288px (1280px wide) and 8px-908px (900px tall); the image box spans
72px-1224px (1152px — exactly 64px/5% margin each side) and 53px-863px
(810px — exactly 45px/5% margin top and bottom). Mathematically exact on
both axes. (The screenshot itself doesn't show a visible "frame" in this
local test only because no real card image is uploaded — Cloudinary isn't
configured in this environment, consistent with every prior phase — so the
placeholder's transparent background blends with the stage's own identical
background color; the geometry check is the real proof here, and is more
precise than eyeballing a screenshot would be regardless.)

## Items 1, 4, 5, 6, 7, 8, 9 — re-verified, some with new evidence

- **Item 1** (in-page modals): confirmed via screenshot — clicking the QR
  trigger opens a centered, backdrop-darkened modal with a real QR image
  and a working close button, with Cameos/Gallery/Other Details rendered
  as buttons (not links) beneath it. No other theme was touched; all four
  still use separate pages by their own established design.
- **Item 4** (Edit Details back-link): still present and working — visible
  in every edit-page screenshot (`← Back to <event>`), unchanged since the
  shared `edit-event.ejs` was not touched.
- **Item 5** (Edit Details save bug): driven a real save through the
  actual rendered form (fill → click Save Changes → redirect →
  reload-and-compare). Zero console/page errors, correct redirect, and the
  saved values read back identical to what was submitted. No failure
  reproduced, now with real-browser evidence instead of only a curl-based
  simulation.
- **Item 6** (itinerary input): already on the shared edit form
  (`itinerary` is a plain `TEXT` column, confirmed in schema/model);
  visually confirmed present, labeled, and populated correctly in the
  edit-page screenshot.
- **Item 7** (remove guest-facing quick-links tile): removed from
  `dashboard.ejs`; confirmed absent in the dashboard screenshot.
- **Item 8** (consolidate two image tiles into one): "Manage Assets" now
  shows both thumbnails side by side under one heading with one button;
  confirmed in the dashboard screenshot — exactly two top-level tiles
  remain (Manage Assets, Edit Event Details).
- **Item 9** (Copy Invitation Link): added, reusing the exact
  `navigator.clipboard` + text-swap-feedback pattern from other-details.ejs's
  own "Copy address" button. Confirmed visible and correctly styled (white
  text on black) in the dashboard screenshot; shown only when
  `!isPreview && event.status === 'live'`.

## New issue found during mobile-width visual testing (not previously reported)

The guest table on Custom Invitation's dashboard had no horizontal-scroll
wrapper — at a 375px viewport its "Checked in" column pushed the whole
page 20px past the screen edge (`document.body.scrollWidth: 395` vs
`window.innerWidth: 375`). Fixed with a `.ci-table-wrap { overflow-x: auto;
}` wrapper div, matching every other theme's own existing
`.lr-table-wrap`/`.lg-table-wrap` convention. Re-verified: `scrollWidth`
now equals `innerWidth` exactly, and the mobile screenshot shows no
overflow.

A second, **pre-existing, cross-theme** overflow was found and fixed while
testing this: the shared `.bb-details-grid` (in `admin-shared.css`, used
by every theme's edit-event.ejs) overflowed its own container by ~20-25px
at 375px width — confirmed on **both** Custom Invitation's and an existing
Botanical Bloom event's edit pages before the fix
(`bodyScrollWidth: 395`/`398` vs `vw: 375` on both). Root cause: CSS
Grid's default `min-width: auto` on grid items lets a track grow past its
`1fr` share to fit non-shrinkable content (here, the Card image tile's
native `<input type="file">`), overflowing the grid past its own
container regardless of the track's nominal minmax. Fixed with the
standard `min-width: 0` on `.bb-details-grid .bb-admin-tile`, plus
`minmax(min(170px, 100%), 1fr)` as a second line of defense. Re-verified
on both themes: `bodyScrollWidth` now equals `375` exactly on both. This
is a shared-infrastructure fix (not a per-theme override) because
`admin-shared.css` is explicitly the one file that owns this grid's layout
for every theme — duplicating the fix five times in each theme's own
stylesheet was the wrong place for it.

## Regression evidence

Full-page screenshots taken and visually inspected (not just HTTP status)
for: Custom Invitation dashboard (desktop + mobile, before and after
fixes), Custom Invitation edit page (desktop + mobile, before and after
fixes), Custom Invitation guest invitation (desktop + mobile, plus the
open QR modal), Custom Invitation admin-preview (confirmed zero `<form>`
elements), an existing Botanical Bloom event's dashboard and edit page,
and an existing Lady Gianna event's dashboard — all rendered correctly,
all buttons legible, no layout breakage. A real guest RSVP (verify →
accept) was driven through the actual browser UI, not curl, and the
resulting "accepted" status was confirmed in both the guest page and the
admin guest table.

Baseline counts before testing: `clients` 0, `booking_inquiries` 0,
`events` 7, `client_access` 0, `guests` 11, `gallery_photos` 0,
`cameo_photos` 0. One disposable test event (with one guest) was created
through the real admin UI, exercised across every screenshot above, then
deleted through the real admin UI. Final counts matched the baseline
exactly.

## Files changed

```
public/assets/css/admin-shared.css                  — grid-blowout fix (all themes)
public/themes/custom-invitation/theme.css            — button-contrast, 5% inset, table-wrap, asset-tile CSS
views/admin/themes/custom-invitation/dashboard.ejs    — remove quick-links, consolidate tiles, add Copy Link, table-wrap
views/guest/themes/custom-invitation/invitation.ejs   — 5% inset on the no-image placeholder
PROMPT-REPORT.md                                      — this section
```

No route, controller, model, or schema file was touched. The
`admin-shared.css` change is the only edit outside Custom Invitation's own
files, and only because the bug it fixes lives in the one file that
already owns this exact layout for all five themes.

## Confirmations

- All eleven Step-2 items addressed; items 3 and 10 specifically carry
  real screenshot/computed-style evidence, not just a repeated claim.
- No schema, migration, route, controller, model, or production write of
  any kind was needed or made in this phase.
- No secret, password, hash, token, cookie, or database URL was printed,
  logged, or exposed — the scratch Playwright script reads `.env` directly
  into `process.env` and never logs any value from it.
- Admin-preview mode re-confirmed strictly read-only with a screenshot
  (zero forms, zero mutating controls visible).
- The other four themes were not modified; Botanical Bloom and Lady
  Gianna were re-verified visually with no regression.

# Phase: Page One Fix — Contain, Not Crop; Rounded Corners; Reliable Scroll Cue

Commit: `fix(theme): fix custom invitation page one crop, corners, scroll cue`
Base commit: `31da1da fix(theme): fix custom invitation contrast, layout, and dashboard`

The prior phase's 5%-inset change used `object-fit: cover`, which crops
whatever doesn't match the inset box's own aspect ratio — never actually
tested against a real image, since no card image was uploaded in any of
that phase's test events (a gap, corrected here).

## What changed

- `.ci-card-stage img` → new `.ci-card-frame` wrapper div (absolute,
  `inset: 5%`, `border-radius: 1.25rem` — matching `.ci-frost-card`'s own
  radius, `overflow: hidden`, `background: #e9e2d4` — the stage's own
  color) containing the `<img>` at `object-fit: contain` instead of
  `cover`. The whole uploaded image is now always visible, letterboxed
  against the frame's own (stage-matching) background rather than cropped
  or distorted.
- The "Card image not uploaded yet" placeholder moved into the same frame
  and was renamed `.ci-card-frame-missing` (not reused from
  `.ci-image-missing`, which the admin dashboard's asset tiles already use
  under a different compound selector — kept as two distinct class names
  specifically to avoid any cross-context CSS bleed between the two).
- `.ci-scroll-cue` changed from text-shadow-on-photo (which assumed the
  cue always sat over a dark photo) to a self-contrasting pill
  (`background: rgba(33,31,26,.6)`, light text) positioned relative to the
  new `.ci-card-frame` rather than the outer `.ci-card-stage`. A pill
  carries its own contrast regardless of what's behind it — image,
  letterboxed stage-color background, or the stage's own outer margin —
  so its visibility no longer depends on guessing where `object-fit:
  contain` actually paints the image.

## Visual verification (real screenshots, two different aspect ratios)

No Cloudinary upload was available to test with a real client-uploaded
image (unchanged limitation, disclosed in every prior phase), so
`event.card_image` was set directly to two publicly-hosted test images of
known, deliberately mismatched proportions: a 1600×900 landscape image and
a 900×1600 portrait image — chosen specifically because neither matches
the inset frame's own ~16:10-ish box, the exact condition that would have
shown cropping under the old `cover` behavior.

Screenshotted and measured (via `getBoundingClientRect()`/
`getComputedStyle()` on the live rendered page) at both 1280px (desktop)
and 375px (mobile) widths — four combinations in total:

| Test | `object-fit` | Scroll cue in viewport | Visual result |
|---|---|---|---|
| Landscape, desktop | `contain` (confirmed) | `true` | Image fills the frame almost edge-to-edge (close aspect match); fully visible, rounded corners clearly visible, cue legible below it |
| Landscape, mobile | `contain` (confirmed) | `true` | Frame is tall/narrow here, so this wide image letterboxes top/bottom; fully visible, not cropped, cue legible |
| Portrait, desktop | `contain` (confirmed) | `true` | Frame is wide/short here, so this tall image letterboxes left/right; fully visible, not cropped, cue legible |
| Portrait, mobile | `contain` (confirmed) | `true` | Closer proportional match; image nearly fills the frame, cue legible |

All four: **no cropping, no stretching/distortion, scroll cue always
fully inside the viewport with legible contrast**. `object-fit: contain`
and the frame's `border-radius: 1.25rem` were independently confirmed via
`getComputedStyle()` in every case, not inferred from the screenshot alone.

**Honest note on rounded corners**: the corners are real and are clearly
visible whenever the image's own rendered edges reach the frame's actual
physical boundary (confirmed in the landscape/desktop screenshot, where
the image nearly fills the frame). In the more extreme letterbox cases
(e.g. the portrait image on desktop, which renders far narrower than the
frame), the letterboxed margin — per the explicit instruction to letterbox
"against the stage's existing background color" — is the *same* color as
the frame's own background, so that margin is visually indistinguishable
from the surrounding stage, and the frame's rounded-corner clip (which
only curves within ~20px of the frame's own far-apart physical corners)
ends up with nothing visible to curve. This is the correct, literal
consequence of the instruction as given (same-color letterboxing), not a
missed implementation — flagged here in the interest of full honesty
rather than overclaiming "rounded corners visible in all cases."

## Cleanup

Baseline confirmed before testing: `clients` 0, `booking_inquiries` 0,
`events` 7, `client_access` 0, `guests` 11, `gallery_photos` 0,
`cameo_photos` 0. One disposable test event was created through the real
admin UI, its `card_image` swapped between the two test URLs directly via
SQL (the only way to exercise this specific code path without a working
local Cloudinary upload), screenshotted in all four combinations above,
then deleted through the real admin UI. Final counts matched the baseline
exactly.

## Files changed

```
public/themes/custom-invitation/theme.css            — .ci-card-frame, contain, pill-style scroll cue
views/guest/themes/custom-invitation/invitation.ejs   — wraps image/placeholder/cue in .ci-card-frame
PROMPT-REPORT.md                                      — this section
```

No other file was touched — this phase is scoped entirely to Page One's
own image-handling CSS and the one template that renders it.

## Confirmations

- No schema, route, controller, model, or production write of any kind
  was needed or made.
- No secret, password, hash, token, cookie, or database URL was printed or
  exposed.
- The new `.ci-card-frame-missing` class name was deliberately chosen to
  avoid colliding with the admin dashboard's own, unrelated
  `.ci-image-tile.ci-image-missing` asset-tile styling — confirmed by
  checking the served CSS for exactly one rule under each name afterward.
- No other theme's files were touched.
