Date: Sun, 25 Oct 2026 16:00:22 +0200
Subject: [PATCH 07/11] refactor(home): rename description test events and
variants
Events become "Homepage Description Shown" / "Homepage Description CTA
Click" and variants become A / B.
---
src/pages/index.astro | 2 +-
src/utils/homepageExperiment.test.ts | 12 ++++++------
src/utils/homepageExperiment.ts | 8 ++++----
3 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 5bc86fbf..4accb4e6 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -190,7 +190,7 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi
{
- variant === "control" ? (
+ variant === "A" ? (
<>
Tech for Palestine is a 501(c)(3) nonprofit starting and scaling initiatives for
diff --git a/src/utils/homepageExperiment.test.ts b/src/utils/homepageExperiment.test.ts
index 89330e5c..c4ef725b 100644
--- a/src/utils/homepageExperiment.test.ts
+++ b/src/utils/homepageExperiment.test.ts
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
import { pickHomepageVariant } from "./homepageExperiment";
describe("pickHomepageVariant", () => {
- it("returns control for the lower half of the range", () => {
- expect(pickHomepageVariant(() => 0)).toBe("control");
- expect(pickHomepageVariant(() => 0.499)).toBe("control");
+ it("returns A for the lower half of the range", () => {
+ expect(pickHomepageVariant(() => 0)).toBe("A");
+ expect(pickHomepageVariant(() => 0.499)).toBe("A");
});
- it("returns treatment for the upper half of the range", () => {
- expect(pickHomepageVariant(() => 0.5)).toBe("treatment");
- expect(pickHomepageVariant(() => 0.999)).toBe("treatment");
+ it("returns B for the upper half of the range", () => {
+ expect(pickHomepageVariant(() => 0.5)).toBe("B");
+ expect(pickHomepageVariant(() => 0.999)).toBe("B");
});
});
diff --git a/src/utils/homepageExperiment.ts b/src/utils/homepageExperiment.ts
index e1e59a74..768267f5 100644
--- a/src/utils/homepageExperiment.ts
+++ b/src/utils/homepageExperiment.ts
@@ -1,13 +1,13 @@
export const HOMEPAGE_EXPERIMENT = "homepage-description";
-export const EXPOSURE_EVENT = "Experiment Exposure";
-export const CTA_CLICK_EVENT = "CTA Click";
+export const EXPOSURE_EVENT = "Homepage Description Shown";
+export const CTA_CLICK_EVENT = "Homepage Description CTA Click";
-export type HomepageVariant = "control" | "treatment";
+export type HomepageVariant = "A" | "B";
// Assigned per request on the server: nothing is stored, so a visitor can see a
// different variant on each page view. That is the price of needing no consent banner.
export function pickHomepageVariant(random: () => number = Math.random): HomepageVariant {
- return random() < 0.5 ? "control" : "treatment";
+ return random() < 0.5 ? "A" : "B";
}
interface PlausibleWindow {
From c58b386bf9b69cae454af8b2fb53d4f7fb21b177 Mon Sep 17 00:00:00 2001
From: mohanadft
Date: Sun, 25 Oct 2026 16:13:10 +0200
Subject: [PATCH 08/11] fix(home): only count homepage test exposures the
visitor actually sees
Layout prerenders / from every page and prerendered pages run their
scripts, so the exposure event now waits for prerenderingchange.
Middle-clicks on the hero cards now count as clicks. Tracking takes an
injectable tracker and document so it is unit tested.
Drop the GrowthBook localStorage polyfill: the test no longer uses
GrowthBook, the homepage still writes localStorage elsewhere, and the
polyfill cost a CDN fetch on every page load.
---
src/lib/growthbook.ts | 7 +-
src/utils/homepageExperiment.test.ts | 114 ++++++++++++++++++++++++++-
src/utils/homepageExperiment.ts | 46 ++++++++---
3 files changed, 150 insertions(+), 17 deletions(-)
diff --git a/src/lib/growthbook.ts b/src/lib/growthbook.ts
index 75f24a48..ddd836f7 100644
--- a/src/lib/growthbook.ts
+++ b/src/lib/growthbook.ts
@@ -1,4 +1,4 @@
-import { GrowthBook, setPolyfills } from "@growthbook/growthbook-react";
+import { GrowthBook } from "@growthbook/growthbook-react";
const clientKey = import.meta.env.PUBLIC_GROWTHBOOK_CLIENT_KEY;
@@ -10,11 +10,6 @@ if (!clientKey) {
);
}
-// Keep the homepage free of browser storage. The SDK persists its flag payload to
-// localStorage on every fetch, and configureCache({ disableCache }) does not stop that
-// write, so swap in a no-op storage. The payload is then refetched on every page load.
-setPolyfills({ localStorage: { getItem: () => null, setItem: () => {} } });
-
export const growthbook = new GrowthBook({
apiHost: "https://cdn.growthbook.io",
clientKey,
diff --git a/src/utils/homepageExperiment.test.ts b/src/utils/homepageExperiment.test.ts
index c4ef725b..34331c98 100644
--- a/src/utils/homepageExperiment.test.ts
+++ b/src/utils/homepageExperiment.test.ts
@@ -1,5 +1,11 @@
-import { describe, expect, it } from "vitest";
-import { pickHomepageVariant } from "./homepageExperiment";
+import { describe, expect, it, vi } from "vitest";
+import {
+ CTA_CLICK_EVENT,
+ EXPOSURE_EVENT,
+ pickHomepageVariant,
+ trackHeroCtas,
+ type PrerenderAwareDocument,
+} from "./homepageExperiment";
describe("pickHomepageVariant", () => {
it("returns A for the lower half of the range", () => {
@@ -12,3 +18,107 @@ describe("pickHomepageVariant", () => {
expect(pickHomepageVariant(() => 0.999)).toBe("B");
});
});
+
+// Vitest runs in node, so the container and document are minimal fakes.
+type Listener = (event: MouseEvent) => void;
+
+function fakeContainer(dataset: Record) {
+ const listeners = new Map>();
+ const container = {
+ dataset,
+ addEventListener: (type: string, listener: Listener) => {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type)?.add(listener);
+ },
+ removeEventListener: (type: string, listener: Listener) => {
+ listeners.get(type)?.delete(listener);
+ },
+ };
+ const fire = (type: string, cta: string | null, button = 0) => {
+ const link = cta ? { dataset: { cta } } : null;
+ const event = { type, button, target: { closest: () => link } } as unknown as MouseEvent;
+ listeners.get(type)?.forEach((listener) => listener(event));
+ };
+ return { container: container as unknown as HTMLElement, fire };
+}
+
+function fakeDocument(prerendering: boolean) {
+ let activate: (() => void) | undefined;
+ const doc: PrerenderAwareDocument = {
+ prerendering,
+ addEventListener: (_type, listener) => {
+ activate = listener;
+ },
+ };
+ return { doc, activate: () => activate?.() };
+}
+
+const props = { experiment: "homepage-description", variant: "B" };
+
+describe("trackHeroCtas", () => {
+ it("records one exposure straight away when the page is visible", () => {
+ const track = vi.fn();
+ const { container } = fakeContainer(props);
+
+ trackHeroCtas(container, { track, doc: fakeDocument(false).doc });
+
+ expect(track).toHaveBeenCalledOnce();
+ expect(track).toHaveBeenCalledWith(EXPOSURE_EVENT, props);
+ });
+
+ it("waits for a prerendered page to be shown before recording the exposure", () => {
+ const track = vi.fn();
+ const { container } = fakeContainer(props);
+ const { doc, activate } = fakeDocument(true);
+
+ trackHeroCtas(container, { track, doc });
+ expect(track).not.toHaveBeenCalled();
+
+ activate();
+ expect(track).toHaveBeenCalledWith(EXPOSURE_EVENT, props);
+ });
+
+ it("does nothing without experiment and variant data attributes", () => {
+ const track = vi.fn();
+ const { container, fire } = fakeContainer({});
+
+ trackHeroCtas(container, { track, doc: fakeDocument(false).doc });
+ fire("click", "membership");
+
+ expect(track).not.toHaveBeenCalled();
+ });
+
+ it("records the clicked card and only the first click", () => {
+ const track = vi.fn();
+ const { container, fire } = fakeContainer(props);
+
+ trackHeroCtas(container, { track, doc: fakeDocument(false).doc });
+ fire("click", "incubator");
+ fire("click", "membership");
+
+ expect(track).toHaveBeenCalledTimes(2);
+ expect(track).toHaveBeenLastCalledWith(CTA_CLICK_EVENT, { ...props, cta: "incubator" });
+ });
+
+ it("ignores clicks outside the cards", () => {
+ const track = vi.fn();
+ const { container, fire } = fakeContainer(props);
+
+ trackHeroCtas(container, { track, doc: fakeDocument(false).doc });
+ fire("click", null);
+
+ expect(track).toHaveBeenCalledOnce();
+ });
+
+ it("counts middle-clicks but not right-clicks", () => {
+ const track = vi.fn();
+ const { container, fire } = fakeContainer(props);
+
+ trackHeroCtas(container, { track, doc: fakeDocument(false).doc });
+ fire("auxclick", "membership", 2);
+ expect(track).toHaveBeenCalledOnce();
+
+ fire("auxclick", "membership", 1);
+ expect(track).toHaveBeenLastCalledWith(CTA_CLICK_EVENT, { ...props, cta: "membership" });
+ });
+});
diff --git a/src/utils/homepageExperiment.ts b/src/utils/homepageExperiment.ts
index c76a1c02..b8abcada 100644
--- a/src/utils/homepageExperiment.ts
+++ b/src/utils/homepageExperiment.ts
@@ -4,35 +4,63 @@ export const CTA_CLICK_EVENT = "Homepage Description CTA Click";
export type HomepageVariant = "A" | "B";
+const VARIANT_A_SHARE = 0.5;
+const MIDDLE_BUTTON = 1;
+
// Assigned per request on the server: nothing is stored, so a visitor can see a
// different variant on each page view. That is the price of needing no consent banner.
export function pickHomepageVariant(random: () => number = Math.random): HomepageVariant {
- return random() < 0.5 ? "A" : "B";
+ return random() < VARIANT_A_SHARE ? "A" : "B";
}
-interface PlausibleWindow {
- plausible?: (name: string, options?: { props: Record }) => void;
+export type TrackEvent = (name: string, props: Record) => void;
+
+// The parts of Document this needs. `prerendering` is not in TypeScript's DOM lib yet.
+export interface PrerenderAwareDocument {
+ prerendering?: boolean;
+ addEventListener(
+ type: "prerenderingchange",
+ listener: () => void,
+ options?: AddEventListenerOptions
+ ): void;
}
-export function trackExperimentEvent(name: string, props: Record): void {
- (window as PlausibleWindow).plausible?.(name, { props });
+export interface TrackHeroCtasOptions {
+ track?: TrackEvent;
+ doc?: PrerenderAwareDocument;
}
-// Fires one exposure per page view for the container carrying data-experiment /
+const trackWithPlausible: TrackEvent = (name, props) => {
+ window.plausible?.(name, { props });
+};
+
+// Records one exposure per page view for the container carrying data-experiment /
// data-variant, and at most one click per page view across its data-cta links, tagged with
// which link was clicked.
-export function trackHeroCtas(container: HTMLElement): void {
+//
+// Layout prerenders "/" from every page, and prerendered pages run their scripts, so the
+// exposure waits until the page is actually shown. Middle-clicks (open in new tab) count too.
+export function trackHeroCtas(container: HTMLElement, options: TrackHeroCtasOptions = {}): void {
+ const { track = trackWithPlausible, doc = document as PrerenderAwareDocument } = options;
const { experiment, variant } = container.dataset;
if (!experiment || !variant) return;
- trackExperimentEvent(EXPOSURE_EVENT, { experiment, variant });
+ const recordExposure = (): void => track(EXPOSURE_EVENT, { experiment, variant });
+ if (doc.prerendering) {
+ doc.addEventListener("prerenderingchange", recordExposure, { once: true });
+ } else {
+ recordExposure();
+ }
const onClick = (event: MouseEvent): void => {
+ if (event.type === "auxclick" && event.button !== MIDDLE_BUTTON) return;
const link = (event.target as Element | null)?.closest("a[data-cta]");
const cta = link?.dataset.cta;
if (!cta) return;
container.removeEventListener("click", onClick);
- trackExperimentEvent(CTA_CLICK_EVENT, { experiment, variant, cta });
+ container.removeEventListener("auxclick", onClick);
+ track(CTA_CLICK_EVENT, { experiment, variant, cta });
};
container.addEventListener("click", onClick);
+ container.addEventListener("auxclick", onClick);
}
From 63d2d5561922b9492da610f70cb508c6cbef903d Mon Sep 17 00:00:00 2001
From: mohanadft
Date: Sun, 25 Oct 2026 16:17:11 +0200
Subject: [PATCH 09/11] feat(home): simplify hero CTAs to two one-line buttons
Replace the two hero cards with a primary "Become a Member" pill and an
outlined "Apply to the Incubator" pill. Click tracking is unchanged.
---
src/pages/index.astro | 35 ++++-------------------------------
1 file changed, 4 insertions(+), 31 deletions(-)
diff --git a/src/pages/index.astro b/src/pages/index.astro
index e4f442c1..940ff14c 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -154,30 +154,13 @@ const press = [
const focusRing =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-grove";
const pillPrimary = `inline-flex items-center justify-center rounded-pill bg-grove px-7 py-3.5 text-[15px] font-bold text-white transition-colors duration-150 hover:bg-grove-hover ${focusRing}`;
+const pillGhost = `inline-flex items-center justify-center rounded-pill border border-ink-divider px-7 py-3.5 text-[15px] font-bold text-ink transition-colors duration-150 hover:border-grove hover:text-grove ${focusRing}`;
// Homepage description test. The variant is chosen per request and rendered into the HTML,
// so there is no flicker and nothing is stored. The page must not be cached or every visitor
// in the cache window would get the same variant.
const variant = pickHomepageVariant();
Astro.response.headers.set("Cache-Control", "no-store");
-// The hero's two paths: each card is one link, a sentence then the action.
-const heroPaths = [
- {
- href: "/membership",
- cta: "membership",
- body: "Join the community, build projects, and start your own through T4P:",
- action: "Become a Member",
- tone: "border-grove bg-grove text-white hover:border-grove-hover hover:bg-grove-hover",
- },
- {
- href: "/incubator",
- cta: "incubator",
- body: "If you run your own pro-Palestine project, and want help scaling:",
- action: "Apply to the Incubator",
- tone: "border-ink-divider bg-paper text-ink hover:border-grove hover:shadow-sm",
- },
-];
-const heroCard = `flex flex-col rounded-lg border p-6 transition-[background-color,border-color,box-shadow] duration-150 ${focusRing}`;
const cardBase = `group relative flex flex-col rounded-lg border border-ink-divider bg-paper transition-[border-color,box-shadow] duration-150 hover:border-grove hover:shadow-sm ${focusRing}`;
---
@@ -250,20 +233,10 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi
id="hero-ctas"
data-experiment={HOMEPAGE_EXPERIMENT}
data-variant={variant}
- class="mt-8 grid max-w-[44rem] grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] gap-3"
+ class="mt-8 flex flex-wrap gap-3"
>
- {
- heroPaths.map((path) => (
-
-
- {path.body}
-
-
-
- ))
- }
+ Become a Member
+ Apply to the Incubator
From 20e6994bb7c5632f53bd8976ef22c6083988ea1e Mon Sep 17 00:00:00 2001
From: mohanadft
Date: Sun, 25 Oct 2026 16:22:56 +0200
Subject: [PATCH 10/11] feat(volunteer): point volunteer form links to
membership
Open-role buttons and the open-roles error/empty fallbacks linked to the
Formbricks volunteer survey. They now all go to /membership. Remove the
unused survey URL, /volunteer-form constant and role link builder.
---
src/components/volunteer/OpenRoleCard.tsx | 12 +++----
src/components/volunteer/OpenRoles.tsx | 10 +++---
src/components/volunteer/openRoleData.test.ts | 34 -------------------
src/components/volunteer/openRoleData.ts | 23 +------------
4 files changed, 10 insertions(+), 69 deletions(-)
diff --git a/src/components/volunteer/OpenRoleCard.tsx b/src/components/volunteer/OpenRoleCard.tsx
index 73559494..0331e2f2 100644
--- a/src/components/volunteer/OpenRoleCard.tsx
+++ b/src/components/volunteer/OpenRoleCard.tsx
@@ -1,6 +1,6 @@
import React, { useId, useMemo, useState } from "react";
import { parseDescriptionHtml, type DescriptionNode, type DescriptionTag } from "./descriptionHtml";
-import { getApplyTarget, type OpenRole } from "./openRoleData";
+import { MEMBERSHIP_URL, type OpenRole } from "./openRoleData";
import type { SkinTokens } from "./openRoleSkins";
/** Descriptions shorter than this read fine in full, so they get no toggle. */
@@ -89,7 +89,6 @@ interface OpenRoleCardProps {
export default function OpenRoleCard({ role, tokens }: OpenRoleCardProps) {
const [expanded, setExpanded] = useState(false);
const descriptionId = useId();
- const apply = getApplyTarget(role);
const isTeamRole = role.type === "team";
const canCollapse = role.description.length > CLAMP_THRESHOLD;
const isCollapsed = canCollapse && !expanded;
@@ -118,12 +117,9 @@ export default function OpenRoleCard({ role, tokens }: OpenRoleCardProps) {
{role.title}
-
- {apply.label}
- as {role.title}
+
+ Become a member
+ to apply as {role.title}
diff --git a/src/components/volunteer/OpenRoles.tsx b/src/components/volunteer/OpenRoles.tsx
index 1f438449..5a5e17f5 100644
--- a/src/components/volunteer/OpenRoles.tsx
+++ b/src/components/volunteer/OpenRoles.tsx
@@ -4,8 +4,8 @@ import {
getPageCount,
getPageMarkers,
getSkillFilters,
+ MEMBERSHIP_URL,
normalizeOpenRoles,
- VOLUNTEER_FORM_URL,
type OpenRole,
} from "./openRoleData";
import { getSkinTokens, type OpenRolesSkin } from "./openRoleSkins";
@@ -106,8 +106,8 @@ export default function OpenRoles({ skin = "classic" }: OpenRolesProps) {
We couldn’t load open roles just now. Try again, or{" "}
-
- apply to volunteer
+
+ become a member
{" "}
and we’ll match you to a role.
@@ -126,8 +126,8 @@ export default function OpenRoles({ skin = "classic" }: OpenRolesProps) {
return (
No roles are open right now.{" "}
-
- Apply anyway
+
+ Become a member
{" "}
and we’ll get in touch when one opens up.
diff --git a/src/components/volunteer/openRoleData.test.ts b/src/components/volunteer/openRoleData.test.ts
index e9c6c6c1..4c37819e 100644
--- a/src/components/volunteer/openRoleData.test.ts
+++ b/src/components/volunteer/openRoleData.test.ts
@@ -2,13 +2,9 @@ import { describe, expect, it } from "vitest";
import {
attachProjectLinks,
clampPage,
- getApplyTarget,
getPageCount,
getPageMarkers,
- getRoleApplicationUrl,
- MEMBERSHIP_URL,
normalizeOpenRoles,
- VOLUNTEER_SURVEY_URL,
type OpenRole,
} from "./openRoleData";
@@ -26,36 +22,6 @@ const projectRole: OpenRole = {
createdAt: "2026-10-14T18:35:10.338Z",
};
-describe("getRoleApplicationUrl", () => {
- it("passes the role id, title and project name as encoded survey params", () => {
- const url = new URL(getRoleApplicationUrl(projectRole));
-
- expect(`${url.origin}${url.pathname}`).toBe(VOLUNTEER_SURVEY_URL);
- expect(url.searchParams.get("role_id")).toBe(projectRole.id);
- expect(url.searchParams.get("role_title")).toBe("Data Entry & Research");
- expect(url.searchParams.get("org_name")).toBe("Demilitarise Education");
- });
-
- it("omits org_name when the role has no project or team name", () => {
- const url = new URL(getRoleApplicationUrl({ ...projectRole, orgName: "" }));
-
- expect(url.searchParams.has("org_name")).toBe(false);
- });
-});
-
-describe("getApplyTarget", () => {
- it("sends project roles to the volunteer survey with the role attached", () => {
- const target = getApplyTarget(projectRole);
-
- expect(target.label).toBe("Apply to volunteer");
- expect(target.href).toBe(getRoleApplicationUrl(projectRole));
- });
-
- it("sends team roles to membership", () => {
- expect(getApplyTarget({ ...projectRole, type: "team" }).href).toBe(MEMBERSHIP_URL);
- });
-});
-
describe("normalizeOpenRoles", () => {
const hubRow = {
id: "14a4bd4a-a8ab-4dec-9c05-730f062a56dc",
diff --git a/src/components/volunteer/openRoleData.ts b/src/components/volunteer/openRoleData.ts
index 4767451c..b10da72c 100644
--- a/src/components/volunteer/openRoleData.ts
+++ b/src/components/volunteer/openRoleData.ts
@@ -36,23 +36,9 @@ export interface OpenRole {
createdAt: string;
}
-export const VOLUNTEER_FORM_URL = "https://techforpalestine.org/volunteer-form";
+/** Every role, project or team, is applied for by becoming a member. */
export const MEMBERSHIP_URL = "/membership";
-/**
- * The Formbricks survey `/volunteer-form` redirects to. Role links point here
- * directly because that Cloudflare redirect drops the query string, and the
- * survey reads `role_id`, `role_title` and `org_name` as passed-in hidden fields.
- */
-export const VOLUNTEER_SURVEY_URL = "https://app.formbricks.com/s/clzavvkp80002yqhejhqgoa3a";
-
-/** Volunteer survey link carrying the role, so the response records what was applied for. */
-export function getRoleApplicationUrl(role: OpenRole): string {
- const params = new URLSearchParams({ role_id: role.id, role_title: role.title });
- if (role.orgName) params.set("org_name", role.orgName);
- return `${VOLUNTEER_SURVEY_URL}?${params.toString()}`;
-}
-
/** Upstream descriptions are free text; cap them so one bad row can't bloat the page. */
const MAX_DESCRIPTION_LENGTH = 4000;
/** Markup roughly doubles a description's length, so its cap is looser. */
@@ -147,13 +133,6 @@ export function attachProjectLinks(
}));
}
-/** Where a role's apply button points, per how the role is staffed. */
-export function getApplyTarget(role: OpenRole): { href: string; label: string } {
- return role.type === "team"
- ? { href: MEMBERSHIP_URL, label: "Become a member" }
- : { href: getRoleApplicationUrl(role), label: "Apply to volunteer" };
-}
-
export interface SkillFilter {
name: string;
count: number;
From 45aa6bb441bc4a04954f4db25a78d0cea4aa10af Mon Sep 17 00:00:00 2001
From: mohanadft
Date: Sun, 25 Oct 2026 16:45:31 +0200
Subject: [PATCH 11/11] feat(home): put each hero CTA's sentence inside its
button
The hero buttons now read as one line of text with the action in bold:
"Join the community, build projects, and start your own through T4P:
Become a Member" and "If you run your own pro-Palestine project, and
want help scaling: Apply to the Incubator". Click tracking is unchanged.
---
src/pages/index.astro | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 940ff14c..7a2dd25a 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -154,7 +154,10 @@ const press = [
const focusRing =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-grove";
const pillPrimary = `inline-flex items-center justify-center rounded-pill bg-grove px-7 py-3.5 text-[15px] font-bold text-white transition-colors duration-150 hover:bg-grove-hover ${focusRing}`;
-const pillGhost = `inline-flex items-center justify-center rounded-pill border border-ink-divider px-7 py-3.5 text-[15px] font-bold text-ink transition-colors duration-150 hover:border-grove hover:text-grove ${focusRing}`;
+// Hero buttons carry a sentence then the action, so they wrap as running text, not flex items.
+const heroButton = `inline-block rounded-pill px-7 py-3.5 text-left text-[15px] leading-[1.45] transition-colors duration-150 ${focusRing}`;
+const heroPrimary = `${heroButton} bg-grove text-white hover:bg-grove-hover`;
+const heroGhost = `${heroButton} border border-ink-divider text-ink hover:border-grove hover:text-grove`;
// Homepage description test. The variant is chosen per request and rendered into the HTML,
// so there is no flicker and nothing is stored. The page must not be cached or every visitor
@@ -233,10 +236,16 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi
id="hero-ctas"
data-experiment={HOMEPAGE_EXPERIMENT}
data-variant={variant}
- class="mt-8 flex flex-wrap gap-3"
+ class="mt-8 flex flex-col items-start gap-3"
>
- Become a Member
- Apply to the Incubator
+
+ Join the community, build projects, and start your own through T4P:
+ Become a Member
+
+
+ If you run your own pro-Palestine project, and want help scaling:
+ Apply to the Incubator
+