",
+ "css": ".ds-hero h1 { font-family: \"Brygada 1918\", serif; font-size: clamp(40px, 6.5vw, 80px); font-weight: 600; line-height: 1.04; letter-spacing: -0.022em; color: var(--text-bright); max-width: 16ch; margin: 0 0 28px; } .ds-hero h1 span { color: var(--ember); } .ds-hero-links { display: flex; align-items: center; gap: 24px; } .ds-hero-primary { font-family: \"Commit Mono\", monospace; font-size: 14px; padding: 13px 30px; background: var(--candle); color: var(--bg); border: 1px solid var(--candle); text-decoration: none; transition: background 0.2s, transform 0.2s; } .ds-hero-primary:hover { background: var(--candle-dim); transform: translateY(-2px); } .ds-hero-link { font-family: \"Commit Mono\", monospace; font-size: 14px; color: var(--candle); padding: 4px 0; border-bottom: 1px dashed var(--candle-faint); text-decoration: none; }"
+ },
+ {
+ "name": "Parchment Inset",
+ "kind": "card",
+ "refersTo": null,
+ "description": "Signature: inverted dark block for a featured passage; pinned to the same values in light and dark mode.",
+ "html": "",
+ "css": ".ds-parch { background: #2a2015; color: #ede4d0; padding: 28px 32px; border-radius: 0; } .ds-parch-label { font-family: \"Commit Mono\", monospace; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: #c4a448; margin-bottom: 12px; } .ds-parch h2 { font-family: \"Brygada 1918\", serif; font-size: 22px; font-weight: 500; margin: 0 0 10px; } .ds-parch p { font-family: \"Commit Mono\", monospace; font-size: 13px; line-height: 1.7; color: #b8ae98; margin: 0; }"
+ }
+ ],
+ "narrative": {
+ "northStar": "The Text Adventure Hall",
+ "overview": "Ghost Guild is a world built from words: a monospace text adventure rendered on cream paper. Rooms instead of pages, prose instead of chrome, structure drawn in dashed ink rather than boxes and shadows. Two typefaces (Brygada 1918 serif for display, Commit Mono for everything else) do all the work over a warm cream ground with a faint noise texture. Candlelight gold is the single voice of action; ember rust is the rare focal emphasis. Depth is tonal layering, never drop shadows or glass.",
+ "keyCharacteristics": [
+ "Two fonts only: Brygada 1918 (serif display) + Commit Mono (everything else).",
+ "Cream paper ground with a 2.5% noise overlay; warm, tinted neutrals throughout.",
+ "Dashed borders for structure, solid borders for inputs and active state.",
+ "Square corners everywhere (border-radius: 0).",
+ "Flat by default: depth comes from tonal layering, not shadows.",
+ "Candle gold is the only call-to-action color; ember rust is the rare accent."
+ ],
+ "rules": [
+ {
+ "name": "The Candlelight Rule",
+ "body": "Candle gold is the only color permitted on a call-to-action. If something is gold, it acts. Nothing decorative wears gold.",
+ "section": "colors"
+ },
+ {
+ "name": "The Single Ember Rule",
+ "body": "Ember rust appears at most once per view as emphasis. Two embers cancel each other out; the rarity is the point.",
+ "section": "colors"
+ },
+ {
+ "name": "The Two-Font Rule",
+ "body": "Brygada 1918 and Commit Mono. That is the entire type system. A third family is forbidden.",
+ "section": "typography"
+ },
+ {
+ "name": "The Flat Paper Rule",
+ "body": "Surfaces sit flat on the page. If you reach for box-shadow on an in-page element, use a dashed border or a tonal surface step instead. Shadows belong only to floating popovers.",
+ "section": "elevation"
+ }
+ ],
+ "dos": [
+ "Do use exactly two typefaces: Brygada 1918 for display/headings, Commit Mono for everything else.",
+ "Do draw structure with 1px dashed borders, and switch borders to solid only for inputs and active state.",
+ "Do keep every corner square (border-radius: 0).",
+ "Do reserve Candle Gold (#7a5a10) for actions and Ember Rust (#8a4420) for a single focal emphasis per view.",
+ "Do convey depth through tonal layering (cream -> surface -> parchment) and the noise overlay, not shadows.",
+ "Do keep text contrast at WCAG AA: Ink Dim (#5a5040) and Ink Faint (#665c4b) were tuned to pass on cream.",
+ "Do use fluid clamp() spacing and type so the layout breathes on large viewports."
+ ],
+ "donts": [
+ "Don't introduce a third typeface. (The Two-Font Rule.)",
+ "Don't round corners anywhere.",
+ "Don't put box-shadow on in-page surfaces; shadows belong only to floating popovers.",
+ "Don't use a border-left/border-right greater than 1px as a colored accent stripe.",
+ "Don't use gradient text or background-clip: text; emphasis comes from weight, size, or a single ember word.",
+ "Don't use purple/blue gradients, glassmorphism, neon-on-dark, or identical icon-title card grids.",
+ "Don't reach for CSS hacks: no negative margins, no magic numbers, no fragile workarounds.",
+ "Don't put neutral gray text on the parchment block or any colored surface; use the parchment text tokens.",
+ "Don't use UToggle; use USwitch (Nuxt UI 4)."
+ ]
+ }
+}
diff --git a/app/assets/css/main.css b/app/assets/css/main.css
index 9ee189f..abb9d3c 100644
--- a/app/assets/css/main.css
+++ b/app/assets/css/main.css
@@ -248,6 +248,17 @@ p a, blockquote a {
border-color: var(--border);
}
+/* ---- ACCENT TINT BLOCKS ---- */
+/* Faint accent fill + matching solid border. Replaces inline color-mix styles. */
+.tint-candle {
+ background: color-mix(in srgb, var(--candle) 15%, transparent);
+ border: 1px solid var(--candle);
+}
+.tint-ember {
+ background: color-mix(in srgb, var(--ember) 15%, transparent);
+ border: 1px solid var(--ember);
+}
+
/* ---- SEGMENTED CONTROL (flush dashed-border groups) ---- */
/* Negative-margin overlap: every item keeps all 4 borders,
siblings overlap by 1px, active item paints on top via z-index. */
diff --git a/app/components/EventSeriesTicketCard.vue b/app/components/EventSeriesTicketCard.vue
index 1340f3c..b475430 100644
--- a/app/components/EventSeriesTicketCard.vue
+++ b/app/components/EventSeriesTicketCard.vue
@@ -63,8 +63,7 @@
class="flex items-start gap-3 p-3"
>
Ghost Guild is where game developers explore cooperative models.
+
Ghost Guild is where game developers explore cooperative models.
Resources, events, and a community of people figuring it out. Three
circles, pay what you can.
@@ -208,51 +208,68 @@ const formatDate = (event) => {
diff --git a/app/pages/join.vue b/app/pages/join.vue
index df1934b..b8b7b8b 100644
--- a/app/pages/join.vue
+++ b/app/pages/join.vue
@@ -3,7 +3,7 @@
-
You're already a member
+
You're already a member
Welcome back, {{ memberData?.name || "member" }}. You're part of Ghost
Guild in the
@@ -75,7 +75,7 @@
- Equal access for everyone. Pick what fits — these aren't
+ Equal access for everyone. Pick what fits. These aren't
tiers.
- Secure payment. Card entry handled by Helcim
- — we never see your card details.
+ Secure payment. Card entry handled by Helcim;
+ we never see your card details.
Change anytime. Adjust your contribution or
@@ -707,7 +714,7 @@ onUnmounted(() => {
.cadence-toggle button {
background: transparent;
border: none;
- padding: 5px 12px;
+ padding: 7px 12px;
font-family: "Commit Mono", monospace;
font-size: 10px;
color: var(--text-faint);
@@ -883,7 +890,7 @@ onUnmounted(() => {
border: 1px solid var(--parch);
padding: 12px 24px;
cursor: pointer;
- transition: all 0.15s;
+ transition: background-color 0.15s, border-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.submit-btn:hover {
@@ -938,7 +945,7 @@ onUnmounted(() => {
padding: 32px;
border-bottom: 1px dashed var(--border);
}
-.full-section h2 {
+.full-section h1 {
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
@@ -982,7 +989,7 @@ onUnmounted(() => {
border: 1px solid var(--parch);
padding: 12px 28px;
cursor: pointer;
- transition: all 0.2s;
+ transition: background-color 0.2s, border-color 0.2s, color 0.2s;
text-decoration: none;
text-align: center;
}
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index a7c4640..e34f397 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -1,6 +1,6 @@
# Ghost Guild — Open Backlog
-_Last consolidated: 2026-05-18. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
+_Last consolidated: 2026-05-24. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
Cutover has not happened yet. Deploy steps + Activation + Open decisions live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md). This file is the everything-else.
@@ -15,6 +15,7 @@ Operational steps that have to run during cutover. Full details + env-var list i
- [ ] Provision the Dokploy app, set env vars (full list in LAUNCH_READINESS.md), confirm `BASE_URL` exact-matches the public origin and `NODE_ENV=production`.
- [ ] Add the daily Dokploy Scheduled Task that POSTs to `/api/internal/reconcile-payments` with `X-Reconcile-Token`.
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.**
+- [ ] **After deploying `feature/contribution-form-polish`: run `node scripts/migrate-annual-contribution-to-cadence-unit.cjs --apply` against prod Mongo.** Converts existing annual Member rows from monthly-equivalent storage to cadence-unit storage (×12 on `contributionAmount` for `billingCadence='annual'` rows). Idempotent via transient `contributionAmountConverted` marker. Without this step, the UI will render `$15/yr` for existing annual members until they update their contribution.
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` and `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy.
- [ ] Set `NUXT_RECONCILE_TOKEN` to a 32+ char random string.
- [ ] Push local `main` to `origin/main`.
@@ -47,9 +48,10 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
## Known gotchas (post-launch)
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. The admin form already shows an `--ember`-bordered notice (commit `e756170`); a real sync flow is a future enhancement.
-- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account`. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
-- **S2 test fixture id/slug mismatch (local dev only).** Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures.
-- **`/admin/series-management` "Delete" button doesn't actually delete.** Click handler iterates events to PUT-unlink each from the series, never calls `DELETE /api/admin/series/:id`. For an empty series the button is a no-op; for a series with events it just orphans them. Either rename to "Unlink events" or add the actual DELETE call. Surfaced by `e2e/admin-series.spec.js` (delete test skipped). Flagged 2026-04-30.
+- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account` — `ContributionAmountField` is rendered with `:allow-cadence-change="false"` there. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
+- **`contributionAmount` is now cadence-unit (not monthly-equivalent).** Post `feature/contribution-form-polish`: `Member.contributionAmount` reads as $180 for "$180/year" annual members, not $15. Server no longer multiplies by 12 anywhere; UI uses `formatContribution(amount, cadence)` from `app/config/contributions.js`. See memory `project_contribution_form_polish`.
+- ~~**S2 test fixture id/slug mismatch (local dev only).**~~ Verified resolved 2026-05-24: no `test-s2-drop-in-allowed` reference remains anywhere in `scripts/`, `tests/`, or JSON — the fixture was removed.
+- ~~**`/admin/series-management` "Delete" button doesn't actually delete.**~~ Fixed 2026-05-24 (`fix/backlog-batch-2026-05-24`): `deleteSeries` (`app/pages/admin/series/index.vue`) now calls `DELETE /api/admin/series/[id]` (the endpoint already unlinks events server-side), replacing the redundant per-event PUT loop. `e2e/admin-series.spec.js` delete test un-skipped.
- **Past-deadline events and sold-out events render identically.** `EventTicketPurchase.vue` falls through to "Event Sold Out" panel for both `tickets.available.reason === 'Registration deadline has passed'` and zero-stock cases. If "Registration closed" is meant to read differently from "Sold out," add a distinct branch. Flagged 2026-04-30 (no e2e written — gated on this UX decision).
---
@@ -57,7 +59,7 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
## Accessibility / a11y
- [ ] **Button minimum target size.** Site-wide `.btn` renders ~35px tall. WCAG AA 2.5.8 (24×24) passes; AAA 2.5.5 (44×44) fails. Bumping padding affects every button — design call, not a drop-in fix. Flagged 2026-04-11.
-- [ ] **`/board` color-contrast violations (WCAG AA).** `.block-label` ("Offering" tag) and `.slack-handle` use `#746a58` on `#e8dfc8` → 4.01:1; AA needs 4.5:1 for small text. Surfaced by `e2e/a11y.spec.js` (the `/board` route fails; test is intentionally left red until fixed). Likely a single CSS variable adjustment. Flagged 2026-04-30.
+- ~~**`/board` color-contrast violations (WCAG AA).**~~ Verified done 2026-05-24: `.block-label` and `.slack-handle` in `BoardPostCard.vue` now use `var(--text-dim)` on the cream card bg (code comments note `--text-faint` was insufficient there). `--text-faint` itself was also darkened from `#746a58` to `#665c4b` (`main.css:33`, 4.94:1 on `--surface`).
---
@@ -72,9 +74,9 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
## Wave-Slack pilot follow-ups
-- [ ] **`/api/auth/member` doesn't return `slackInvited`.** Dashboard's Slack-coming note is gated on `memberData.slackInvited`, which is always `undefined` client-side, so the note shows for *every* active member regardless of state. Real bug. Add `slackInvited` (and `slackInvitedAt`) to the auth/member response. Surfaced by wave-slack §7.2 e2e (skipped pending this fix). Flagged 2026-04-30.
-- [ ] **Admin members list row mutation isn't reactive.** `markSlackInvited` in `app/pages/admin/members/index.vue` does `Object.assign(member, res.member)` on a plain object inside a `useFetch` array; Vue doesn't react, so the "Mark as Slack invited" button stays visible until a manual reload. Fix: `members.value[i] = { ...members.value[i], ...res.member }` or `splice`. Detail page uses the right pattern (covered by §6.6). Surfaced by wave-slack §6.2 e2e (skipped pending this fix). Flagged 2026-04-30.
-- [ ] **Deprecated `slackInviteStatus` field still serialized.** Removed from UI but still on `Member` documents and the `/api/admin/members` payload. Project it away in the API response and run a one-shot `$unset` cleanup. Surfaced by wave-slack §6.7 e2e. Flagged 2026-04-30.
+- ~~**`/api/auth/member` doesn't return `slackInvited`.**~~ Verified done 2026-05-24: `server/api/auth/member.get.js:20-21` returns both `slackInvited` and `slackInvitedAt`.
+- ~~**Admin members list row mutation isn't reactive.**~~ Verified done 2026-05-24: `markSlackInvited` (`app/pages/admin/members/index.vue:843`) now does `members.value[idx] = { ...members.value[idx], ...res.member }`.
+- [ ] **Deprecated `slackInviteStatus` — optional DB cleanup only.** No longer serialized: removed from the `Member` schema and the UI, and `server/api/admin/members.get.js` projects only current schema paths (`Object.keys(Member.schema.paths)`), so stale doc values can't leak into the payload. Remaining: an optional one-shot `$unset` to tidy old Mongo docs — nothing reads the field. Narrowed 2026-05-24.
- [ ] **Spec vs shipped-UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 asserts "no wave/cohort/batch language" in the dashboard note, but the shipped welcome-email and dashboard copy say "monthly onboarding waves." Decide which side wins; update the other.
- [ ] **E2E coverage for `e2e/wave-slack-onboarding.spec.js`.** 9 of 16 scaffolded tests now passing (admin Slack-invited button + non-trivial dashboard cases). 7 remain skipped pending the bugs above (7.2, 6.2), seeding gaps (7.4 — no dev endpoint to mint members of arbitrary status), Open Questions (7.8, 6.9), or spec-vs-UI conflicts (7.5, 6.7).
- [ ] **Pilot exit decision (~8 weeks post-launch).** Either restore `server/_archive/utils/checkSlackJoins.js` + its plugin if polling is needed, or delete the archive permanently. Driven by whether the manual-invite cadence is sustainable post-pilot.
@@ -82,20 +84,35 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
---
+## Contribution-form-polish follow-ups
+
+From `feature/contribution-form-polish` (14 commits, see memory `project_contribution_form_polish`). Captured 2026-05-23.
+
+- ~~**`account.vue` `currentContributionLabel` uses long-form `/year`, `/month`.**~~ Done 2026-05-24: now returns `formatContribution(amount, cadence.value)` (`/yr`, `/mo`).
+- [ ] **Annual-abandoned-signup `billingCadence` corruption — `/join` half still open + existing-row cleanup.** A member who picks annual but abandons before `/api/helcim/subscription` runs is left at `billingCadence: 'monthly'` + cadence-unit `contributionAmount` (e.g. 180) + `status: 'pending_payment'`, rendering `$180/mo` in admin views (member can't see it — not yet signed in). `billingCadence` is only corrected to `'annual'` once the subscription call runs.
+ - ~~Invite-accept path.~~ Fixed 2026-05-24 (`c3b1c59`): `inviteAcceptSchema` now accepts `cadence`, `accept-invite.vue` sends it, and `accept.post.js` persists `billingCadence` at `Member.create` ($0 forced to `'monthly'`).
+ - ~~`/join` path had the same gap.~~ Fixed 2026-05-24 (`426f233`): `helcimCustomerSchema` accepts `cadence`, `join.vue` sends it to `/api/helcim/customer`, and `customer.post.js` persists `billingCadence` in both the new-member create branch and the guest-upgrade `$set` branch ($0 forced to `'monthly'`). (`/api/members/create` carries the same omission but is uncalled by any frontend — left as-is.)
+ - ~~One-shot cleanup for rows corrupted before these fixes.~~ Verified no-op 2026-05-24: swept the `ghost-guild` DB (59 members) — 0 rows with `billingCadence: 'annual'`, 0 with an annual-magnitude `contributionAmount` (≥60), and all 17 `pending_payment` rows sit at monthly presets {0,5,15,30,50}. No corruption exists, so no script was written or run. Because both code fixes land before cutover, there's no pre-fix production window in which this corruption could occur. (If the fixes somehow ship *after* real annual signups have happened, re-run the sweep post-launch.)
+- [ ] **`/member/payment-setup` is monthly-only by design — lossy `Math.floor(amount/12)` redirect from account.vue.** Spec already deferred this. Annual members landing on payment-setup via account.vue's `requiresPaymentSetup` recovery path get their cadence-unit amount floor-divided to monthly at the redirect (e.g. $100/yr → tier=8 → $96/yr after re-charge). Reachable only for corrupt-state annual members who lost their `helcimSubscriptionId`. Long-term: payment-setup accepts `?cadence=` and passes through to `update-contribution`. WHY comment lives at `account.vue:472-474`.
+- [ ] **No unit tests on `ContributionAmountField.vue`.** The cadence-toggle math (×12 / floor(/12)) and soft-max threshold ($500/mo equiv) live entirely in the component. E2E tests on `/join` + `/accept-invite` cover the happy paths. A small Vitest suite around toggle math, preset selection, and emission would protect against regressions as the component gains more consumers. _Deferred 2026-05-24: a mounted test needs `@vue/test-utils` (not installed; not bundled by `@nuxt/test-utils`) plus a `~` alias / Nuxt env for the `client` vitest project. Out of scope for the low-risk batch — pick up with a deliberate decision on the test approach._
+- [ ] **Migration script TOCTOU.** `scripts/migrate-annual-contribution-to-cadence-unit.cjs` reads then writes in two separate ops per doc. If a member updates their contribution via the UI between the read and the write, the migration overwrites the new value with `(stale × 12)`. Mitigation: run during a brief maintenance window, or refactor to a single `updateOne` with `$mul` + marker guard. Low-impact given the small annual-member population.
+
+---
+
## Simplify-pass follow-ups (still open)
Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins batch shipped 3 items (STATUS_LABELS dedup, ImageUpload focus, signupBridge rename). Remaining:
-- [ ] **Extract `.tint-candle` / `.tint-ember` utility classes.** The `color-mix(in srgb, var(--candle) 15%, transparent)` + matching border pattern is now inlined as `style=""` in ~9 sites across `EventSeriesTicketCard.vue`, `SeriesPassPurchase.vue`, `NaturalDateInput.vue`, `ImageUpload.vue`. Promote to utility classes in `app/assets/css/main.css` so future tints don't keep multiplying inline styles (and so `:hover` / `:focus` variants are reachable).
-- [ ] **Audit `member &&` truthy checks in sibling ticket/subscription routes.** Commit `f66455e` fixed `server/api/events/[id]/tickets/available.get.js:115` to use `hasMemberAccess(member)`. Same anti-pattern likely exists in adjacent routes (`tickets/purchase.post.js`, subscription endpoints). Guests/suspended/cancelled members would currently look like full members for any feature gated on truthiness alone.
-- [ ] **STATUS_LABELS dedup — verify.** The 2026-04-30 small-wins batch claimed STATUS_LABELS dedup, but `e2e/admin-members.spec.js` expansion found an inline copy still at `app/pages/admin/members/index.vue:491` and another at `app/pages/member/account.vue:420`. Either the previous dedup was partial or a new copy was reintroduced — confirm and finish dedup into a shared constants module.
-- [ ] **`app/pages/admin/members/[id].vue` status select still hand-written.** Commit `441a5f5` aligned the index page's status `