From 4ad9d8d3102c3219ca22095681ee0c6b6c220a3d Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Sun, 9 Aug 2026 14:38:13 +0200 Subject: [PATCH 01/29] feat(nav): add "Get updates" newsletter entry to the header Adds a "Get updates" nav item (mail icon) between "Get involved" and Donate, pointing at /join for now. Rendered leading-icon on desktop (WideNavbar) and trailing-icon on mobile (NarrowNavbar) so the label lines up with the other items. Also centers the nav row (align-items) so the taller item does not push the others up. --- messages/en.json | 1 + .../navbar/narrow/NarrowNavbar.svelte | 16 +++++++++++ src/lib/components/navbar/navItems.ts | 3 +++ .../navbar/universal/UniversalNavbar.svelte | 27 +++++++++++++++++++ .../components/navbar/wide/WideNavbar.svelte | 7 +++++ 5 files changed, 54 insertions(+) diff --git a/messages/en.json b/messages/en.json index c2248b9bd..87b51d286 100644 --- a/messages/en.json +++ b/messages/en.json @@ -14,6 +14,7 @@ "header_about": "About", "header_community": "Community", "header_get_involved": "Get involved", + "header_get_updates": "Get updates", "header_our_strategy": "Our strategy", "header_why": "Why this matters", "header_find_group": "Find your local group", diff --git a/src/lib/components/navbar/narrow/NarrowNavbar.svelte b/src/lib/components/navbar/narrow/NarrowNavbar.svelte index ba92dd742..286f9693f 100644 --- a/src/lib/components/navbar/narrow/NarrowNavbar.svelte +++ b/src/lib/components/navbar/narrow/NarrowNavbar.svelte @@ -6,6 +6,7 @@ import Logo from '$lib/components/logo.svelte' import { localizeHref } from '$lib/paraglide/runtime' import Menu from '@lucide/svelte/icons/menu' + import Mail from '@lucide/svelte/icons/mail' import X from '@lucide/svelte/icons/x' import ChevronDown from '@lucide/svelte/icons/chevron-down' import { onMount } from 'svelte' @@ -82,6 +83,15 @@ {/each} + {:else if item.mail} + + {item.label} + + {:else} diff --git a/src/lib/components/navbar/wide/WideNavbar.svelte b/src/lib/components/navbar/wide/WideNavbar.svelte index 94f063236..f0093cb6d 100644 --- a/src/lib/components/navbar/wide/WideNavbar.svelte +++ b/src/lib/components/navbar/wide/WideNavbar.svelte @@ -2,6 +2,8 @@ import UniversalNavbar from '../universal/UniversalNavbar.svelte' import Navlink from '../Navlink.svelte' import NavDropdown from '../NavDropdown.svelte' + import MailIcon from '@lucide/svelte/icons/mail' + import LinkWithoutIcon from '$lib/components/LinkWithoutIcon.svelte' import type { NavItem } from '../navItems' interface Props { @@ -17,6 +19,11 @@ {#each items as item, i (item.label)} {#if item.children} + {:else if item.mail} + + + {item.label} + {:else} {item.label} From 08caefe7641b278dde9cdf13d73f8cd087c432c8 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Sun, 9 Aug 2026 14:42:52 +0200 Subject: [PATCH 02/29] feat(newsletter): route the homepage subscribe box to the /join movement signup The homepage newsletter box posted straight to Substack, which is a different thing from the CRM movement newsletter. Adds a joinHandoff mode to NewsletterSignup that instead sends the visitor to /join with the email prefilled (?subscribe-email=...), wiring OnboardingFlow to consume the previously-no-op initialEmail. Collage/learn keep the Substack behaviour. --- src/lib/components/Home.svelte | 2 +- src/lib/components/NewsletterSignup.svelte | 15 ++++++++++++++- .../components/onboarding/OnboardingFlow.svelte | 11 ++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/lib/components/Home.svelte b/src/lib/components/Home.svelte index 5dd5581b8..172f1c88b 100644 --- a/src/lib/components/Home.svelte +++ b/src/lib/components/Home.svelte @@ -52,7 +52,7 @@ - + diff --git a/src/lib/components/navbar/wide/WideNavbar.svelte b/src/lib/components/navbar/wide/WideNavbar.svelte index f0093cb6d..5d75c02d5 100644 --- a/src/lib/components/navbar/wide/WideNavbar.svelte +++ b/src/lib/components/navbar/wide/WideNavbar.svelte @@ -4,6 +4,8 @@ import NavDropdown from '../NavDropdown.svelte' import MailIcon from '@lucide/svelte/icons/mail' import LinkWithoutIcon from '$lib/components/LinkWithoutIcon.svelte' + import { page } from '$app/state' + import { localizeHref } from '$lib/paraglide/runtime' import type { NavItem } from '../navItems' interface Props { @@ -20,7 +22,11 @@ {#if item.children} {:else if item.mail} - + {@const active = !!item.href && localizeHref(page.url.pathname) === localizeHref(item.href)} + {item.label} diff --git a/src/posts/subscribe.md b/src/posts/subscribe.md new file mode 100644 index 000000000..7b707583b --- /dev/null +++ b/src/posts/subscribe.md @@ -0,0 +1,20 @@ +--- +title: Get updates +metaTitle: Get PauseAI updates +description: Sign up to get updates from PauseAI. +--- + + + +Sign up to get PauseAI's campaign updates, actions, and news from your local group. You can also choose to get more involved. + + From ef31caa0bb8fe0503fd83c33b6bddc1b2c97afe8 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Sun, 9 Aug 2026 16:58:43 +0200 Subject: [PATCH 05/29] copy: /subscribe h1 -> "Get PauseAI updates" --- src/posts/subscribe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posts/subscribe.md b/src/posts/subscribe.md index 7b707583b..b2ecf8658 100644 --- a/src/posts/subscribe.md +++ b/src/posts/subscribe.md @@ -1,5 +1,5 @@ --- -title: Get updates +title: Get PauseAI updates metaTitle: Get PauseAI updates description: Sign up to get updates from PauseAI. --- From cda0dc91032aa80407ad5b5d07fd40755b11a1ee Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Wed, 12 Aug 2026 13:28:26 +0200 Subject: [PATCH 06/29] feat(subscribe): dedicated newsletter form with local-group + Substack opt-ins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks /subscribe from reusing the full /join flow into a lightweight single-step signup, per Jonathan's design review: - SubscribeFlow: name/email/country/city, optional local-group and Substack opt-ins, privacy-policy microcopy (no consent checkbox — signing up is the consent) - global newsletter by default; chapter sharing only on the explicit local-group tick. Decouples chapter sharing from the privacy consent on the subscribe path; the /join form still bundles the two, untouched. - "do more" continues in place into the intent step instead of linking to /join, carrying the created record id so it updates rather than creating a duplicate - the continuation hides the email opt-in cards and second consent, so it can only add involvement, never re-ask or silently undo the subscribe-time choices. Chapter sharing is preserved on update, escalating only for Volunteer/Lead. - rename the nav entry "Get updates" -> "Subscribe" --- messages/en.json | 2 +- src/lib/components/navbar/navItems.ts | 2 +- .../onboarding/OnboardingFlow.svelte | 121 ++++--- .../onboarding/SubscribeFlow.svelte | 318 ++++++++++++++++++ src/posts/subscribe.md | 10 +- .../embed/onboarding-form/+page.server.ts | 47 ++- 6 files changed, 438 insertions(+), 62 deletions(-) create mode 100644 src/lib/components/onboarding/SubscribeFlow.svelte diff --git a/messages/en.json b/messages/en.json index fe4208385..2dcd07803 100644 --- a/messages/en.json +++ b/messages/en.json @@ -14,7 +14,7 @@ "header_about": "About", "header_community": "Community", "header_get_involved": "Get involved", - "header_get_updates": "Get updates", + "header_subscribe": "Subscribe", "home_newsletter_heading": "Get updates", "home_newsletter_button": "Sign up", "header_our_strategy": "Our strategy", diff --git a/src/lib/components/navbar/navItems.ts b/src/lib/components/navbar/navItems.ts index c7d730761..ca826bd61 100644 --- a/src/lib/components/navbar/navItems.ts +++ b/src/lib/components/navbar/navItems.ts @@ -45,7 +45,7 @@ export function getNavItems(): NavItem[] { { label: m.header_start_group(), href: '/national-groups' } ] }, - { label: m.header_get_updates(), href: '/subscribe', mail: true }, + { label: m.header_subscribe(), href: '/subscribe', mail: true }, { label: m.header_donate(), href: '/donate', c2a: true } ] } diff --git a/src/lib/components/onboarding/OnboardingFlow.svelte b/src/lib/components/onboarding/OnboardingFlow.svelte index 78bab078a..59b0c2e5c 100644 --- a/src/lib/components/onboarding/OnboardingFlow.svelte +++ b/src/lib/components/onboarding/OnboardingFlow.svelte @@ -47,12 +47,24 @@ initialEmail = '', initialCountry = '', initialCity = '', - initialLanguages = [] as string[] + initialFullName = '', + initialLanguages = [] as string[], + initialRecordId = '', + startStep = 1, + initialKeepInformed = false }: { initialEmail?: string initialCountry?: string initialCity?: string + initialFullName?: string initialLanguages?: string[] + // The /subscribe flow creates the record up front and then hands off here + // to let people go further. It seeds the created record id and starts at + // the intent step, so this submission updates that record instead of + // creating a duplicate, and pre-sets the newsletter opt-in they already made. + initialRecordId?: string + startStep?: 1 | 2 + initialKeepInformed?: boolean } = $props() // Surface stub/live mode in the browser console when the form loads. The @@ -73,7 +85,7 @@ } }) - let step: 1 | 2 | 3 | 4 = $state(1) + let step: 1 | 2 | 3 | 4 = $state(startStep) let flowEl: HTMLDivElement | undefined = $state() // Scroll back to the top of the flow on step changes. The volunteer form @@ -91,9 +103,10 @@ let mode: 'contact' | 'browse' = $state('contact') let intent: IntentKey | null = $state(null) // Airtable record id from the step-2 submission; later submissions send it - // back so the server updates the record instead of creating another. - let recordId = $state('') - let keepInformed = $state(false) + // back so the server updates the record instead of creating another. When the + // /subscribe flow hands off, it's seeded with the record already created there. + let recordId = $state(initialRecordId) + let keepInformed = $state(initialKeepInformed) let submitting = $state(false) let browseSignedUp = $state(false) let honeypot = $state('') @@ -101,7 +114,7 @@ // Step 1: basic info (shared with the browse-mode inline signup and the // volunteer form, which pre-fills from the same state) let basics = $state({ - fullName: '', + fullName: initialFullName, email: initialEmail, country: initialCountry, city: initialCity, @@ -149,11 +162,19 @@ // user stays in the flow. let becomePayingMember = $state(false) + // The /subscribe "do more" hand-off: we start at the intent step with a record + // already created and its email opt-ins already chosen. In that mode the step + // only asks how involved they want to be — the opt-in cards and a second + // consent are hidden so it can't re-ask (or silently undo) what they already + // picked on the subscribe form. + const isContinuation = $derived(startStep === 2 && !!initialRecordId) + // GDPR data-processing consent gating every record-creating submission // (step 2 + browse). Chapter-sharing consent is not bundled here: it lives // in the optional "Keep me informed" opt-in, since we only share details - // with a local chapter when the person asks to be connected to one. - let gdprConsent = $state(false) + // with a local chapter when the person asks to be connected to one. The + // continuation already consented on the subscribe form, so it starts satisfied. + let gdprConsent = $state(startStep === 2 && !!initialRecordId) // Phone: dial code prefilled from country of residence, editable in case // their phone is from elsewhere. Submitted combined via a hidden input. @@ -486,47 +507,49 @@ {/if}

{msgs.onboarding_step2_heading}

-
- - -
- + + + + + {/if}
{#each intentOptions as option (option.key)}
- {@render gdprConsentField()} + {#if !isContinuation} + {@render gdprConsentField()} + {/if} +

+ By signing up you agree to our + Privacy Policy. We'll only use your + details to keep you updated on PauseAI, and you can unsubscribe anytime. +

+ + + +{:else if phase === 'thanks'} + +{:else} + +{/if} + + diff --git a/src/posts/subscribe.md b/src/posts/subscribe.md index b2ecf8658..5d943c6bd 100644 --- a/src/posts/subscribe.md +++ b/src/posts/subscribe.md @@ -1,13 +1,13 @@ --- -title: Get PauseAI updates -metaTitle: Get PauseAI updates +title: Subscribe +metaTitle: Subscribe to PauseAI description: Sign up to get updates from PauseAI. --- -Sign up to get PauseAI's campaign updates, actions, and news from your local group. You can also choose to get more involved. +Sign up to our newsletter and receive updates about how to support our movement, from getting involved in the current campaign to taking part in a local event. - + diff --git a/src/routes/embed/onboarding-form/+page.server.ts b/src/routes/embed/onboarding-form/+page.server.ts index bb6c0bce3..70e2f6597 100644 --- a/src/routes/embed/onboarding-form/+page.server.ts +++ b/src/routes/embed/onboarding-form/+page.server.ts @@ -92,14 +92,32 @@ export const actions: Actions = { const existingRecordId = getString(data, 'record_id') // The volunteer detail fields are only present on the step-3 form post. const hasVolunteerDetails = data.get('volunteer_details') === 'on' - - if (!fullName || !email || !country || !city) { + // The lightweight /subscribe newsletter form. It only strictly needs an + // email; name and city are optional, and country matters only when the + // person opts into local-chapter updates (it's what routes them to a + // chapter). It also decouples chapter sharing from the privacy consent: + // the full /join form keeps bundling the two, this one shares only on an + // explicit tick. + const isSubscribeForm = data.get('subscribe_form') === '1' + const wantsChapter = data.get('chapter_share') === 'on' + + if (isSubscribeForm) { + if (!email) { + return fail(400, { message: 'Please enter your email address.' }) + } + if (wantsChapter && !country) { + return fail(400, { + message: 'Please select your country so we can connect you with your local group.' + }) + } + } else if (!fullName || !email || !country || !city) { return fail(400, { message: 'Please fill in your name, email, country and city.' }) } if (!/^\S+@\S+\.\S+$/.test(email)) { return fail(400, { message: 'Please enter a valid email address.' }) } - if (!COUNTRIES.includes(country)) { + // Country is optional on the subscribe form, so only validate it when set. + if (country && !COUNTRIES.includes(country)) { return fail(400, { message: 'Please select a country from the list.' }) } if (!isIntent(intent)) { @@ -111,6 +129,19 @@ export const actions: Actions = { return fail(400, { message: 'Please agree to the data processing consent to continue.' }) } + // Chapter sharing: + // - /join create bundles it into the single privacy checkbox -> true + // - /subscribe create shares only when they tick the local-updates box + // - an update (the /subscribe "do more" hand-off, or the volunteer step) + // leaves the signup-time choice alone, except Volunteer/Lead, whose + // local involvement means they hear from a chapter regardless + let chapterShare: boolean | undefined + if (existingRecordId) { + if (intent === 'Volunteer' || intent === 'Lead') chapterShare = true + } else { + chapterShare = isSubscribeForm ? wantsChapter : true + } + const fields: FieldSet = { 'Full name': fullName, Email: email, @@ -119,10 +150,12 @@ export const actions: Actions = { Intent: intent, 'Signup source': SIGNUP_SOURCE, 'Email subscription': keepInformed, - // One required consent checkbox covers both: agreeing to the privacy - // policy and, as part of it, sharing details with the local chapter. - 'Data privacy policy agreed': true, - 'GDPR chapter share permission': true + // Signing up is itself the privacy-policy consent: the /join checkbox + // and the /subscribe microcopy both link it. + 'Data privacy policy agreed': true + } + if (chapterShare !== undefined) { + fields['GDPR chapter share permission'] = chapterShare } if (intent === 'Volunteer' && hasVolunteerDetails) { From 067e214afb5fc9ffac8fcf971abf849611f2121d Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Wed, 12 Aug 2026 14:53:36 +0200 Subject: [PATCH 07/29] fix(subscribe): address review findings on the do-more continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The continuation posted through /join's step-2 form (no subscribe_form marker), so its update hit the strict required-field validation and 400'd for any subscriber who omitted an optional field (name/city/country) — the common email-only case. Updates now skip the create-time required-field check, since they patch an existing record. - The continuation's step-2 Back button led to step 1 and its "just browse" path, which had no record_id and so created a duplicate record with chapter sharing forced true, overriding a declined opt-in. Hide Back on the continuation so that path is unreachable. - gdprConsent is no longer seeded true on the continuation (it was pre-checking visible consent boxes on reachable sub-forms); instead the continuation's step-2 submit is exempted from the consent gate, since consent was captured on the subscribe form. - SubscribeFlow's email prefill now applies once into an empty field, so a prefilled address (?subscribe-email) can be cleared to fix a typo. - Move the lookupChapter fetch into the stub branch, its only consumer; the live path never read the result. --- .../onboarding/OnboardingFlow.svelte | 19 ++++++++++++------- .../onboarding/SubscribeFlow.svelte | 16 +++++++++++----- .../embed/onboarding-form/+page.server.ts | 14 +++++++++----- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/lib/components/onboarding/OnboardingFlow.svelte b/src/lib/components/onboarding/OnboardingFlow.svelte index 59b0c2e5c..01738450b 100644 --- a/src/lib/components/onboarding/OnboardingFlow.svelte +++ b/src/lib/components/onboarding/OnboardingFlow.svelte @@ -172,9 +172,10 @@ // GDPR data-processing consent gating every record-creating submission // (step 2 + browse). Chapter-sharing consent is not bundled here: it lives // in the optional "Keep me informed" opt-in, since we only share details - // with a local chapter when the person asks to be connected to one. The - // continuation already consented on the subscribe form, so it starts satisfied. - let gdprConsent = $state(startStep === 2 && !!initialRecordId) + // with a local chapter when the person asks to be connected to one. + // Stays false so it never pre-checks a visible consent box; the continuation + // already consented on the subscribe form and is exempted at the submit gate. + let gdprConsent = $state(false) // Phone: dial code prefilled from country of residence, editable in case // their phone is from elsewhere. Submitted combined via a hidden input. @@ -577,7 +578,9 @@ - + {#if !isContinuation} + + {/if} {:else if step === 3 && !intent} diff --git a/src/lib/components/onboarding/SubscribeFlow.svelte b/src/lib/components/onboarding/SubscribeFlow.svelte index a56a3f925..3cb2e3f28 100644 --- a/src/lib/components/onboarding/SubscribeFlow.svelte +++ b/src/lib/components/onboarding/SubscribeFlow.svelte @@ -35,14 +35,20 @@ let recordId = $state('') // A newsletter signup elsewhere (e.g. the homepage box) hands off here via - // ?subscribe-email=...; that email arrives after mount, so prefill it once - // available without clobbering anything the visitor has typed. + // ?subscribe-email=...; that email arrives after mount. Apply it once into an + // empty field, then never again — so it neither clobbers typed input nor + // snaps back when the visitor clears it to fix a typo. + let prefillApplied = $state(false) $effect(() => { - if (initialEmail && !email) email = initialEmail + if (prefillApplied || !initialEmail) return + if (!email) email = initialEmail + prefillApplied = true }) const canSubmit = $derived(!submitting && !!email.trim() && !(wantsChapter && !country.trim())) + const ERROR_MESSAGE = 'Something went wrong. Please try again.' + const submit: SubmitFunction = () => { submitting = true return ({ result }) => { @@ -51,9 +57,9 @@ if (typeof result.data.recordId === 'string') recordId = result.data.recordId phase = 'thanks' } else if (result.type === 'failure') { - toast.error(String(result.data?.message ?? 'Something went wrong. Please try again.')) + toast.error(String(result.data?.message ?? ERROR_MESSAGE)) } else { - toast.error('Something went wrong. Please try again.') + toast.error(ERROR_MESSAGE) } } } diff --git a/src/routes/embed/onboarding-form/+page.server.ts b/src/routes/embed/onboarding-form/+page.server.ts index 70e2f6597..64859f51f 100644 --- a/src/routes/embed/onboarding-form/+page.server.ts +++ b/src/routes/embed/onboarding-form/+page.server.ts @@ -110,7 +110,11 @@ export const actions: Actions = { message: 'Please select your country so we can connect you with your local group.' }) } - } else if (!fullName || !email || !country || !city) { + } else if (!existingRecordId && (!fullName || !email || !country || !city)) { + // Updates (the volunteer step, and the /subscribe "do more" hand-off) + // patch an existing record, so they must not re-require fields the + // original create may have left blank — a /subscribe signup can omit + // name/city/country entirely. return fail(400, { message: 'Please fill in your name, email, country and city.' }) } if (!/^\S+@\S+\.\S+$/.test(email)) { @@ -202,10 +206,6 @@ export const actions: Actions = { } } - // Chapter routing is recorded only; notifying the chapter stays a manual - // Airtable process (plan decision 6). - const chapter = await lookupChapter(fetch, country) - if (isOnboardingLive()) { let recordId: string | undefined = existingRecordId || undefined if (recordId) { @@ -229,6 +229,10 @@ export const actions: Actions = { return { success: true, recordId } } + // Chapter routing is recorded only for stub inspection; notifying the + // chapter stays a manual Airtable process (plan decision 6). The live + // branch never reads it, so it's resolved here rather than on every submit. + const chapter = await lookupChapter(fetch, country) const submission = recordStubSubmission({ airtable: { baseId: AIRTABLE_BASE_ID, From 3ffaca54fda79d3c5564439918f6ed865ee8938c Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Wed, 12 Aug 2026 14:53:38 +0200 Subject: [PATCH 08/29] copy: align homepage newsletter box heading to Subscribe --- messages/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en.json b/messages/en.json index 2dcd07803..651f7f447 100644 --- a/messages/en.json +++ b/messages/en.json @@ -15,7 +15,7 @@ "header_community": "Community", "header_get_involved": "Get involved", "header_subscribe": "Subscribe", - "home_newsletter_heading": "Get updates", + "home_newsletter_heading": "Subscribe", "home_newsletter_button": "Sign up", "header_our_strategy": "Our strategy", "header_why": "Why this matters", From 2cf203ab1684c562af882154ced3d0a7887b28e4 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Wed, 12 Aug 2026 19:23:19 +0200 Subject: [PATCH 09/29] fix(subscribe): stop form inputs overflowing the card on mobile The .field inputs set width:100% with their own padding, and the site's reset doesn't apply box-sizing to form controls, so on narrow screens the inputs rendered content-box and spilled past the card's right edge. Add box-sizing:border-box to the card and inputs, and tighten the card padding on mobile. --- src/lib/components/onboarding/SubscribeFlow.svelte | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/components/onboarding/SubscribeFlow.svelte b/src/lib/components/onboarding/SubscribeFlow.svelte index 3cb2e3f28..342217a46 100644 --- a/src/lib/components/onboarding/SubscribeFlow.svelte +++ b/src/lib/components/onboarding/SubscribeFlow.svelte @@ -191,6 +191,10 @@ diff --git a/src/lib/components/navbar/wide/WideNavbar.svelte b/src/lib/components/navbar/wide/WideNavbar.svelte index 5d75c02d5..94f063236 100644 --- a/src/lib/components/navbar/wide/WideNavbar.svelte +++ b/src/lib/components/navbar/wide/WideNavbar.svelte @@ -2,10 +2,6 @@ import UniversalNavbar from '../universal/UniversalNavbar.svelte' import Navlink from '../Navlink.svelte' import NavDropdown from '../NavDropdown.svelte' - import MailIcon from '@lucide/svelte/icons/mail' - import LinkWithoutIcon from '$lib/components/LinkWithoutIcon.svelte' - import { page } from '$app/state' - import { localizeHref } from '$lib/paraglide/runtime' import type { NavItem } from '../navItems' interface Props { @@ -21,15 +17,6 @@ {#each items as item, i (item.label)} {#if item.children} - {:else if item.mail} - {@const active = !!item.href && localizeHref(page.url.pathname) === localizeHref(item.href)} - - - {item.label} - {:else} {item.label} From 6e00712148ce308eef0600839020646b6273da36 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 13 Aug 2026 01:12:50 +0200 Subject: [PATCH 15/29] fix(nav): give the header its own width so all six top-level items fit inline The header was capped at --page-width (the 768px content column), so the sixth top-level item ("Subscribe") pushed the nav links past the row and the logo wrapped above them on every page, regardless of viewport width. Add a --header-width (48rem) and apply it where the nav is width-capped: the hero .menu-band nav, and the .wide-navbar inside the width-capped .layout grid on other pages. The header now gets slightly more room than the content column, so all six items sit inline beside the logo; genuinely narrow widths still center the logo above the nav as before. Restores the desktop mail icon on Subscribe. --- .../navbar/universal/UniversalNavbar.svelte | 31 +++++++++++++++++++ .../components/navbar/wide/WideNavbar.svelte | 13 ++++++++ src/routes/+layout.svelte | 10 +++++- src/styles/styles.css | 3 ++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/lib/components/navbar/universal/UniversalNavbar.svelte b/src/lib/components/navbar/universal/UniversalNavbar.svelte index 9d7784461..7aa43eca0 100644 --- a/src/lib/components/navbar/universal/UniversalNavbar.svelte +++ b/src/lib/components/navbar/universal/UniversalNavbar.svelte @@ -116,5 +116,36 @@ text-transform: uppercase; flex-wrap: wrap; justify-content: center; + align-items: center; + } + + /* The "Get updates" nav item (WideNavbar) is a bare link, not a Navlink, so it + needs its own colour/spacing here where .nav-links is in scope: matches the + .navlink rhythm and overrides the global `a` colour. */ + .nav-links :global(.get-updates) { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-family: var(--font-heading); + font-weight: 700; + font-size: 1.1rem; + color: var(--text); + text-decoration: none; + white-space: nowrap; + padding: 0 0.5rem; + margin-left: 0.5rem; + margin-right: -0.5rem; + } + + .nav-links :global(.get-updates.inverted) { + color: white; + } + + .nav-links :global(.get-updates:hover) { + color: var(--brand); + } + + .nav-links :global(.get-updates.active) { + color: var(--brand-subtle); } diff --git a/src/lib/components/navbar/wide/WideNavbar.svelte b/src/lib/components/navbar/wide/WideNavbar.svelte index 94f063236..5d75c02d5 100644 --- a/src/lib/components/navbar/wide/WideNavbar.svelte +++ b/src/lib/components/navbar/wide/WideNavbar.svelte @@ -2,6 +2,10 @@ import UniversalNavbar from '../universal/UniversalNavbar.svelte' import Navlink from '../Navlink.svelte' import NavDropdown from '../NavDropdown.svelte' + import MailIcon from '@lucide/svelte/icons/mail' + import LinkWithoutIcon from '$lib/components/LinkWithoutIcon.svelte' + import { page } from '$app/state' + import { localizeHref } from '$lib/paraglide/runtime' import type { NavItem } from '../navItems' interface Props { @@ -17,6 +21,15 @@ {#each items as item, i (item.label)} {#if item.children} + {:else if item.mail} + {@const active = !!item.href && localizeHref(page.url.pathname) === localizeHref(item.href)} + + + {item.label} + {:else} {item.label} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f9b5b8f25..e7de9f822 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -297,12 +297,20 @@ } .menu-band :global(nav) { - width: min(var(--page-width), 100% - 2 * var(--page-gutter)); + width: min(var(--header-width), 100% - 2 * var(--page-gutter)); margin-inline: auto; /* Tighter vertical padding than the component's responsive default. */ --vspace: 1.85rem; } + /* The header on non-hero pages sits inside .layout, whose max-inline-size is + the (narrower) content width. Let it use the wider header width so all + top-level nav items fit on one row beside the logo. */ + .layout > :global(.wide-navbar) { + width: min(var(--header-width), 100dvw - 2 * var(--page-gutter)); + justify-self: center; + } + .layout { height: 100%; position: relative; diff --git a/src/styles/styles.css b/src/styles/styles.css index 65b9148c2..c8db738c0 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -11,6 +11,9 @@ html { /* Major theme stuff */ --page-width: 40rem; /* Please update in media queries and $lib/config.ts */ + /* The header/nav gets a little more room than the content column so all + top-level items fit on one row beside the logo (see +layout.svelte). */ + --header-width: 48rem; /* Colors */ --t-color-main: #f68b1e; /* Default main color - saturated orange */ From 544921812ef154c759b0a5de39a1b5bed50c815f Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 13 Aug 2026 13:26:57 +0200 Subject: [PATCH 16/29] fix(newsletter): make the handoff work without JS, and latch the /join prefill - The homepage box's native form still posted straight to Substack, so a submit before hydration (or with JS off) bypassed the signup flow entirely. When a handoff target is set, the form's native action/method/field now mirror the hydrated goto(), landing on /subscribe with the email prefilled. - Apply the prefill latch to OnboardingFlow too: /join?subscribe-email=... could not be cleared to fix a typo, because the effect restored it as soon as the field went empty. --- src/lib/components/NewsletterSignup.svelte | 17 +++++++++++------ .../components/onboarding/OnboardingFlow.svelte | 10 +++++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/lib/components/NewsletterSignup.svelte b/src/lib/components/NewsletterSignup.svelte index b3795af33..75ee14076 100644 --- a/src/lib/components/NewsletterSignup.svelte +++ b/src/lib/components/NewsletterSignup.svelte @@ -59,25 +59,30 @@

{headingText}

{descriptionText}

- +
- + {#if !handoffHref} + + {/if}
diff --git a/src/lib/components/onboarding/OnboardingFlow.svelte b/src/lib/components/onboarding/OnboardingFlow.svelte index 08e50f351..13036b65e 100644 --- a/src/lib/components/onboarding/OnboardingFlow.svelte +++ b/src/lib/components/onboarding/OnboardingFlow.svelte @@ -122,10 +122,14 @@ }) // A newsletter signup elsewhere (e.g. the homepage box) can hand off here - // via ?subscribe-email=...; that email arrives after mount, so prefill it - // once it's available without clobbering anything the visitor has typed. + // via ?subscribe-email=...; that email arrives after mount. Apply it once into + // an empty field, then never again — so it neither clobbers typed input nor + // snaps back when the visitor clears it to fix a typo. + let prefillApplied = $state(false) $effect(() => { - if (initialEmail && !basics.email) basics.email = initialEmail + if (prefillApplied || !initialEmail) return + if (!basics.email) basics.email = initialEmail + prefillApplied = true }) const validLanguageStored = new Set(LANGUAGES.map((l) => l.stored)) From dc27fdec994200ae73f0bf988ee115690cc91751 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 13 Aug 2026 13:47:49 +0200 Subject: [PATCH 17/29] =?UTF-8?q?fix(subscribe):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20hover=20contrast,=20consent=20restore,=20update=20h?= =?UTF-8?q?ardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Subscribe nav item turned brand-orange on hover, which is invisible on the hero's orange band (the :hover rule outranked .inverted at equal specificity). Go black on hover when inverted, matching .navlink.inverted:hover. - Backing out of Volunteer/Lead in the "do more" step left "GDPR chapter share permission" true for someone who had declined chapter updates on the subscribe form. The continuation now reposts that signup-time choice, so a non-escalating intent restores it; /join's own volunteer updates are unaffected. - Updates only write name/country/city when non-empty, so a partial post can't blank fields the create collected. - Localize the homepage handoff target so a visitor on a localized homepage stays in their locale instead of landing on the English page. --- src/lib/components/NewsletterSignup.svelte | 5 +++-- .../navbar/universal/UniversalNavbar.svelte | 8 ++++++++ .../components/onboarding/OnboardingFlow.svelte | 14 +++++++++++++- src/lib/components/onboarding/SubscribeFlow.svelte | 1 + src/routes/embed/onboarding-form/+page.server.ts | 12 +++++++++--- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/lib/components/NewsletterSignup.svelte b/src/lib/components/NewsletterSignup.svelte index 75ee14076..28fd434ae 100644 --- a/src/lib/components/NewsletterSignup.svelte +++ b/src/lib/components/NewsletterSignup.svelte @@ -2,6 +2,7 @@ import * as m from '$lib/paraglide/messages.js' import { tick } from 'svelte' import { goto } from '$app/navigation' + import { localizeHref } from '$lib/paraglide/runtime' interface Props { // Localizable text @@ -34,7 +35,7 @@ // Newsletter entry that feeds the CRM: hand off to the signup flow with the // email prefilled, rather than subscribing to Substack directly. if (handoffHref) { - const dest = `${handoffHref}?subscribe-email=${encodeURIComponent(email)}` + const dest = `${localizeHref(handoffHref)}?subscribe-email=${encodeURIComponent(email)}` email = '' void goto(dest) return @@ -65,7 +66,7 @@ subscribing straight to Substack. -->
{/if} + {#if isContinuation} + + + {#if initialChapterShare} + + {/if} + {/if}

{msgs.onboarding_step2_heading}

{#if !isContinuation}
diff --git a/src/lib/components/onboarding/SubscribeFlow.svelte b/src/lib/components/onboarding/SubscribeFlow.svelte index 65cf4efdf..20366c086 100644 --- a/src/lib/components/onboarding/SubscribeFlow.svelte +++ b/src/lib/components/onboarding/SubscribeFlow.svelte @@ -203,6 +203,7 @@ initialCountry={country} initialCity={city} initialKeepInformed={true} + initialChapterShare={wantsChapter} /> {/if} diff --git a/src/routes/embed/onboarding-form/+page.server.ts b/src/routes/embed/onboarding-form/+page.server.ts index b71595714..b26d64290 100644 --- a/src/routes/embed/onboarding-form/+page.server.ts +++ b/src/routes/embed/onboarding-form/+page.server.ts @@ -128,15 +128,15 @@ export const actions: Actions = { let chapterShare: boolean | undefined if (existingRecordId) { if (intent === 'Volunteer' || intent === 'Lead') chapterShare = true + // The subscribe "do more" step reposts the signup-time choice, so backing + // out of Volunteer/Lead restores it rather than leaving the escalation. + else if (isSubscribeForm) chapterShare = wantsChapter } else { chapterShare = isSubscribeForm ? wantsChapter : true } const fields: FieldSet = { - 'Full name': fullName, Email: email, - Country: country, - City: city, Intent: intent, 'Signup source': SIGNUP_SOURCE, 'Email subscription': keepInformed, @@ -144,6 +144,12 @@ export const actions: Actions = { // and the /subscribe microcopy both link it. 'Data privacy policy agreed': true } + // A create always writes the basics (they're validated above). An update only + // overwrites them when non-empty, so a partial post can't blank what the + // create collected. + if (!existingRecordId || fullName) fields['Full name'] = fullName + if (!existingRecordId || country) fields.Country = country + if (!existingRecordId || city) fields.City = city if (chapterShare !== undefined) { fields['GDPR chapter share permission'] = chapterShare } From 1ed9d658176865dbc2fa8b6cfbb4e30439cdea43 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Sun, 16 Aug 2026 14:19:12 +0200 Subject: [PATCH 18/29] style(subscribe): match the /join form width The subscribe card was 560px against /join's 800px, so the card visibly jumped width when the "do more" step swapped one for the other in place. Match the width, and override the global 30rem form cap as OnboardingFlow does so the fields fill the card instead of leaving the extra width empty. --- src/lib/components/onboarding/SubscribeFlow.svelte | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/components/onboarding/SubscribeFlow.svelte b/src/lib/components/onboarding/SubscribeFlow.svelte index 20366c086..11a08b54f 100644 --- a/src/lib/components/onboarding/SubscribeFlow.svelte +++ b/src/lib/components/onboarding/SubscribeFlow.svelte @@ -231,7 +231,9 @@ background-color: var(--bg-subtle); border-radius: 32px; width: 100%; - max-width: 560px; + /* Matches OnboardingFlow's width: the "do more" step swaps this card for + that one in place, so a different width would jump mid-flow. */ + max-width: 800px; margin: 2.5rem auto 0; padding: 2.25rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); @@ -248,6 +250,10 @@ display: flex; flex-direction: column; gap: 1.1rem; + width: 100%; + /* The global `form` rule caps at 30rem, which would leave the card's extra + width empty. */ + max-width: none; } .field { From 54eb6cf0e497f6582b7ec588371cdb58b517eda7 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Sun, 16 Aug 2026 14:52:47 +0200 Subject: [PATCH 19/29] fix(subscribe): match /join's heading rhythm, and drop confirmations that misstate the signup - The heading spacing was inverted relative to /join (19px under the title and 48px before the card, where /join has 48 and 19). Mirror the page template's hgroup rhythm the page suppresses, so it now measures identically. - The "do more" confirmation repeated "You're in, thanks for joining us" moments after the subscribe thanks screen, and its opt-in confirmations claimed "we'll connect you with your local PauseAI chapter" even for someone who declined the chapter box on the subscribe form. Both are hidden on the continuation, where those opt-ins were already made and confirmed. --- src/lib/components/onboarding/OnboardingFlow.svelte | 11 +++++++++-- src/lib/components/onboarding/SubscribeFlow.svelte | 9 ++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/components/onboarding/OnboardingFlow.svelte b/src/lib/components/onboarding/OnboardingFlow.svelte index 6d2c98d21..d13370435 100644 --- a/src/lib/components/onboarding/OnboardingFlow.svelte +++ b/src/lib/components/onboarding/OnboardingFlow.svelte @@ -388,7 +388,10 @@ {/snippet} {#snippet checkboxConfirmations()} - {#if keepInformed || basics.newsletter} + + {#if !isContinuation && (keepInformed || basics.newsletter)}