From a9312c423b9ac8d0e3859ffb3141ecc919717d21 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 24 May 2026 22:17:19 +0100 Subject: [PATCH 1/4] fix(admin): series Delete button actually deletes the series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /admin/series Delete handler only PUT-unlinked each event and never called the DELETE /api/admin/series/[id] endpoint, so the series document persisted (a no-op for empty series). Replace the redundant per-event loop with a single DELETE call — the endpoint already unlinks events server-side. Unskip the e2e delete test. --- app/pages/admin/series/index.vue | 10 +-------- e2e/admin-series.spec.js | 35 +++++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/pages/admin/series/index.vue b/app/pages/admin/series/index.vue index c558d3a..af961bd 100644 --- a/app/pages/admin/series/index.vue +++ b/app/pages/admin/series/index.vue @@ -604,15 +604,7 @@ const deleteSeries = (series) => { confirmAction.execute = async () => { confirmAction.running = true try { - for (const event of series.events) { - await $fetch(`/api/admin/events/${event.id}`, { - method: 'PUT', - body: { - ...event, - series: { isSeriesEvent: false, id: '', title: '', description: '', type: 'workshop_series', position: 1, totalEvents: null }, - }, - }) - } + await $fetch(`/api/admin/series/${series.id}`, { method: 'DELETE' }) confirmAction.show = false await refresh() } catch (error) { diff --git a/e2e/admin-series.spec.js b/e2e/admin-series.spec.js index ed0f6d8..f6d7b43 100644 --- a/e2e/admin-series.spec.js +++ b/e2e/admin-series.spec.js @@ -57,9 +57,34 @@ test.describe('Admin series CRUD', () => { await expect(editedCard).toContainText(editedDescription, { timeout: 10000 }) }) - // Delete is skipped: the series-management page's "Delete" button only - // unlinks events from the series via PUT /api/admin/events/:id; it does - // not call DELETE /api/admin/series/:id, so the series record remains. - // No UI affordance currently exists to remove an empty series. - test.skip('delete a series', async () => {}) + test('delete a series', async ({ adminPage }) => { + const suffix = Date.now().toString().slice(-6) + const title = `e2e-series-del-${suffix}` + + // --- Create the series to delete --- + await adminPage.goto('/admin/series/create') + await expect(adminPage.locator('h1')).toContainText('Create New Series') + await adminPage + .getByPlaceholder('e.g., Cooperative Game Development Fundamentals') + .fill(title) + await adminPage + .getByPlaceholder('Describe what the series covers and its goals') + .fill('e2e delete-me series') + await adminPage.getByRole('button', { name: 'Create Series' }).click() + await adminPage.waitForURL('**/admin/series', { timeout: 15000 }) + + const card = adminPage.locator('.series-card', { hasText: title }) + await expect(card).toBeVisible({ timeout: 10000 }) + + // --- Delete (card button → confirm modal) --- + await card.getByRole('button', { name: 'Delete' }).click() + const confirmModal = adminPage.locator('.modal-overlay', { hasText: 'Delete Series' }) + await expect(confirmModal).toBeVisible() + await confirmModal.getByRole('button', { name: 'Delete', exact: true }).click() + + // --- Series is gone --- + await expect(adminPage.locator('.series-card', { hasText: title })).toHaveCount(0, { + timeout: 10000, + }) + }) }) From dac423afcda19adc1f4190e4c28ce5ca5c361e9d Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 24 May 2026 22:17:24 +0100 Subject: [PATCH 2/4] fix(email): gate resend wrappers behind ALLOW_DEV_TEST_ENDPOINTS All five resend.js send wrappers (registration, cancellation, waitlist, series pass, welcome) dispatched live in dev. Add a skipEmailInDev guard mirroring the gate in pre-registrants/invite.post.js so dev runs and e2e don't fire real Resend sends. Also add the monthly-onboarding Slack-timing line to the welcome email. Unit-tested. --- server/utils/resend.js | 33 ++++++++++++ tests/server/utils/resend.test.js | 85 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/server/utils/resend.test.js diff --git a/server/utils/resend.js b/server/utils/resend.js index 528ab94..6b69505 100644 --- a/server/utils/resend.js +++ b/server/utils/resend.js @@ -2,6 +2,17 @@ import { Resend } from "resend"; const resend = new Resend(useRuntimeConfig().resendApiKey); +// In dev/test runs (ALLOW_DEV_TEST_ENDPOINTS=true) skip live email dispatch so +// local flows and e2e don't fire real Resend sends. Mirrors the gate in +// server/api/admin/pre-registrants/invite.post.js. +const skipEmailInDev = (label, to) => { + if (process.env.ALLOW_DEV_TEST_ENDPOINTS === "true") { + console.log(`[resend] DEV MODE — skipping ${label}`, { to }); + return true; + } + return false; +}; + const formatEventDate = (dateString, timeZone = "America/Toronto") => { const date = new Date(dateString); return new Intl.DateTimeFormat("en-US", { @@ -58,6 +69,10 @@ Paid: $${registration.amountPaid.toFixed(2)} CAD`; ticketSection = "\nThis event is free for Ghost Guild members.\n"; } + if (skipEmailInDev("registration email", registration.email)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -96,6 +111,10 @@ We look forward to seeing you there!`, export async function sendEventCancellationEmail(registration, eventData) { const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; + if (skipEmailInDev("cancellation email", registration.email)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -129,6 +148,10 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) { const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`; + if (skipEmailInDev("waitlist notification email", waitlistEntry.email)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -188,6 +211,10 @@ export async function sendSeriesPassConfirmation(options) { }) .join("\n\n"); + if (skipEmailInDev("series pass confirmation email", to)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -226,6 +253,10 @@ ${eventList}`, export async function sendWelcomeEmail(member) { const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; + if (skipEmailInDev("welcome email", member.email)) { + return { success: true, skipped: true }; + } + try { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", @@ -238,6 +269,8 @@ Welcome to Ghost Guild! You're now part of the ${member.circle} circle. Sign in to your dashboard to get started: ${baseUrl}/member/dashboard +Your Slack invitation arrives in our monthly onboarding waves — there may be a short wait. + If you have questions, just reply to this email.`, }); diff --git a/tests/server/utils/resend.test.js b/tests/server/utils/resend.test.js new file mode 100644 index 0000000..dbbbeb1 --- /dev/null +++ b/tests/server/utils/resend.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import { + sendEventRegistrationEmail, + sendEventCancellationEmail, + sendWaitlistNotificationEmail, + sendSeriesPassConfirmation, + sendWelcomeEmail, +} from '../../../server/utils/resend.js' + +// Hoisted spy so the resend mock and the assertions share one reference. +const { sendSpy } = vi.hoisted(() => ({ sendSpy: vi.fn() })) + +vi.mock('resend', () => ({ + Resend: class { + constructor() { + this.emails = { send: sendSpy } + } + }, +})) + +const eventData = { + title: 'Co-op Basics', + startDate: '2026-06-01T17:00:00.000Z', + endDate: '2026-06-01T18:00:00.000Z', + location: 'Online', + slug: 'co-op-basics', +} + +const registration = { email: 'reg@example.com', name: 'Reg', ticketType: 'member', amountPaid: 0 } + +const seriesPassOptions = { + to: 'pass@example.com', + name: 'Pass Holder', + series: { title: 'Workshop Series' }, + ticket: { type: 'member', price: 0, currency: 'CAD', isFree: true }, + events: [eventData], + paymentId: null, +} + +const member = { email: 'welcome@example.com', name: 'New Member', circle: 'Community' } + +describe('resend email wrappers — ALLOW_DEV_TEST_ENDPOINTS gate', () => { + beforeEach(() => { + sendSpy.mockReset() + sendSpy.mockResolvedValue({ data: { id: 'email_1' }, error: null }) + }) + afterEach(() => { + delete process.env.ALLOW_DEV_TEST_ENDPOINTS + }) + + describe('when ALLOW_DEV_TEST_ENDPOINTS=true', () => { + beforeEach(() => { + process.env.ALLOW_DEV_TEST_ENDPOINTS = 'true' + }) + + const cases = [ + ['registration', () => sendEventRegistrationEmail(registration, eventData)], + ['cancellation', () => sendEventCancellationEmail(registration, eventData)], + ['waitlist', () => sendWaitlistNotificationEmail(registration, eventData)], + ['series pass', () => sendSeriesPassConfirmation(seriesPassOptions)], + ['welcome', () => sendWelcomeEmail(member)], + ] + + it.each(cases)('skips the live send for %s', async (_label, call) => { + const result = await call() + expect(result).toEqual({ success: true, skipped: true }) + expect(sendSpy).not.toHaveBeenCalled() + }) + }) + + describe('when the gate is off', () => { + it('dispatches a live send and returns success', async () => { + const result = await sendWelcomeEmail(member) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(result).toEqual({ success: true, data: { id: 'email_1' } }) + }) + + it('includes the monthly-onboarding Slack-timing line in the welcome email', async () => { + await sendWelcomeEmail(member) + const sent = sendSpy.mock.calls[0][0] + expect(sent.text).toContain('monthly onboarding waves') + }) + }) +}) From 53f81b3605a4563b789cacd10c4177013a820c04 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 24 May 2026 22:18:26 +0100 Subject: [PATCH 3/4] refactor(css): extract .tint-candle / .tint-ember utility classes The candle tint (color-mix accent fill + matching solid border) was inlined as style="" in five spots across SeriesPassPurchase and EventSeriesTicketCard. Promote to .tint-candle / .tint-ember utility classes in main.css and replace the inline styles with the class. --- app/assets/css/main.css | 11 +++++++++++ app/components/EventSeriesTicketCard.vue | 12 ++++-------- app/components/SeriesPassPurchase.vue | 3 +-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 9ee189f..abb9d3c 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -248,6 +248,17 @@ p a, blockquote a { border-color: var(--border); } +/* ---- ACCENT TINT BLOCKS ---- */ +/* Faint accent fill + matching solid border. Replaces inline color-mix styles. */ +.tint-candle { + background: color-mix(in srgb, var(--candle) 15%, transparent); + border: 1px solid var(--candle); +} +.tint-ember { + background: color-mix(in srgb, var(--ember) 15%, transparent); + border: 1px solid var(--ember); +} + /* ---- SEGMENTED CONTROL (flush dashed-border groups) ---- */ /* Negative-margin overlap: every item keeps all 4 borders, siblings overlap by 1px, active item paints on top via z-index. */ diff --git a/app/components/EventSeriesTicketCard.vue b/app/components/EventSeriesTicketCard.vue index 1340f3c..b475430 100644 --- a/app/components/EventSeriesTicketCard.vue +++ b/app/components/EventSeriesTicketCard.vue @@ -63,8 +63,7 @@ class="flex items-start gap-3 p-3" >
{{ index + 1 }}
@@ -86,8 +85,7 @@
@@ -103,8 +101,7 @@
@@ -162,8 +159,7 @@
diff --git a/app/components/SeriesPassPurchase.vue b/app/components/SeriesPassPurchase.vue index fff5fd4..8569ed5 100644 --- a/app/components/SeriesPassPurchase.vue +++ b/app/components/SeriesPassPurchase.vue @@ -100,8 +100,7 @@
Date: Mon, 25 May 2026 13:28:54 +0100 Subject: [PATCH 4/4] Design and UI tweaks --- .impeccable/design.json | 302 ++++++++++++++++++++++++++++++++++++++++ app/pages/index.vue | 108 +++++++++----- app/pages/join.vue | 29 ++-- docs/BACKLOG.md | 46 ++++-- 4 files changed, 424 insertions(+), 61 deletions(-) create mode 100644 .impeccable/design.json diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..a29587c --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,302 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-05-24T23:40:30.515Z", + "title": "Design System: Ghost Guild", + "extensions": { + "colorMeta": { + "candle": { + "role": "primary", + "displayName": "Candle Gold", + "canonical": "oklch(0.44 0.085 80)", + "tonalRamp": [ + "oklch(0.18 0.053 80)", + "oklch(0.29 0.069 80)", + "oklch(0.4 0.08 80)", + "oklch(0.51 0.085 80)", + "oklch(0.62 0.084 80)", + "oklch(0.73 0.078 80)", + "oklch(0.84 0.066 80)", + "oklch(0.95 0.048 80)" + ] + }, + "ember": { + "role": "secondary", + "displayName": "Ember Rust", + "canonical": "oklch(0.46 0.11 47)", + "tonalRamp": [ + "oklch(0.18 0.069 47)", + "oklch(0.29 0.09 47)", + "oklch(0.4 0.103 47)", + "oklch(0.51 0.11 47)", + "oklch(0.62 0.109 47)", + "oklch(0.73 0.1 47)", + "oklch(0.84 0.085 47)", + "oklch(0.95 0.062 47)" + ] + }, + "c-community": { + "role": "tertiary", + "displayName": "Community Clay", + "canonical": "oklch(0.44 0.06 40)", + "tonalRamp": [ + "oklch(0.18 0.038 40)", + "oklch(0.29 0.049 40)", + "oklch(0.4 0.056 40)", + "oklch(0.51 0.06 40)", + "oklch(0.62 0.059 40)", + "oklch(0.73 0.055 40)", + "oklch(0.84 0.046 40)", + "oklch(0.95 0.034 40)" + ] + }, + "c-practitioner": { + "role": "tertiary", + "displayName": "Practitioner Slate", + "canonical": "oklch(0.36 0.045 230)", + "tonalRamp": [ + "oklch(0.18 0.028 230)", + "oklch(0.29 0.037 230)", + "oklch(0.4 0.042 230)", + "oklch(0.51 0.045 230)", + "oklch(0.62 0.044 230)", + "oklch(0.73 0.041 230)", + "oklch(0.84 0.035 230)", + "oklch(0.95 0.025 230)" + ] + }, + "green": { + "role": "tertiary", + "displayName": "Guild Green", + "canonical": "oklch(0.5 0.08 135)", + "tonalRamp": [ + "oklch(0.18 0.05 135)", + "oklch(0.29 0.065 135)", + "oklch(0.4 0.075 135)", + "oklch(0.51 0.08 135)", + "oklch(0.62 0.079 135)", + "oklch(0.73 0.073 135)", + "oklch(0.84 0.062 135)", + "oklch(0.95 0.045 135)" + ] + }, + "bg": { + "role": "neutral", + "displayName": "Cream Paper", + "canonical": "oklch(0.95 0.012 90)", + "tonalRamp": [ + "oklch(0.18 0.008 90)", + "oklch(0.29 0.01 90)", + "oklch(0.4 0.011 90)", + "oklch(0.51 0.012 90)", + "oklch(0.62 0.012 90)", + "oklch(0.73 0.011 90)", + "oklch(0.84 0.009 90)", + "oklch(0.95 0.007 90)" + ] + }, + "surface": { + "role": "neutral", + "displayName": "Surface Tan", + "canonical": "oklch(0.9 0.025 88)", + "tonalRamp": [ + "oklch(0.18 0.016 88)", + "oklch(0.29 0.02 88)", + "oklch(0.4 0.023 88)", + "oklch(0.51 0.025 88)", + "oklch(0.62 0.025 88)", + "oklch(0.73 0.023 88)", + "oklch(0.84 0.019 88)", + "oklch(0.95 0.014 88)" + ] + }, + "text": { + "role": "neutral", + "displayName": "Ink", + "canonical": "oklch(0.24 0.02 70)", + "tonalRamp": [ + "oklch(0.18 0.013 70)", + "oklch(0.29 0.016 70)", + "oklch(0.4 0.019 70)", + "oklch(0.51 0.02 70)", + "oklch(0.62 0.02 70)", + "oklch(0.73 0.018 70)", + "oklch(0.84 0.015 70)", + "oklch(0.95 0.011 70)" + ] + } + }, + "typographyMeta": { + "display": { + "displayName": "Display", + "purpose": "Hero proclamations only; one per page, commands the fold." + }, + "headline": { + "displayName": "Headline", + "purpose": "Section and card headings, e.g. the circle metaphors." + }, + "title": { + "displayName": "Title", + "purpose": "Smaller serif headings inside dense blocks." + }, + "body": { + "displayName": "Body", + "purpose": "All prose and UI text; Commit Mono, measure capped 65-75ch." + }, + "label": { + "displayName": "Label", + "purpose": "Uppercase kickers, field labels, badges; the faint structural voice." + } + }, + "shadows": [ + { + "name": "popover-lift", + "value": "0 4px 12px rgba(0,0,0,0.12)", + "purpose": "The only shadow in the system. Floating portaled overlays (select menus, dropdowns) only; never in-page surfaces." + } + ], + "motion": [ + { + "name": "state", + "value": "0.15s ease", + "purpose": "Default button/border state transitions." + }, + { + "name": "reveal", + "value": "0.2s ease", + "purpose": "Hover nudges and lifts (transform only; respects prefers-reduced-motion)." + } + ], + "breakpoints": [ + { + "name": "content", + "value": "768px", + "purpose": "Multi-column content rows collapse to a single column." + }, + { + "name": "page-collapse", + "value": "1024px", + "purpose": "Sidebar navigation collapses to a Menu disclosure (--page-collapse)." + } + ] + }, + "components": [ + { + "name": "Primary Button", + "kind": "button", + "refersTo": "button-primary", + "description": "The single call-to-action treatment; solid candle gold with a solid border.", + "html": "", + "css": ".ds-btn-primary { font-family: \"Commit Mono\", monospace; font-size: 12px; letter-spacing: 0.04em; padding: 7px 18px; background: var(--candle); color: var(--bg); border: 1px solid var(--candle); border-radius: 0; cursor: pointer; transition: background 0.15s, border-color 0.15s; } .ds-btn-primary:hover { background: var(--candle-dim); border-color: var(--candle-dim); } .ds-btn-primary:focus-visible { outline: 2px dashed var(--candle); outline-offset: 3px; }" + }, + { + "name": "Default Button", + "kind": "button", + "refersTo": "button-default", + "description": "Neutral action; dashed border on cream, tonal fill on hover.", + "html": "", + "css": ".ds-btn { font-family: \"Commit Mono\", monospace; font-size: 12px; letter-spacing: 0.04em; padding: 7px 18px; background: var(--bg); color: var(--text); border: 1px dashed var(--border); border-radius: 0; cursor: pointer; transition: background 0.15s, border-color 0.15s; } .ds-btn:hover { background: var(--surface-hover); border-color: var(--border-d); } .ds-btn:focus-visible { outline: 2px dashed var(--candle); outline-offset: 3px; }" + }, + { + "name": "Danger Button", + "kind": "button", + "refersTo": "button-danger", + "description": "Destructive action; ember text and border, inverts to ember fill on hover.", + "html": "", + "css": ".ds-btn-danger { font-family: \"Commit Mono\", monospace; font-size: 12px; letter-spacing: 0.04em; padding: 7px 18px; background: var(--bg); color: var(--ember); border: 1px dashed var(--ember); border-radius: 0; cursor: pointer; transition: background 0.15s, color 0.15s; } .ds-btn-danger:hover { background: var(--ember); color: var(--bg); border-style: solid; }" + }, + { + "name": "Text Field", + "kind": "input", + "refersTo": "input", + "description": "Editable surface; dashed border that goes solid candle on focus.", + "html": "", + "css": ".ds-field span { display: block; font-family: \"Commit Mono\", monospace; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-faint); margin-bottom: 3px; } .ds-field input { width: 100%; box-sizing: border-box; font-family: \"Commit Mono\", monospace; font-size: 13px; padding: 5px 8px; color: var(--text-bright); background: var(--input-bg); border: 1px dashed var(--border); border-radius: 0; outline: none; } .ds-field input:focus { border-color: var(--candle); border-style: solid; }" + }, + { + "name": "Circle Badge", + "kind": "chip", + "refersTo": "badge", + "description": "Membership-tier identity tag; dashed border in the circle hue. Not a generic tag.", + "html": "Practitioner", + "css": ".ds-badge { display: inline-block; font-family: \"Commit Mono\", monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; padding: 2px 8px; color: var(--c-practitioner); border: 1px dashed rgba(42,70,80,0.35); border-radius: 0; }" + }, + { + "name": "Sidebar Nav Item", + "kind": "nav", + "refersTo": "navigation", + "description": "Left-rail navigation entry; tonal fill when active, mono type.", + "html": "", + "css": ".ds-nav { display: flex; flex-direction: column; font-family: \"Commit Mono\", monospace; font-size: 13px; border-right: 1px dashed var(--border); width: 200px; } .ds-nav-item { padding: 6px 12px; color: var(--text); text-decoration: none; transition: background 0.15s, color 0.15s; } .ds-nav-item:hover { background: var(--surface-hover); } .ds-nav-item.is-active { background: var(--surface); color: var(--text-bright); }" + }, + { + "name": "Hero CTA Broadside", + "kind": "custom", + "refersTo": "hero-cta", + "description": "Signature: oversized serif headline with one ember word, a solid primary block, and demoted text links.", + "html": "

Game developers explore cooperative models.

", + "css": ".ds-hero h1 { font-family: \"Brygada 1918\", serif; font-size: clamp(40px, 6.5vw, 80px); font-weight: 600; line-height: 1.04; letter-spacing: -0.022em; color: var(--text-bright); max-width: 16ch; margin: 0 0 28px; } .ds-hero h1 span { color: var(--ember); } .ds-hero-links { display: flex; align-items: center; gap: 24px; } .ds-hero-primary { font-family: \"Commit Mono\", monospace; font-size: 14px; padding: 13px 30px; background: var(--candle); color: var(--bg); border: 1px solid var(--candle); text-decoration: none; transition: background 0.2s, transform 0.2s; } .ds-hero-primary:hover { background: var(--candle-dim); transform: translateY(-2px); } .ds-hero-link { font-family: \"Commit Mono\", monospace; font-size: 14px; color: var(--candle); padding: 4px 0; border-bottom: 1px dashed var(--candle-faint); text-decoration: none; }" + }, + { + "name": "Parchment Inset", + "kind": "card", + "refersTo": null, + "description": "Signature: inverted dark block for a featured passage; pinned to the same values in light and dark mode.", + "html": "", + "css": ".ds-parch { background: #2a2015; color: #ede4d0; padding: 28px 32px; border-radius: 0; } .ds-parch-label { font-family: \"Commit Mono\", monospace; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: #c4a448; margin-bottom: 12px; } .ds-parch h2 { font-family: \"Brygada 1918\", serif; font-size: 22px; font-weight: 500; margin: 0 0 10px; } .ds-parch p { font-family: \"Commit Mono\", monospace; font-size: 13px; line-height: 1.7; color: #b8ae98; margin: 0; }" + } + ], + "narrative": { + "northStar": "The Text Adventure Hall", + "overview": "Ghost Guild is a world built from words: a monospace text adventure rendered on cream paper. Rooms instead of pages, prose instead of chrome, structure drawn in dashed ink rather than boxes and shadows. Two typefaces (Brygada 1918 serif for display, Commit Mono for everything else) do all the work over a warm cream ground with a faint noise texture. Candlelight gold is the single voice of action; ember rust is the rare focal emphasis. Depth is tonal layering, never drop shadows or glass.", + "keyCharacteristics": [ + "Two fonts only: Brygada 1918 (serif display) + Commit Mono (everything else).", + "Cream paper ground with a 2.5% noise overlay; warm, tinted neutrals throughout.", + "Dashed borders for structure, solid borders for inputs and active state.", + "Square corners everywhere (border-radius: 0).", + "Flat by default: depth comes from tonal layering, not shadows.", + "Candle gold is the only call-to-action color; ember rust is the rare accent." + ], + "rules": [ + { + "name": "The Candlelight Rule", + "body": "Candle gold is the only color permitted on a call-to-action. If something is gold, it acts. Nothing decorative wears gold.", + "section": "colors" + }, + { + "name": "The Single Ember Rule", + "body": "Ember rust appears at most once per view as emphasis. Two embers cancel each other out; the rarity is the point.", + "section": "colors" + }, + { + "name": "The Two-Font Rule", + "body": "Brygada 1918 and Commit Mono. That is the entire type system. A third family is forbidden.", + "section": "typography" + }, + { + "name": "The Flat Paper Rule", + "body": "Surfaces sit flat on the page. If you reach for box-shadow on an in-page element, use a dashed border or a tonal surface step instead. Shadows belong only to floating popovers.", + "section": "elevation" + } + ], + "dos": [ + "Do use exactly two typefaces: Brygada 1918 for display/headings, Commit Mono for everything else.", + "Do draw structure with 1px dashed borders, and switch borders to solid only for inputs and active state.", + "Do keep every corner square (border-radius: 0).", + "Do reserve Candle Gold (#7a5a10) for actions and Ember Rust (#8a4420) for a single focal emphasis per view.", + "Do convey depth through tonal layering (cream -> surface -> parchment) and the noise overlay, not shadows.", + "Do keep text contrast at WCAG AA: Ink Dim (#5a5040) and Ink Faint (#665c4b) were tuned to pass on cream.", + "Do use fluid clamp() spacing and type so the layout breathes on large viewports." + ], + "donts": [ + "Don't introduce a third typeface. (The Two-Font Rule.)", + "Don't round corners anywhere.", + "Don't put box-shadow on in-page surfaces; shadows belong only to floating popovers.", + "Don't use a border-left/border-right greater than 1px as a colored accent stripe.", + "Don't use gradient text or background-clip: text; emphasis comes from weight, size, or a single ember word.", + "Don't use purple/blue gradients, glassmorphism, neon-on-dark, or identical icon-title card grids.", + "Don't reach for CSS hacks: no negative margins, no magic numbers, no fragile workarounds.", + "Don't put neutral gray text on the parchment block or any colored surface; use the parchment text tokens.", + "Don't use UToggle; use USwitch (Nuxt UI 4)." + ] + } +} diff --git a/app/pages/index.vue b/app/pages/index.vue index e4b1487..8343d6a 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -2,7 +2,7 @@
-

Ghost Guild is where game developers explore cooperative models.

+

Ghost Guild is where game developers explore cooperative models.

Resources, events, and a community of people figuring it out. Three circles, pay what you can. @@ -208,51 +208,68 @@ const formatDate = (event) => { diff --git a/app/pages/join.vue b/app/pages/join.vue index df1934b..b8b7b8b 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -3,7 +3,7 @@