@@ -537,24 +534,6 @@ onMounted(loadActivity)
margin-top: 12px;
}
-.field-hint {
- font-size: 11px;
- color: var(--text-faint);
- margin: 6px 0 0;
- line-height: 1.4;
-}
-
-.field-hint--warn {
- color: var(--ember);
- border-left: 2px solid var(--ember);
- padding: 4px 0 4px 8px;
-}
-
-.field-hint code {
- font-family: "Commit Mono", monospace;
- font-size: 10px;
-}
-
.form-actions {
display: flex;
gap: 8px;
diff --git a/app/pages/admin/members/index.vue b/app/pages/admin/members/index.vue
index bce22e1..4e8f28d 100644
--- a/app/pages/admin/members/index.vue
+++ b/app/pages/admin/members/index.vue
@@ -42,7 +42,7 @@
@@ -371,7 +373,7 @@
-
+
+ {{ circleLabels[member.circle] }}
+
·
{{ member.studio }}
@@ -370,7 +372,7 @@ useHead({
}
.profile-name {
font-family: "Brygada 1918", serif;
- font-size: 36px;
+ font-size: 42px;
font-weight: 600;
color: var(--text-bright);
margin: 0;
diff --git a/app/pages/series/index.vue b/app/pages/series/index.vue
index aaade84..6634190 100644
--- a/app/pages/series/index.vue
+++ b/app/pages/series/index.vue
@@ -185,7 +185,7 @@ const getEventStatus = (event) => {
display: flex;
align-items: baseline;
gap: 12px;
- padding: 12px 28px;
+ padding: 10px 28px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md
index 2fdacdf..27c0e99 100644
--- a/docs/LAUNCH_READINESS.md
+++ b/docs/LAUNCH_READINESS.md
@@ -132,16 +132,13 @@ Not blocking launch — the amendment hasn't passed yet, and the user-visible co
See `docs/TODO.md` for:
- Button minimum target size (WCAG AAA 2.5.5).
-- ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`.
-- ~~Admin layout migration from `guild-*` tokens to zine spec~~ — verified clean 2026-04-29; grep for `guild-[0-9]|candlelight-[0-9]|ember-[0-9]` across `app/layouts/`, `app/pages/admin/`, `app/components/admin/` returns zero matches. All tokens already converted.
-- ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29.
-- ~~Members table NAME column clipping~~ — verified stale 2026-04-29.
+- `/oidc/interaction/[uid]` routing quirk.
+- Admin layout migration from `guild-*` tokens to zine spec.
+- Admin dashboard quick-action button contrast.
+- Members table NAME column clipping.
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
-- ~~`tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members~~ — fixed 2026-04-29 (commit `f66455e`); `memberSavings` now gated on `hasMemberAccess(member)`.
-- Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`.
-- ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`.
-- ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed.
-- Simplify-pass follow-ups (2026-04-29): smallest wins shipped in commit `26791cc`; deferred items (rename `setPaymentBridgeCookie`, dedup admin `STATUS_LABELS`, extract `.tint-candle`/`.tint-ember` utilities, audit `member &&` truthy checks in sibling routes, restore `ImageUpload` alt-text input focus styling) tracked in `docs/TODO.md` § _Simplify-pass follow-ups — 2026-04-29_.
+- `tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members — cosmetic; suppress comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI.
+- Simplify-pass follow-ups (2026-04-25): source-grep test bloat, login/verify rate-limit gap, stringly-typed `metadata.type`, reconcile-payments sequential loop, stale `new Date()` in events list, `loadPublicSeries` helper extraction.
### Known gotchas worth addressing post-launch
@@ -153,13 +150,14 @@ See `docs/TODO.md` for:
Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach.
-- ~~**Success-state color convention (4 instances).**~~ Resolved 2026-04-29: gold (`--candle`) chosen as zine-consistent. Phantom-Tailwind cleanup shipped in `dc2becf` (`EventSeriesTicketCard.vue` + `SeriesPassPurchase.vue` member-benefit notice).
-- ~~**Sidebar breakpoint unverified.**~~ Verified clean 2026-04-29 — `.events-mini` hides at ≤1024px cleanly across 1023/1024/1025/1100. Actual rule lives in `EventsMiniSidebar.vue:129` + `ColumnsLayout.vue:83` (audit doc cited the wrong line).
-- ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach.
-- ~~**`.section-label` extraction candidate.**~~ Verified 2026-04-29 — utility already exists at `main.css:128` and is used in 30+ places. Two scoped overrides intentionally diverge.
-- ~~**Past-events toggle component.**~~ Audited 2026-04-29 — consistent with the design system (dashed-border button, gold active state, valid `aria-pressed` toggle). Added missing `:focus-visible` outline in commit `dadec1a`; no other changes warranted.
+- **Success-state color convention (4 instances).** "You're Registered!" blocks use `--candle` (gold) instead of `--green`. Touches `EventSeriesTicketCard.vue:186-196` (still uses phantom `candlelight-*` classes — preserved byte-for-byte pending decision) and registered-state wrappers in `SeriesPassPurchase.vue`. Needs a UX call on whether success should render gold (zine-consistent) or green (semantic). Once decided, finish the phantom-palette removal on those 4 lines.
+- **Sidebar breakpoint unverified.** `app/layouts/default.vue:89` hides the sidebar at ≤1024px per spec. Browser `resize_window` tool refused viewport changes during the audit, so the actual crossover and any layout shift at 1023–1025px was never visually confirmed. Do a manual responsive check before declaring the sidebar pattern shipped.
+- **`EventTicketPurchase.vue:469` magic padding.** `.consent-hint { padding-left: 24px; }` is a hardcoded offset to align the hint under the checkbox text. Cosmetic; swap for a gap/grid approach when touching the consent block next.
+- **`.section-label` extraction candidate.** Several audited files repeat the same uppercase/letter-spaced small label pattern inline. Low-priority refactor into a utility class in `main.css`.
+- **Past-events toggle component.** Existing, untouched this pass; noted in findings doc as a future consistency check.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
-
-SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message).
+- Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`).
+- Update error log message referencing "tier" in `server/api/members/update-contribution.post.js:221`.
+- Rename `handleUpdateTier` handler in `app/pages/member/account.vue`.
diff --git a/server/api/events/[id]/tickets/available.get.js b/server/api/events/[id]/tickets/available.get.js
index 12d64e2..2fd64a3 100644
--- a/server/api/events/[id]/tickets/available.get.js
+++ b/server/api/events/[id]/tickets/available.get.js
@@ -6,7 +6,6 @@ import {
checkTicketAvailability,
checkUserSeriesPass,
formatPrice,
- hasMemberAccess,
} from "../../../../utils/tickets.js";
/**
@@ -112,7 +111,7 @@ export default defineEventHandler(async (event) => {
);
}
- if (hasMemberAccess(member) && eventData.tickets?.public?.available) {
+ if (member && eventData.tickets?.public?.available) {
response.publicTicket = {
price: eventData.tickets.public.price,
formattedPrice: formatPrice(
diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js
index d0fc95d..3382b7f 100644
--- a/server/api/helcim/customer.post.js
+++ b/server/api/helcim/customer.post.js
@@ -88,11 +88,12 @@ export default defineEventHandler(async (event) => {
member
})
- // Signup completes (paid checkout or free activation) before the magic
- // link is clicked, so issue a short-lived, payment-only bridge cookie
- // that lets /api/helcim/initialize-payment and /api/helcim/subscription
- // identify the member without a verified auth session.
- setPaymentBridgeCookie(event, member)
+ // Paid-tier signups need to complete Helcim checkout in the same tab
+ // before the magic link can be clicked. Issue a short-lived, payment-only
+ // bridge cookie so /api/helcim/initialize-payment accepts the request.
+ if (body.contributionAmount > 0) {
+ setPaymentBridgeCookie(event, member)
+ }
return {
success: true,
diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js
index 3e2c071..0e4d1f9 100644
--- a/server/api/members/update-contribution.post.js
+++ b/server/api/members/update-contribution.post.js
@@ -203,10 +203,6 @@ export default defineEventHandler(async (event) => {
}
const memberCadence = member.billingCadence || 'monthly';
- // TODO: Cadence-switch UI on /member/account. Plain Helcim subscription
- // updates can't change billing period — would need a sub-replacement flow
- // (cancel current, create new at desired cadence). See
- // docs/LAUNCH_READINESS.md "Known gotchas" → "Cadence switch rejected".
if (body.cadence && body.cadence !== memberCadence) {
throw createError({
statusCode: 400,
@@ -254,7 +250,7 @@ export default defineEventHandler(async (event) => {
};
} catch (error) {
if (error.statusCode) throw error;
- console.error("Error updating contribution amount:", error);
+ console.error("Error updating contribution tier:", error);
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
diff --git a/server/utils/oidc-provider.ts b/server/utils/oidc-provider.ts
index 187f8a8..dfc7042 100644
--- a/server/utils/oidc-provider.ts
+++ b/server/utils/oidc-provider.ts
@@ -86,7 +86,9 @@ export async function getOidcProvider() {
},
features: {
- devInteractions: { enabled: false },
+ devInteractions: {
+ enabled: process.env.NODE_ENV !== "production",
+ },
revocation: { enabled: true },
rpInitiatedLogout: {
enabled: true,
diff --git a/tests/server/api/free-signup-flow.test.js b/tests/server/api/free-signup-flow.test.js
deleted file mode 100644
index 521c0b2..0000000
--- a/tests/server/api/free-signup-flow.test.js
+++ /dev/null
@@ -1,142 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-
-import Member from '../../../server/models/member.js'
-import { createHelcimCustomer } from '../../../server/utils/helcim.js'
-import customerHandler from '../../../server/api/helcim/customer.post.js'
-import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
-import { resetRateLimit } from '../../../server/utils/rateLimit.js'
-import { sendWelcomeEmail } from '../../../server/utils/resend.js'
-import { createMockEvent } from '../helpers/createMockEvent.js'
-
-// Deliberately does NOT mock server/utils/auth.js — the bridge-cookie
-// hand-off between the two handlers is the contract under test.
-
-vi.mock('../../../server/models/member.js', () => ({
- default: {
- findOne: vi.fn(),
- create: vi.fn(),
- findByIdAndUpdate: vi.fn(),
- findById: vi.fn(),
- findOneAndUpdate: vi.fn()
- }
-}))
-vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
-vi.mock('../../../server/utils/helcim.js', () => ({
- createHelcimCustomer: vi.fn(),
- createHelcimSubscription: vi.fn(),
- generateIdempotencyKey: vi.fn().mockReturnValue('idem-1'),
- listHelcimCustomerTransactions: vi.fn().mockResolvedValue([])
-}))
-vi.mock('../../../server/utils/magicLink.js', () => ({
- sendMagicLink: vi.fn().mockResolvedValue(undefined)
-}))
-vi.mock('../../../server/utils/resend.js', () => ({
- sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true })
-}))
-vi.mock('../../../server/utils/slack.ts', () => ({
- getSlackService: vi.fn().mockReturnValue(null)
-}))
-vi.mock('../../../server/utils/payments.js', () => ({
- upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true })
-}))
-
-vi.stubGlobal('helcimCustomerSchema', {})
-vi.stubGlobal('helcimSubscriptionSchema', {})
-
-const ALLOWED_ORIGIN = 'https://ghostguild.test'
-const MEMBER_ID = '69f231152939bf109ac79d83'
-
-const SUBSCRIPTION_BODY = {
- customerId: 999,
- customerCode: 'CST999',
- contributionAmount: 0,
- cadence: 'monthly',
- cardToken: null
-}
-
-function extractBridgeCookie(event) {
- const setCookie = event.node.res.getHeader('set-cookie')
- const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean)
- const match = cookies.find(c => typeof c === 'string' && c.startsWith('payment-bridge='))
- if (!match) return null
- return match.match(/payment-bridge=([^;]+)/)[1]
-}
-
-describe('signup → subscription bridge-cookie hand-off', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- resetRateLimit()
- process.env.BASE_URL = ALLOWED_ORIGIN
-
- createHelcimCustomer.mockResolvedValue({ id: 999, customerCode: 'CST999' })
- })
-
- it('$0 signup: customer endpoint issues a bridge cookie that subscription endpoint accepts', async () => {
- Member.findOne.mockResolvedValue(null)
- Member.create.mockResolvedValue({
- _id: MEMBER_ID,
- email: 'free@example.com',
- name: 'Free User',
- circle: 'community',
- contributionAmount: 0,
- status: 'pending_payment'
- })
-
- const customerEvent = createMockEvent({
- method: 'POST',
- path: '/api/helcim/customer',
- headers: { origin: ALLOWED_ORIGIN },
- body: {
- name: 'Free User',
- email: 'free@example.com',
- circle: 'community',
- contributionAmount: 0,
- agreedToGuidelines: true,
- billingAddress: { country: 'CA' }
- }
- })
-
- const result1 = await customerHandler(customerEvent)
- expect(result1.success).toBe(true)
- expect(result1.member.status).toBe('pending_payment')
-
- const bridgeToken = extractBridgeCookie(customerEvent)
- expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy()
-
- Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' })
- Member.findById.mockResolvedValue({
- _id: MEMBER_ID,
- email: 'free@example.com',
- name: 'Free User',
- circle: 'community',
- contributionAmount: 0,
- status: 'active'
- })
-
- const subscriptionEvent = createMockEvent({
- method: 'POST',
- path: '/api/helcim/subscription',
- headers: { origin: ALLOWED_ORIGIN },
- cookies: { 'payment-bridge': bridgeToken },
- body: SUBSCRIPTION_BODY
- })
-
- const result2 = await subscriptionHandler(subscriptionEvent)
- expect(result2.success).toBe(true)
- expect(result2.member.status).toBe('active')
- expect(sendWelcomeEmail).toHaveBeenCalledTimes(1)
- })
-
- it('$0 signup with no bridge cookie carried forward → subscription returns 401', async () => {
- const subscriptionEvent = createMockEvent({
- method: 'POST',
- path: '/api/helcim/subscription',
- headers: { origin: ALLOWED_ORIGIN },
- body: SUBSCRIPTION_BODY
- })
-
- await expect(subscriptionHandler(subscriptionEvent)).rejects.toMatchObject({
- statusCode: 401
- })
- })
-})