Compare commits

...

27 commits

Author SHA1 Message Date
9c7d6fa446 Merge pull request 'chore/visual-fidelity-fixes' (#2) from chore/visual-fidelity-fixes into main
Some checks failed
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Successful in 11m11s
Test / playwright (push) Has been cancelled
Reviewed-on: #2
2026-04-30 12:36:08 +00:00
5a69d6ab75 style(visual-fidelity): missed Batch B row in admin/members
Some checks failed
Test / vitest (pull_request) Successful in 11m59s
Test / playwright (pull_request) Failing after 9m53s
Test / visual (pull_request) Failing after 9m20s
Test / Notify on failure (pull_request) Successful in 1s
.row-error background was the one rgba leftover from the
pages-admin slice — line had shifted from 1309 to 1307 after
earlier Batch B edits.
2026-04-30 11:47:44 +01:00
d6cdf45838 style(visual-fidelity): components — batches B,E,G,H
- B: token-equivalent rgba → color-mix in SignupFlowOverlay, OnboardingWidget
- E: drop text-white Tailwind utility from ImageUpload remove-button (now color: var(--parch-text) inline)
- G: typography off-scale snaps (9→10, 14→13, 15→16, 19→18 px)
- H: padding off-scale snaps in BoardPostCard/Form, CirclePicker, FilterBar, LoginModal
2026-04-30 00:13:13 +01:00
cb93f14160 style(visual-fidelity): pages-admin — batches B,C,F
- B: token-equivalent rgba → color-mix(srgb, var(--ember|green|candle) X%, transparent) so colors track dark mode
- C: drop stale var(--green, #...) fallbacks (canonical token now defined in main.css)
- F: inline circle badge → <CircleBadge/> in admin/index, members/[id], members/index
2026-04-30 00:13:09 +01:00
d93c16fbf7 style(visual-fidelity): pages-auth — batches D,G
font-weight 700 → 600 across auth pages; wiki-login hero 32→36
2026-04-30 00:13:05 +01:00
cad57b0083 style(visual-fidelity): pages-public — batches A,D,F,G,H
- about.vue: promote h3 → h2 on circle headings (h1→h2→h2→h2)
- coming-soon.vue: font-weight 700 → 600
- members/[id].vue: inline circle badge → <CircleBadge/>; hero size 42→36
- community-guidelines.vue: padding + font-size off-scale snaps
- board.vue: loading/empty padding 60→64
- series/index.vue, join.vue: padding off-scale snaps
2026-04-30 00:13:02 +01:00
1c2d1537a8 docs(backlog): log 2026-04-29 simplify-pass and deferred follow-ups 2026-04-29 21:50:43 +01:00
26791cc0e3 chore(simplify): trim narrating comments and dedup test body
Test file: drop step markers, regression explainers, and the lead
comment block that restated the contract; hoist the shared subscription
request body to a const; move Member mock defaults into the test that
uses them. Two it() cases unchanged.

Events page: drop WCAG comment that narrated what the
.past-toggle:focus-visible selector already says.
2026-04-29 21:50:00 +01:00
6527bbbe4e test(api): cover free-signup → subscription bridge-cookie hand-off
Two tests guarding the regression where /api/helcim/customer skipped
setPaymentBridgeCookie for $0 signups and left the user unable to
complete activation. Second test confirms the auth gate on
/api/helcim/subscription still rejects fresh unauthenticated calls.
2026-04-29 21:00:27 +01:00
90acc35792 fix(helcim): always issue payment-bridge cookie on signup
Free ($0) signups need the same short-lived bridge cookie as paid signups
so /api/helcim/subscription can identify the member during activation
without a verified auth session. Drops the contributionAmount > 0 guard
that broke free-tier activation in the same flow.
2026-04-29 21:00:22 +01:00
dbd46cc157 docs(backlog): strike EventSeriesBadge dead-code follow-up as shipped 2026-04-29 20:57:06 +01:00
a9acc4c2dc docs(backlog): strike past-events toggle as audited and fixed 2026-04-29 20:56:21 +01:00
dadec1a273 fix(events): add focus-visible outline to past-events toggle
Custom .past-toggle button had no focus indicator — keyboard users
got nothing. Match the canonical WCAG 2.4.7 outline used on .btn
and .zine-select (dashed candle, 3px offset).
2026-04-29 20:39:31 +01:00
f85f284ea5 chore(series): delete unused EventSeriesBadge component
Zero usages across app/ and server/. Migrated to design tokens in commit
350d6c2 before the dead-code status was confirmed; safe to remove now.
2026-04-29 20:38:29 +01:00
55c57d263d docs(backlog): strike shipped items in launch-readiness post-launch list
Strikes:
- memberSavings inactive-member block (shipped f66455e)
- Success-state color convention 4-instances (gold chosen, shipped dc2becf)
- Sidebar 1024px breakpoint verified clean
- EventTicketPurchase magic 24px padding (shipped 7e44809)
- .section-label extraction (already extracted at main.css:128)
- Contribution-amount cosmetic cleanup (shipped 955217a)
- Reconcile customerCode bug (shipped 3c38333, pre-existing on main)

Adds:
- Pointer noting EventSeriesBadge.vue is unused — delete in a future pass.
- Pointer noting Simplify-pass follow-ups are documented in memory.
2026-04-29 20:26:52 +01:00
1da76b11cb fix(series): replace phantom Tailwind on SeriesPassPurchase
Error state and main registration card swap bg-ember-*/border-ember-* and
bg-guild-*/border-guild-* utilities for design tokens in a scoped style
block. Error state uses the codebase's --ember + 8% color-mix pattern;
registration card uses --surface + dashed --border per the zine spec.
2026-04-29 20:22:35 +01:00
350d6c219c fix(series): replace phantom guild Tailwind on EventSeriesBadge
Swap bg-guild-*/border-guild-*/text-guild-* utility classes for design tokens
in a scoped style block. Drops rounded-* per the no-rounded-corners rule and
uses dashed borders for the structural block per the zine spec.
2026-04-29 20:22:30 +01:00
05c47c4499 docs(backlog): close out admin layout token migration as stale
Verified clean 2026-04-29: grep for guild-[0-9]|candlelight-[0-9]|ember-[0-9]
across app/layouts/, app/pages/admin/, and app/components/admin/ returns zero
matches. All admin surfaces already use design tokens.
2026-04-29 20:22:25 +01:00
59d2be2df8 docs(backlog): close out a11y triage items
Strike two stale entries (verified 2026-04-29) and the OIDC routing
quirk (fixed in 23154ff).
2026-04-29 20:10:38 +01:00
23154ff232 fix(oidc): disable devInteractions so custom interactions.url runs in dev
oidc-provider's devInteractions is a quick-start scaffold that, when
enabled, mutates configuration.url to its own urlFor('interaction')
helper — emitting /interaction/UID instead of our /oidc/interaction/UID.
That made /oidc/auth redirect to a 404 in local dev and forced a stale
TODO entry. We already have our own interaction handler at
server/routes/oidc/interaction/[uid].get.ts, so devInteractions is
unnecessary; disabling it makes dev match prod and clears the
oidc-provider warning "your configuration is not in effect".
2026-04-29 19:59:49 +01:00
a69c9d9b49 fix(uploads): replace phantom Tailwind palette with design tokens
Sibling sweep to dc2becf: NaturalDateInput.vue and ImageUpload.vue used
candlelight-/ember-/guild-* utility classes that aren't defined in the
project's Tailwind palette and rendered as no-ops. Swapped to inline
styles using --candle, --ember, --text-dim/faint/bright, --border,
--input-bg, --surface. Drag-state and parsed-date notices follow the
color-mix(... 15%) + 1px solid pattern from dc2becf.
2026-04-29 19:46:59 +01:00
dc2becf63e fix(events): replace phantom candlelight Tailwind with --candle var 2026-04-29 18:30:29 +01:00
e19b16a5cc chore(members): TODO comment for cadence-switch sub-replacement flow 2026-04-29 18:26:40 +01:00
e756170884 feat(admin): warn that contribution edit doesn't sync Helcim 2026-04-29 18:25:59 +01:00
7e44809a83 fix(events): grid-align consent hint, drop magic 24px padding 2026-04-29 18:22:45 +01:00
f66455eda5 fix(tickets): gate memberSavings on hasMemberAccess
Previously the publicTicket comparison block ran whenever a Member record
existed, which surfaced "$0 saved" for cancelled/suspended/guest accounts.
Use the canonical hasMemberAccess helper so only active/pending_payment
members see the savings comparison.
2026-04-29 17:54:58 +01:00
955217a941 chore(admin): rename pending_payment label and tier→contribution
Backlog cleanup from docs/LAUNCH_READINESS.md:
- B4: admin status filter + form options + STATUS_LABELS now read
  "Payment setup incomplete" so admins stop conflating with membership state
- CSV import preview header "Tier" → "Contribution"
- handleUpdateTier → handleUpdateContribution on /member/account
- update-contribution error log "tier" → "amount"
2026-04-29 17:54:53 +01:00
40 changed files with 369 additions and 220 deletions

View file

@ -158,7 +158,7 @@ const slackLinks = computed(() => {
<style scoped> <style scoped>
.board-post { .board-post {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 18px 22px; padding: 20px 24px;
background: var(--surface); background: var(--surface);
break-inside: avoid; break-inside: avoid;
-webkit-column-break-inside: avoid; -webkit-column-break-inside: avoid;
@ -219,7 +219,7 @@ const slackLinks = computed(() => {
.post-title { .post-title {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 19px; font-size: 18px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
margin: 0 0 12px; margin: 0 0 12px;

View file

@ -138,7 +138,7 @@ function handleSubmit() {
<style scoped> <style scoped>
.post-form { .post-form {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 14px 16px; padding: 16px 16px;
background: transparent; background: transparent;
} }
@ -147,7 +147,7 @@ function handleSubmit() {
} }
.form-title { .form-title {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 15px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
} }
@ -183,7 +183,7 @@ function handleSubmit() {
color: var(--text-faint); color: var(--text-faint);
text-transform: none; text-transform: none;
letter-spacing: 0; letter-spacing: 0;
font-size: 9px; font-size: 10px;
margin-left: 4px; margin-left: 4px;
opacity: 0.7; opacity: 0.7;
} }

View file

@ -48,7 +48,7 @@ defineEmits(['update:modelValue'])
.circle-option { .circle-option {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 14px 12px; padding: 12px 12px;
background: var(--bg); background: var(--bg);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
@ -83,7 +83,7 @@ defineEmits(['update:modelValue'])
} }
.circle-tag { .circle-tag {
font-size: 9px; font-size: 10px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
margin-top: 6px; margin-top: 6px;

View file

@ -1,70 +0,0 @@
<template>
<div
class="series-badge p-4 bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600"
>
<div class="flex items-start justify-between gap-6">
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span
class="series-badge__label text-sm font-semibold text-guild-300 dark:text-guild-300"
>
Part of a Series
</span>
<span
v-if="totalEvents"
class="series-badge__count inline-flex items-center px-2 py-0.5 rounded-md bg-guild-700/50 dark:bg-guild-600/50 text-sm font-medium text-guild-200 dark:text-guild-200"
>
<template v-if="position">
Event {{ position }} of {{ totalEvents }}
</template>
<template v-else> {{ totalEvents }} events in series </template>
</span>
</div>
<h3
class="series-badge__title text-lg font-semibold text-guild-100 dark:text-guild-100 mb-2"
>
{{ title }}
</h3>
<p
v-if="description"
class="series-badge__description text-sm text-guild-300 dark:text-guild-300"
>
{{ description }}
</p>
</div>
<div v-if="seriesId" class="flex-shrink-0 self-start">
<UButton
:to="`/series/${seriesId}`"
color="primary"
size="md"
label="View Series"
/>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
default: "",
},
position: {
type: Number,
default: null,
},
totalEvents: {
type: Number,
default: null,
},
seriesId: {
type: String,
required: true,
},
});
</script>

View file

@ -160,12 +160,16 @@
</div> </div>
<!-- Already Registered --> <!-- Already Registered -->
<div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"> <div
v-else-if="alreadyRegistered"
class="p-4"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<Icon name="heroicons:check-badge" class="w-6 h-6 text-candlelight-400 flex-shrink-0" /> <Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" />
<div> <div>
<div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div> <div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div>
<div class="text-sm text-candlelight-400"> <div class="text-sm" style="color: var(--candle)">
You have a series pass and are registered for all {{ totalEvents }} events. You have a series pass and are registered for all {{ totalEvents }} events.
</div> </div>
</div> </div>

View file

@ -154,17 +154,19 @@
securely securely
</p> </p>
<label class="consent-field"> <div class="consent-block">
<input <label class="consent-field">
v-model="form.createAccount" <input
type="checkbox" v-model="form.createAccount"
:disabled="processing" type="checkbox"
> :disabled="processing"
<span>Create a free guest account so I can manage my registration</span> >
</label> <span>Create a free guest account so I can manage my registration</span>
<p class="field-hint consent-hint"> </label>
Guest accounts let you view your tickets and register faster next time. We won't add you to member communications. <p class="field-hint consent-hint">
</p> Guest accounts let you view your tickets and register faster next time. We won't add you to member communications.
</p>
</div>
<button <button
type="submit" type="submit"
@ -450,22 +452,26 @@ const formatEventDate = (date) => {
margin-top: 2px; margin-top: 2px;
} }
.consent-field { .consent-block {
display: flex; display: grid;
grid-template-columns: auto 1fr;
align-items: flex-start; align-items: flex-start;
gap: 8px; column-gap: 8px;
row-gap: 4px;
margin-bottom: 14px;
}
.consent-field {
display: contents;
font-size: 12px; font-size: 12px;
color: var(--text); color: var(--text);
margin-bottom: 4px;
cursor: pointer; cursor: pointer;
} }
.consent-field input[type="checkbox"] { .consent-field input[type="checkbox"] {
margin-top: 3px; margin-top: 3px;
flex-shrink: 0;
accent-color: var(--candle); accent-color: var(--candle);
} }
.consent-hint { .consent-hint {
margin-bottom: 14px; grid-column: 2;
padding-left: 24px; margin: 0;
} }
</style> </style>

View file

@ -104,7 +104,7 @@ const formatDate = (dateStr) => {
} }
.em-circle { .em-circle {
font-size: 9px; font-size: 10px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
margin-top: 2px; margin-top: 2px;

View file

@ -22,7 +22,7 @@ defineEmits(['update:modelValue'])
<style scoped> <style scoped>
.filter-bar { .filter-bar {
padding: 14px 32px; padding: 16px 28px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -5,14 +5,16 @@
<img <img
:src="transformedImageUrl" :src="transformedImageUrl"
:alt="modelValue.alt || 'Event image'" :alt="modelValue.alt || 'Event image'"
class="w-full h-48 object-cover rounded-lg border border-guild-700" class="w-full h-48 object-cover"
style="border: 1px solid var(--border)"
@error="console.log('Image failed to load:', transformedImageUrl)" @error="console.log('Image failed to load:', transformedImageUrl)"
@load="console.log('Image loaded successfully:', transformedImageUrl)" @load="console.log('Image loaded successfully:', transformedImageUrl)"
/> >
<button <button
@click="removeImage"
type="button" type="button"
class="absolute top-2 right-2 p-1 bg-ember-500 text-white rounded-full hover:bg-ember-600 transition-colors" class="absolute top-2 right-2 p-1 rounded-full transition-colors"
style="background: var(--ember); color: var(--parch-text)"
@click="removeImage"
> >
<Icon name="heroicons:x-mark" class="w-4 h-4" /> <Icon name="heroicons:x-mark" class="w-4 h-4" />
</button> </button>
@ -21,67 +23,89 @@
<!-- Upload Area --> <!-- Upload Area -->
<div <div
v-if="!modelValue?.url" v-if="!modelValue?.url"
class="border-2 border-dashed border-guild-700 rounded-lg p-6 text-center hover:border-guild-600 transition-colors" class="border-2 border-dashed p-6 text-center transition-colors"
:style="
isDragging
? 'border-color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent)'
: 'border-color: var(--border)'
"
@dragover.prevent="isDragging = true" @dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false" @dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
:class="{ 'border-candlelight-400 bg-candlelight-900/20': isDragging }"
> >
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
accept="image/*" accept="image/*"
@change="handleFileSelect"
class="hidden" class="hidden"
/> @change="handleFileSelect"
>
<div class="space-y-3"> <div class="space-y-3">
<Icon name="heroicons:photo" class="w-12 h-12 text-guild-400 mx-auto" /> <Icon
name="heroicons:photo"
class="w-12 h-12 mx-auto"
style="color: var(--text-dim)"
/>
<div> <div>
<p class="text-guild-400"> <p style="color: var(--text-dim)">
<button <button
type="button" type="button"
class="font-medium"
style="color: var(--candle)"
@click="$refs.fileInput.click()" @click="$refs.fileInput.click()"
class="text-candlelight-400 hover:text-candlelight-300 font-medium"
> >
Click to upload Click to upload
</button> </button>
or drag and drop or drag and drop
</p> </p>
<p class="text-sm text-guild-500">PNG, JPG, GIF up to 10MB</p> <p class="text-sm" style="color: var(--text-faint)">
PNG, JPG, GIF up to 10MB
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Alt Text Input --> <!-- Alt Text Input -->
<div v-if="modelValue?.url"> <div v-if="modelValue?.url">
<label class="block text-sm font-medium text-guild-100 mb-1"> <label
class="block text-sm font-medium mb-1"
style="color: var(--text-bright)"
>
Alt Text (for accessibility) Alt Text (for accessibility)
</label> </label>
<input <input
:value="modelValue.alt || ''" :value="modelValue.alt || ''"
@input="updateAltText($event.target.value)"
placeholder="Describe this image..." placeholder="Describe this image..."
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-candlelight-500 focus:border-transparent" class="w-full px-3 py-2"
/> style="
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
"
@input="updateAltText($event.target.value)"
>
</div> </div>
<!-- Upload Progress --> <!-- Upload Progress -->
<div v-if="isUploading" class="space-y-2"> <div v-if="isUploading" class="space-y-2">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="text-guild-400">Uploading...</span> <span style="color: var(--text-dim)">Uploading...</span>
<span class="text-guild-400">{{ uploadProgress }}%</span> <span style="color: var(--text-dim)">{{ uploadProgress }}%</span>
</div> </div>
<div class="w-full bg-guild-800 rounded-full h-2"> <div
class="w-full rounded-full h-2"
style="background: var(--surface)"
>
<div <div
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300" class="h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%`" :style="`width: ${uploadProgress}%; background: var(--candle)`"
/> />
</div> </div>
</div> </div>
<!-- Error Message --> <!-- Error Message -->
<div v-if="errorMessage" class="text-sm text-ember-400"> <div v-if="errorMessage" class="text-sm" style="color: var(--ember)">
{{ errorMessage }} {{ errorMessage }}
</div> </div>
</div> </div>

View file

@ -40,7 +40,7 @@
type="email" type="email"
placeholder="your.email@example.com" placeholder="your.email@example.com"
required required
/> >
</div> </div>
<div class="info-box"> <div class="info-box">
@ -182,7 +182,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
.modal-overline { .modal-overline {
font-family: 'Brygada 1918', serif; font-family: 'Brygada 1918', serif;
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--candle); color: var(--candle);
margin-bottom: 12px; margin-bottom: 12px;
@ -218,7 +218,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
.info-box { .info-box {
font-size: 11px; font-size: 11px;
color: var(--text-faint); color: var(--text-faint);
padding: 10px 14px; padding: 12px 16px;
border: 1px dashed var(--border); border: 1px dashed var(--border);
margin-bottom: 16px; margin-bottom: 16px;
line-height: 1.6; line-height: 1.6;

View file

@ -18,12 +18,14 @@
<Icon <Icon
v-if="isValidParse && naturalInput.trim()" v-if="isValidParse && naturalInput.trim()"
name="heroicons:check-circle" name="heroicons:check-circle"
class="w-5 h-5 text-candlelight-500" class="w-5 h-5"
style="color: var(--candle)"
/> />
<Icon <Icon
v-else-if="hasError && naturalInput.trim()" v-else-if="hasError && naturalInput.trim()"
name="heroicons:exclamation-circle" name="heroicons:exclamation-circle"
class="w-5 h-5 text-ember-500" class="w-5 h-5"
style="color: var(--ember)"
/> />
</template> </template>
</UInput> </UInput>
@ -31,7 +33,8 @@
<div <div
v-if="parsedDate && isValidParse" v-if="parsedDate && isValidParse"
class="text-sm text-candlelight-400 bg-candlelight-900/20 px-3 py-2 rounded-lg border border-candlelight-800" class="text-sm px-3 py-2"
style="color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon name="heroicons:calendar" class="w-4 h-4" /> <Icon name="heroicons:calendar" class="w-4 h-4" />
@ -41,7 +44,8 @@
<div <div
v-if="hasError && naturalInput.trim()" v-if="hasError && naturalInput.trim()"
class="text-sm text-ember-400 bg-ember-900/20 px-3 py-2 rounded-lg border border-ember-800" class="text-sm px-3 py-2"
style="color: var(--ember); background: color-mix(in srgb, var(--ember) 15%, transparent); border: 1px solid var(--ember)"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" /> <Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
@ -51,7 +55,7 @@
<!-- Fallback datetime-local input --> <!-- Fallback datetime-local input -->
<details class="text-sm"> <details class="text-sm">
<summary class="cursor-pointer text-guild-400 hover:text-guild-100"> <summary class="cursor-pointer" style="color: var(--text-dim)">
Use traditional date picker Use traditional date picker
</summary> </summary>
<div class="mt-2"> <div class="mt-2">

View file

@ -118,7 +118,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
display: inline-block; display: inline-block;
margin-top: 8px; margin-top: 8px;
padding: 4px 12px; padding: 4px 12px;
border: 1px dashed rgba(237, 228, 208, 0.25); border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent);
color: var(--parch-accent); color: var(--parch-accent);
font-size: 11px; font-size: 11px;
text-decoration: none; text-decoration: none;
@ -134,7 +134,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
.ow-progress { .ow-progress {
margin-top: 10px; margin-top: 10px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed rgba(237, 228, 208, 0.12); border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent);
font-size: 11px; font-size: 11px;
color: var(--parch-text-dim); color: var(--parch-text-dim);
display: flex; display: flex;
@ -153,7 +153,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
} }
.ow-bar-empty { .ow-bar-empty {
color: rgba(237, 228, 208, 0.2); color: color-mix(in srgb, var(--parch-text) 20%, transparent);
} }
.ow-skip { .ow-skip {

View file

@ -9,14 +9,11 @@
</div> </div>
<!-- Error State --> <!-- Error State -->
<div <div v-else-if="error" class="error-state p-6">
v-else-if="error" <h3 class="error-state__heading text-lg font-semibold mb-2">
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
>
<h3 class="text-lg font-semibold text-ember-300 mb-2">
Unable to Load Series Pass Unable to Load Series Pass
</h3> </h3>
<p class="text-ember-400">{{ error }}</p> <p class="error-state__body">{{ error }}</p>
</div> </div>
<!-- Content --> <!-- Content -->
@ -48,7 +45,7 @@
<!-- Registration Form --> <!-- Registration Form -->
<div <div
v-if="passInfo.available && !passInfo.alreadyRegistered" v-if="passInfo.available && !passInfo.alreadyRegistered"
class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6" class="registration-form p-6"
> >
<h3 class="text-xl font-bold text-[--ui-text] mb-6"> <h3 class="text-xl font-bold text-[--ui-text] mb-6">
{{ {{
@ -103,18 +100,20 @@
<!-- Member Benefits Notice --> <!-- Member Benefits Notice -->
<div <div
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember" v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg" class="p-4"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<Icon <Icon
name="heroicons:sparkles" name="heroicons:sparkles"
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" class="w-5 h-5 flex-shrink-0 mt-0.5"
style="color: var(--candle)"
/> />
<div> <div>
<div class="font-semibold text-candlelight-300 mb-1"> <div class="font-semibold mb-1" style="color: var(--candle)">
Member Benefit Member Benefit
</div> </div>
<div class="text-sm text-candlelight-400"> <div class="text-sm" style="color: var(--candle)">
This series pass is free for Ghost Guild members! This series pass is free for Ghost Guild members!
</div> </div>
</div> </div>
@ -355,3 +354,18 @@ const formatPrice = (price, currency = "CAD") => {
}).format(price); }).format(price);
}; };
</script> </script>
<style scoped>
.error-state {
background: color-mix(in srgb, var(--ember) 8%, transparent);
border: 1px dashed var(--ember);
}
.error-state__heading,
.error-state__body {
color: var(--ember);
}
.registration-form {
background: var(--surface);
border: 1px dashed var(--border);
}
</style>

View file

@ -108,7 +108,7 @@ const stepLabel = computed(() => {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 50; z-index: 50;
background: rgba(42, 32, 21, 0.72); background: color-mix(in srgb, var(--parch) 72%, transparent);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -38,16 +38,16 @@
<div class="section-label">The Circles</div> <div class="section-label">The Circles</div>
<div class="circles-grid"> <div class="circles-grid">
<div id="community" class="circle-cell"> <div id="community" class="circle-cell">
<h3 style="color: var(--c-community)">Community</h3> <h2 style="color: var(--c-community)">Community</h2>
<p>For anyone exploring cooperative models.</p> <p>For anyone exploring cooperative models.</p>
</div> </div>
<div id="founder" class="circle-cell"> <div id="founder" class="circle-cell">
<h3 style="color: var(--c-founder)">Founder</h3> <h2 style="color: var(--c-founder)">Founder</h2>
<p>For people actively building cooperatives.</p> <p>For people actively building cooperatives.</p>
</div> </div>
<div id="practitioner" class="circle-cell"> <div id="practitioner" class="circle-cell">
<h3 style="color: var(--c-practitioner)">Practitioner</h3> <h2 style="color: var(--c-practitioner)">Practitioner</h2>
<p>For experienced practitioners sharing what they know.</p> <p>For experienced practitioners sharing what they know.</p>
</div> </div>
</div> </div>

View file

@ -570,7 +570,7 @@ tbody td {
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
color: var(--c-founder); color: var(--c-founder);
border: 1px dashed rgba(138, 68, 32, 0.3); border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
padding: 2px 8px; padding: 2px 8px;
} }
@ -583,7 +583,7 @@ tbody td {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--c-founder); color: var(--c-founder);
border: 1px dashed rgba(138, 68, 32, 0.4); border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
border-radius: 50%; border-radius: 50%;
} }
@ -632,12 +632,12 @@ tbody td {
.status-upcoming { .status-upcoming {
color: var(--candle); color: var(--candle);
border-color: rgba(122, 90, 16, 0.3); border-color: color-mix(in srgb, var(--candle) 30%, transparent);
} }
.status-ongoing { .status-ongoing {
color: var(--green); color: var(--green);
border-color: rgba(74, 106, 56, 0.3); border-color: color-mix(in srgb, var(--green) 30%, transparent);
} }
.status-past { .status-past {
@ -647,7 +647,7 @@ tbody td {
.status-cancelled { .status-cancelled {
color: var(--ember); color: var(--ember);
border-color: rgba(138, 68, 32, 0.3); border-color: color-mix(in srgb, var(--ember) 30%, transparent);
margin-top: 4px; margin-top: 4px;
} }

View file

@ -65,7 +65,7 @@
<span class="item-sub">{{ member.email }}</span> <span class="item-sub">{{ member.email }}</span>
</div> </div>
<div class="item-meta"> <div class="item-meta">
<span class="badge" :class="member.circle">{{ member.circle }}</span> <CircleBadge :circle="member.circle" />
<span class="item-date">{{ formatDate(member.createdAt) }}</span> <span class="item-date">{{ formatDate(member.createdAt) }}</span>
</div> </div>
</div> </div>

View file

@ -16,7 +16,7 @@
<p v-if="member" class="member-email">{{ member.email }}</p> <p v-if="member" class="member-email">{{ member.email }}</p>
</div> </div>
<div v-if="member" class="header-badges"> <div v-if="member" class="header-badges">
<span class="badge" :class="member.circle">{{ member.circle }}</span> <CircleBadge :circle="member.circle" />
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span> <span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div> </div>
</div> </div>
@ -56,6 +56,9 @@
<div class="field"> <div class="field">
<label>Contribution ($/mo)</label> <label>Contribution ($/mo)</label>
<input v-model.number="form.contributionAmount" type="number" min="0" step="1"> <input v-model.number="form.contributionAmount" type="number" min="0" step="1">
<p class="field-hint field-hint--warn">
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard this form does not sync.
</p>
</div> </div>
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
@ -534,6 +537,24 @@ onMounted(loadActivity)
margin-top: 12px; 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 { .form-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;

View file

@ -42,7 +42,7 @@
<select v-model="statusFilter" aria-label="Filter by status"> <select v-model="statusFilter" aria-label="Filter by status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="pending_payment">Pending Payment</option> <option value="pending_payment">Payment setup incomplete</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
</select> </select>
@ -108,9 +108,7 @@
</td> </td>
<td class="col-email">{{ member.email }}</td> <td class="col-email">{{ member.email }}</td>
<td> <td>
<span class="badge" :class="member.circle">{{ <CircleBadge :circle="member.circle" />
member.circle
}}</span>
</td> </td>
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td> <td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
<td> <td>
@ -269,7 +267,7 @@
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
<th>Circle</th> <th>Circle</th>
<th>Tier</th> <th>Contribution</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -373,7 +371,7 @@
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="editingMember.status"> <select v-model="editingMember.status">
<option value="pending_payment">Pending Payment</option> <option value="pending_payment">Payment setup incomplete</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
@ -490,7 +488,7 @@ const sortDir = ref("desc");
const STATUS_LABELS = { const STATUS_LABELS = {
active: "Active", active: "Active",
pending_payment: "Pending", pending_payment: "Payment setup incomplete",
suspended: "Suspended", suspended: "Suspended",
cancelled: "Cancelled", cancelled: "Cancelled",
}; };
@ -1149,7 +1147,7 @@ th.sortable:hover {
text-transform: uppercase; text-transform: uppercase;
} }
.badge.status-active { .badge.status-active {
color: var(--green, #3a6b3a); color: var(--green);
border-color: rgba(58, 107, 58, 0.45); border-color: rgba(58, 107, 58, 0.45);
} }
.badge.status-pending_payment { .badge.status-pending_payment {
@ -1158,7 +1156,7 @@ th.sortable:hover {
} }
.badge.status-suspended { .badge.status-suspended {
color: var(--ember); color: var(--ember);
border-color: rgba(138, 68, 32, 0.45); border-color: color-mix(in srgb, var(--ember) 45%, transparent);
} }
.badge.status-cancelled { .badge.status-cancelled {
color: var(--text-faint); color: var(--text-faint);
@ -1306,7 +1304,7 @@ th.sortable:hover {
} }
.row-error { .row-error {
background: rgba(138, 68, 32, 0.04); background: color-mix(in srgb, var(--ember) 4%, transparent);
} }
/* ---- PREVIEW BOX ---- */ /* ---- PREVIEW BOX ---- */

View file

@ -643,8 +643,8 @@ tbody td {
} }
.status-accepted { .status-accepted {
color: var(--green, #4a7); color: var(--green);
border-color: var(--green, #4a7); border-color: var(--green);
} }
.status-expired { .status-expired {
@ -671,7 +671,7 @@ tbody td {
/* ---- STATUS INDICATORS ---- */ /* ---- STATUS INDICATORS ---- */
.status-ok { .status-ok {
color: var(--green, #4a7); color: var(--green);
font-size: 11px; font-size: 11px;
} }

View file

@ -850,7 +850,7 @@ const exportSeriesData = () => {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--c-founder); color: var(--c-founder);
border: 1px dashed rgba(138, 68, 32, 0.4); border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
@ -931,12 +931,12 @@ const exportSeriesData = () => {
.status-active { .status-active {
color: var(--green); color: var(--green);
border-color: rgba(74, 106, 56, 0.3); border-color: color-mix(in srgb, var(--green) 30%, transparent);
} }
.status-upcoming { .status-upcoming {
color: var(--candle); color: var(--candle);
border-color: rgba(122, 90, 16, 0.3); border-color: color-mix(in srgb, var(--candle) 30%, transparent);
} }
.status-completed { .status-completed {
@ -946,7 +946,7 @@ const exportSeriesData = () => {
.status-ongoing { .status-ongoing {
color: var(--green); color: var(--green);
border-color: rgba(74, 106, 56, 0.3); border-color: color-mix(in srgb, var(--green) 30%, transparent);
} }
/* ---- LINK BUTTONS ---- */ /* ---- LINK BUTTONS ---- */

View file

@ -954,8 +954,8 @@ const applyBatchVisibility = async (hidden) => {
} }
.sync-created { .sync-created {
color: var(--green, #4a7); color: var(--green);
border-color: var(--green, #4a7); border-color: var(--green);
} }
.sync-updated { .sync-updated {

View file

@ -82,7 +82,7 @@ if (import.meta.server && !xsrf.value) {
.auth-title { .auth-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 600;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);

View file

@ -46,7 +46,7 @@ useHead({ title: "Signed Out — Ghost Guild" });
.auth-title { .auth-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 600;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);

View file

@ -70,7 +70,7 @@ const hasDetail = computed(
.auth-title { .auth-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 600;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);
@ -97,7 +97,7 @@ const hasDetail = computed(
.auth-detail-code { .auth-detail-code {
color: var(--ember); color: var(--ember);
font-weight: 700; font-weight: 600;
margin: 0 0 4px; margin: 0 0 4px;
} }

View file

@ -172,8 +172,8 @@ function resetForm() {
.wiki-login-title { .wiki-login-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 32px; font-size: 36px;
font-weight: 700; font-weight: 600;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);
@ -240,7 +240,7 @@ function resetForm() {
.wiki-login-sent-heading { .wiki-login-sent-heading {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 600;
color: var(--text-bright); color: var(--text-bright);
margin: 0; margin: 0;
} }

View file

@ -357,13 +357,13 @@ onMounted(async () => {
/* ---- LOADING / EMPTY ---- */ /* ---- LOADING / EMPTY ---- */
.loading-state { .loading-state {
padding: 60px 24px; padding: 64px 24px;
text-align: center; text-align: center;
color: var(--text-faint); color: var(--text-faint);
font-size: 12px; font-size: 12px;
} }
.empty-state { .empty-state {
padding: 60px 24px; padding: 64px 24px;
text-align: center; text-align: center;
} }
.empty-title { .empty-title {

View file

@ -124,7 +124,7 @@ const handleLogout = async () => {
.coming-soon-title { .coming-soon-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 3rem; font-size: 3rem;
font-weight: 700; font-weight: 600;
color: var(--text-bright); color: var(--text-bright);
margin-bottom: 8px; margin-bottom: 8px;
} }

View file

@ -309,7 +309,7 @@ useHead({
} }
.guidelines-section ul li { .guidelines-section ul li {
position: relative; position: relative;
padding: 2px 0 2px 18px; padding: 2px 0 2px 16px;
font-size: 13px; font-size: 13px;
color: var(--text-dim); color: var(--text-dim);
line-height: 1.7; line-height: 1.7;
@ -365,7 +365,7 @@ useHead({
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-style: italic; font-style: italic;
color: var(--text-bright); color: var(--text-bright);
font-size: 15px; font-size: 16px;
margin-top: 12px; margin-top: 12px;
} }

View file

@ -430,6 +430,10 @@ const isAlmostFull = (event) => {
border-color: var(--candle-faint); border-color: var(--candle-faint);
color: var(--text-dim); color: var(--text-dim);
} }
.past-toggle:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;
}
.past-toggle.active { .past-toggle.active {
border-color: var(--candle); border-color: var(--candle);
border-style: solid; border-style: solid;

View file

@ -747,7 +747,7 @@ onUnmounted(() => {
padding: 0; padding: 0;
} }
.tier-list li { .tier-list li {
padding: 5px 0; padding: 4px 0;
font-size: 12px; font-size: 12px;
color: var(--text-dim); color: var(--text-dim);
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);

View file

@ -283,7 +283,7 @@
form.contributionAmount === Number(memberData.contributionAmount || 0) || form.contributionAmount === Number(memberData.contributionAmount || 0) ||
isUpdating isUpdating
" "
@click="handleUpdateTier" @click="handleUpdateContribution"
> >
{{ isUpdating ? "Updating…" : "Update Contribution" }} {{ isUpdating ? "Updating…" : "Update Contribution" }}
</button> </button>
@ -482,7 +482,7 @@ const refreshNextBillingIfStale = async () => {
} }
}; };
const handleUpdateTier = async () => { const handleUpdateContribution = async () => {
isUpdating.value = true; isUpdating.value = true;
try { try {
await $fetch("/api/members/update-contribution", { await $fetch("/api/members/update-contribution", {

View file

@ -37,9 +37,7 @@
<span class="profile-pronouns">{{ member.pronouns }}</span> <span class="profile-pronouns">{{ member.pronouns }}</span>
</div> </div>
<div class="profile-meta"> <div class="profile-meta">
<span v-if="member.circle" class="badge" :class="member.circle"> <CircleBadge v-if="member.circle" :circle="member.circle" :label="circleLabels[member.circle]" />
{{ circleLabels[member.circle] }}
</span>
<template v-if="member.studio"> <template v-if="member.studio">
<span class="meta-sep">&middot;</span> <span class="meta-sep">&middot;</span>
<span class="profile-studio">{{ member.studio }}</span> <span class="profile-studio">{{ member.studio }}</span>
@ -372,7 +370,7 @@ useHead({
} }
.profile-name { .profile-name {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 42px; font-size: 36px;
font-weight: 600; font-weight: 600;
color: var(--text-bright); color: var(--text-bright);
margin: 0; margin: 0;

View file

@ -185,7 +185,7 @@ const getEventStatus = (event) => {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 12px; gap: 12px;
padding: 10px 28px; padding: 12px 28px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
font-size: 12px; font-size: 12px;
} }

View file

@ -132,13 +132,16 @@ Not blocking launch — the amendment hasn't passed yet, and the user-visible co
See `docs/TODO.md` for: See `docs/TODO.md` for:
- Button minimum target size (WCAG AAA 2.5.5). - Button minimum target size (WCAG AAA 2.5.5).
- `/oidc/interaction/[uid]` routing quirk. - ~~`/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. - ~~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. - ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29.
- Members table NAME column clipping. - ~~Members table NAME column clipping~~ — verified stale 2026-04-29.
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption). - 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 — cosmetic; suppress comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI. - ~~`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): 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. - 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_.
### Known gotchas worth addressing post-launch ### Known gotchas worth addressing post-launch
@ -150,14 +153,13 @@ 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. 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).** "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. - ~~**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.** `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 10231025px was never visually confirmed. Do a manual responsive check before declaring the sidebar pattern shipped. - ~~**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.** `.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. - ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach.
- **`.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`. - ~~**`.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.** Existing, untouched this pass; noted in findings doc as a future consistency check. - ~~**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.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior) ### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
- 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`. SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message).
- Rename `handleUpdateTier` handler in `app/pages/member/account.vue`.

View file

@ -6,6 +6,7 @@ import {
checkTicketAvailability, checkTicketAvailability,
checkUserSeriesPass, checkUserSeriesPass,
formatPrice, formatPrice,
hasMemberAccess,
} from "../../../../utils/tickets.js"; } from "../../../../utils/tickets.js";
/** /**
@ -111,7 +112,7 @@ export default defineEventHandler(async (event) => {
); );
} }
if (member && eventData.tickets?.public?.available) { if (hasMemberAccess(member) && eventData.tickets?.public?.available) {
response.publicTicket = { response.publicTicket = {
price: eventData.tickets.public.price, price: eventData.tickets.public.price,
formattedPrice: formatPrice( formattedPrice: formatPrice(

View file

@ -88,12 +88,11 @@ export default defineEventHandler(async (event) => {
member member
}) })
// Paid-tier signups need to complete Helcim checkout in the same tab // Signup completes (paid checkout or free activation) before the magic
// before the magic link can be clicked. Issue a short-lived, payment-only // link is clicked, so issue a short-lived, payment-only bridge cookie
// bridge cookie so /api/helcim/initialize-payment accepts the request. // that lets /api/helcim/initialize-payment and /api/helcim/subscription
if (body.contributionAmount > 0) { // identify the member without a verified auth session.
setPaymentBridgeCookie(event, member) setPaymentBridgeCookie(event, member)
}
return { return {
success: true, success: true,

View file

@ -203,6 +203,10 @@ export default defineEventHandler(async (event) => {
} }
const memberCadence = member.billingCadence || 'monthly'; 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) { if (body.cadence && body.cadence !== memberCadence) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
@ -250,7 +254,7 @@ export default defineEventHandler(async (event) => {
}; };
} catch (error) { } catch (error) {
if (error.statusCode) throw error; if (error.statusCode) throw error;
console.error("Error updating contribution tier:", error); console.error("Error updating contribution amount:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "An unexpected error occurred", statusMessage: "An unexpected error occurred",

View file

@ -86,9 +86,7 @@ export async function getOidcProvider() {
}, },
features: { features: {
devInteractions: { devInteractions: { enabled: false },
enabled: process.env.NODE_ENV !== "production",
},
revocation: { enabled: true }, revocation: { enabled: true },
rpInitiatedLogout: { rpInitiatedLogout: {
enabled: true, enabled: true,

View file

@ -0,0 +1,142 @@
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
})
})
})