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,6 +154,7 @@
securely securely
</p> </p>
<div class="consent-block">
<label class="consent-field"> <label class="consent-field">
<input <input
v-model="form.createAccount" v-model="form.createAccount"
@ -165,6 +166,7 @@
<p class="field-hint consent-hint"> <p class="field-hint consent-hint">
Guest accounts let you view your tickets and register faster next time. We won't add you to member communications. Guest accounts let you view your tickets and register faster next time. We won't add you to member communications.
</p> </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 <div
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300" class="w-full rounded-full h-2"
:style="`width: ${uploadProgress}%`" style="background: var(--surface)"
>
<div
class="h-2 rounded-full transition-all duration-300"
: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
})
})
})