The public surface
The guest sees one invite page per event: violet hero, date/location pills, description, calendar link, and an RSVP card. No login. This is where the anonymous write from chapter 7 becomes something you can click.



| Surface | Local | Cloud |
|---|---|---|
| Host login (apex) | http://show-pony.localhost:4180/login | https://show-pony.kumiko.rocks/login |
| Demo invite (rooftop) | http://demo.show-pony.localhost:4180/e/rooftop-launch | https://demo.show-pony.kumiko.rocks/e/rooftop-launch |
| Acme invite (offsite) | http://acme.show-pony.localhost:4180/e/acme-offsite | https://acme.show-pony.kumiko.rocks/e/acme-offsite |

Invite layout
Section titled “Invite layout”EventPage is custom React (not schema-driven): hero band with --color-primary,
meta pills, description card, then RsvpForm. The guest bundle adds
class="show-pony-public" on <body> for a slightly warmer canvas. It uses the
same tokens as chapter 2 with a different
surface layout.
Public client entry
Section titled “Public client entry”The guest bundle mounts a single React tree:
import { mountPublic } from "./public/mount";mountPublic();src/client-public.tsx.
Routing by host is in chapter 4. The guest never loads admin JavaScript.
Anonymous fetch
Section titled “Anonymous fetch”export async function submitRsvp(input: RsvpInput): Promise<SubmitResult> { const res = await fetch("/api/write", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "showpony:write:rsvp:submit", payload: input }), }); ...}src/public/api.ts.
The client does not send the tenant. tenantResolver derives it from the subdomain.
The form
Section titled “The form”RsvpForm is ordinary React: name (required), status, plus-guests, email
(optional). Success appears inline, with no redirect.
The complete file
Section titled “The complete file”// The public event page. Loads the event by the slug in the URL// (<key>.show-pony.<domain>/e/<slug>) and shows it plus the RSVP form.// Anonymous — no login, no account.
import { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";import { type ReactElement, useEffect, useState } from "react";import { fetchDemoMode } from "../demo-mode-client";import type { InviteBranding } from "../features/show-pony/invite-branding.shared";import { fetchEventBySlug, fetchInviteBranding, type PublicEvent } from "./api";import { DemoPublicNotice } from "./DemoPublicNotice";import { InviteHero, inviteBrandingCssVars } from "./InviteHero";import { icsHref } from "./ics";import { RsvpForm } from "./RsvpForm";
function slugFromPath(): string { const segments = window.location.pathname.split("/").filter(Boolean); return segments[segments.length - 1] ?? "";}
type Load = | { kind: "loading" } | { kind: "missing" } | { kind: "ready"; event: PublicEvent; branding: InviteBranding };
// The gradient itself lives once, in styles.css's `.sp-invite-split-page`// fallback rule — it reads var(--color-primary)/var(--color-ring), so// setting just these two custom properties is enough for the CSS rule to// paint the same gradient with the tenant's accent, no JS-side duplicate.function splitInviteCanvasStyle(accent: string): Record<string, string> { return { "--color-primary": accent, "--color-ring": accent, };}
export function EventPage(): ReactElement { const t = useTranslation(); const appLocale = useLocale().locale(); const [load, setLoad] = useState<Load>({ kind: "loading" }); const [readOnly, setReadOnly] = useState(false);
// kumiko-lint-ignore no-raw-hooks anonymous public bundle — one-shot fetch by slug, no dispatcher useEffect(() => { void fetchDemoMode().then((demo) => setReadOnly(demo.readOnly)); const slug = slugFromPath(); void Promise.all([fetchEventBySlug(slug), fetchInviteBranding()]) .then(([event, branding]) => setLoad(event ? { kind: "ready", event, branding } : { kind: "missing" }), ) .catch(() => setLoad({ kind: "missing" })); }, []);
const splitPage = load.kind === "ready" && load.branding.heroStyle === "split"; const splitAccent = load.kind === "ready" ? load.branding.accentColor : "";
// Syncs split-page body classes + tenant accent CSS vars; the gradient // itself comes from the CSS fallback rule (styles.css) keyed off these // same classes, so JS never sets it inline. // kumiko-lint-ignore no-raw-hooks DOM-side-effect on document.body, not app state useEffect(() => { if (!splitPage || !splitAccent) return; document.body.classList.add("show-pony-public", "sp-invite-split-page"); document.body.style.setProperty("--color-primary", splitAccent); document.body.style.setProperty("--color-ring", splitAccent); return () => { document.body.classList.remove("show-pony-public", "sp-invite-split-page"); document.body.style.removeProperty("--color-primary"); document.body.style.removeProperty("--color-ring"); }; }, [splitPage, splitAccent]);
if (load.kind === "loading") { return ( <main className="mx-auto max-w-2xl p-8 text-[var(--color-muted-foreground)] show-pony-public"> … </main> ); } if (load.kind === "missing") { return ( <main className="mx-auto max-w-2xl p-8 show-pony-public"> {t("showpony:public.event.missing")} </main> ); }
const { event, branding } = load; const when = new Date(event.startsAt).toLocaleString(appLocale, { dateStyle: "long", timeStyle: "short", }); const guestLimit = event.guestLimit > 0 ? event.guestLimit : null; const canvasStyle = splitPage && splitAccent ? splitInviteCanvasStyle(splitAccent) : inviteBrandingCssVars(branding);
return ( // kumiko-lint-ignore no-inline-styles tenant accent color from branding config <div className={`min-h-screen show-pony-public${splitPage ? " sp-invite-split-page" : ""}`} style={canvasStyle} > <InviteHero branding={branding} title={event.title} when={when} location={event.location || null} guestLimit={guestLimit} />
<main className="relative z-10 mx-auto max-w-2xl px-4 pb-12 pt-6 sm:px-6"> <div className="space-y-6"> <section className={ event.description ? "rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6" : "sp-invite-card rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6" } > {event.description ? ( <p className="whitespace-pre-line text-[var(--color-foreground)] leading-relaxed"> {event.description} </p> ) : null} <a href={icsHref(event)} download={`${event.slug}.ics`} className={`${event.description ? "mt-5 " : ""}inline-flex items-center gap-1 text-sm font-medium text-[var(--color-primary)] hover:underline`} > {t("showpony:public.event.add-to-calendar")} </a> </section>
<section className="sp-invite-card rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6"> <h2 className="text-lg font-semibold text-[var(--color-foreground)]"> {t("showpony:public.rsvp.heading")} </h2> <p className="mt-1 text-sm text-[var(--color-muted-foreground)]"> {t("showpony:public.rsvp.subheading")} </p> {readOnly ? <DemoPublicNotice /> : <RsvpForm eventId={event.id} />} </section> </div> </main> </div> );}import { useTranslation } from "@cosmicdrift/kumiko-renderer";import type { ReactElement } from "react";import type { InviteBranding } from "../features/show-pony/invite-branding.shared";
type InviteHeroProps = { readonly branding: InviteBranding; readonly title: string; readonly when: string; readonly location: string | null; readonly guestLimit: number | null;};
type CssVarStyle = Record<string, string>;
function brandingThemeStyle(accent: string): CssVarStyle | undefined { if (!accent) return undefined; return { "--color-primary": accent, "--color-ring": accent };}
const HEX6_PATTERN = /^#[0-9a-fA-F]{6}$/;const HEX8_PATTERN = /^#[0-9a-fA-F]{8}$/;
// The alpha suffixes below assume a 6-digit hex. The config field allows// #rrggbbaa (8-digit, already has alpha), which would otherwise grow into// an invalid 10-digit color — strip any existing alpha channel first.function normalizedTint(accent: string): string { if (HEX6_PATTERN.test(accent)) return accent; if (HEX8_PATTERN.test(accent)) return accent.slice(0, 7); return "#7c3aed";}
function heroOverlayStyle(accent: string): CssVarStyle { const tint = normalizedTint(accent); return { background: `linear-gradient(135deg, ${tint}99 0%, ${tint}55 45%, ${tint}22 100%)`, };}
function MetaPill({ icon, children, variant,}: { icon: string; children: string; variant: "hero" | "card";}): ReactElement { const surface = variant === "hero" ? "bg-[var(--color-primary-foreground)]/15 backdrop-blur-sm" : "bg-[var(--color-muted)]"; return ( <span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm ${surface}`}> <span aria-hidden>{icon}</span> {children} </span> );}
function MetaPills({ when, location, guestLimit, variant,}: { when: string; location: string | null; guestLimit: number | null; variant: "hero" | "card";}): ReactElement { const t = useTranslation(); return ( <div className="mt-5 flex flex-wrap gap-2"> <MetaPill icon="📅" variant={variant}> {when} </MetaPill> {location ? ( <MetaPill icon="📍" variant={variant}> {location} </MetaPill> ) : null} {guestLimit != null && guestLimit > 0 ? ( <MetaPill icon="👥" variant={variant}> {t("showpony:public.event.guest-limit", { limit: String(guestLimit) })} </MetaPill> ) : null} </div> );}
function HeroCopy({ branding, title, when, location, guestLimit, className, metaVariant,}: InviteHeroProps & { readonly className?: string; readonly metaVariant: "hero" | "card";}): ReactElement { const t = useTranslation(); return ( <div className={className}> {branding.logoUrl ? ( <img src={branding.logoUrl} alt="" className="mb-4 h-10 w-auto object-contain" /> ) : branding.title ? ( <p className="text-sm font-semibold uppercase tracking-widest opacity-90"> {branding.title} </p> ) : ( <p className="text-sm font-medium uppercase tracking-widest opacity-90"> {t("showpony:public.event.invited")} </p> )} <h1 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">{title}</h1> {branding.description ? ( <p className="mt-3 max-w-xl text-sm leading-relaxed opacity-90">{branding.description}</p> ) : null} <MetaPills when={when} location={location} guestLimit={guestLimit} variant={metaVariant} /> </div> );}
function HeroImage({ url, alt, focus = "center",}: { url: string; alt: string; focus?: "center" | "bottom" | "right";}): ReactElement { const focusClass = focus === "bottom" ? "sp-hero-focus-bottom" : focus === "right" ? "sp-hero-focus-right" : ""; return ( <div className="sp-hero-media absolute inset-0"> <img src={url} alt={alt} className={`sp-hero-ken-burns h-full w-full object-cover ${focusClass}`} /> </div> );}
function ImmersiveHeroBackdrop({ heroUrl, accent, themeStyle,}: { heroUrl: string; accent: string; themeStyle: CssVarStyle | undefined;}): ReactElement { if (heroUrl) { return ( <> <HeroImage url={heroUrl} alt="" focus="bottom" /> {/* kumiko-lint-ignore no-inline-styles tenant hero gradient from branding config */} <div className="sp-hero-grain absolute inset-0" style={heroOverlayStyle(accent)} /> </> ); } // kumiko-lint-ignore no-inline-styles tenant accent fallback when no hero image return <div className="absolute inset-0 bg-[var(--color-primary)]" style={themeStyle} />;}
export function InviteHero(props: InviteHeroProps): ReactElement { const { branding } = props; const themeStyle = brandingThemeStyle(branding.accentColor); const heroUrl = branding.heroImageUrl;
if (branding.heroStyle === "split") { return ( // kumiko-lint-ignore no-inline-styles tenant accent color from branding config <header className="sp-hero-split border-b border-[var(--color-border)] text-[var(--color-foreground)]" style={themeStyle} > <div className="sp-hero-split-row"> <div className="sp-hero-split-copy flex flex-col justify-center px-6 py-10 sm:px-10 lg:px-12 lg:py-14"> <HeroCopy {...props} metaVariant="card" /> </div> {heroUrl ? ( <div className="sp-hero-split-media relative overflow-hidden"> <HeroImage url={heroUrl} alt="" focus="right" /> </div> ) : ( <div className="sp-hero-split-media bg-[var(--color-primary)]/20" aria-hidden /> )} </div> </header> ); }
return ( // kumiko-lint-ignore no-inline-styles tenant accent color + hero overlay from branding config <header className="sp-hero-immersive relative isolate z-0 overflow-hidden px-6 py-12 text-[var(--color-primary-foreground)] sm:px-10 sm:py-14" style={themeStyle} > <ImmersiveHeroBackdrop heroUrl={heroUrl} accent={branding.accentColor} themeStyle={themeStyle} /> <div className="relative z-10 mx-auto flex min-h-[inherit] max-w-3xl flex-col justify-end pb-2"> <HeroCopy {...props} metaVariant="hero" /> </div> </header> );}
export function inviteBrandingCssVars(branding: InviteBranding): CssVarStyle | undefined { return brandingThemeStyle(branding.accentColor);}// The RSVP form — the anonymous write from the public page. Name is required,// email optional. Inline success on submit, no redirect.
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";import { ModeSwitch } from "@cosmicdrift/kumiko-renderer-web";import { type ReactElement, useState } from "react";import { type RsvpStatus, submitRsvp } from "./api";
type FormState = | { kind: "idle" } | { kind: "submitting" } | { kind: "success"; name: string } | { kind: "error"; reason: string };
export function RsvpForm({ eventId }: { readonly eventId: string }): ReactElement { const t = useTranslation(); const { Banner, Button, Field, Form, Input } = usePrimitives(); const [name, setName] = useState(""); const [status, setStatus] = useState<RsvpStatus>("yes"); const [plusN, setPlusN] = useState(0); const [email, setEmail] = useState(""); const [state, setState] = useState<FormState>({ kind: "idle" });
const statusLabels: Record<RsvpStatus, string> = { yes: t("showpony:public.rsvp.status.yes"), maybe: t("showpony:public.rsvp.status.maybe"), no: t("showpony:public.rsvp.status.no"), };
async function onSubmit(): Promise<void> { if (name.length === 0) return; setState({ kind: "submitting" }); const result = await submitRsvp({ eventId, name, status, plusN, ...(email.length > 0 ? { email } : {}), }); setState(result.ok ? { kind: "success", name } : { kind: "error", reason: result.reason }); }
if (state.kind === "success") { return ( <div className="sp-rsvp-success mt-6 rounded-xl border border-[var(--color-primary)]/40 bg-[var(--color-card)] p-5 text-sm"> <div className="flex items-start gap-3"> <span className="sp-rsvp-check flex size-9 shrink-0 items-center justify-center rounded-full bg-[var(--color-primary)] text-lg text-[var(--color-primary-foreground)]" aria-hidden > ✓ </span> <div> <strong className="text-base"> {t("showpony:public.rsvp.thanks", { name: state.name })} </strong> <p className="mt-1 text-[var(--color-muted-foreground)]"> {t("showpony:public.rsvp.on-list")} </p> </div> </div> </div> ); }
return ( <div className="mt-4"> <Form onSubmit={(e) => { e?.preventDefault(); void onSubmit(); }} > <Field id="rsvp-name" label={t("showpony:public.rsvp.name")} required> <Input kind="text" id="rsvp-name" name="rsvp-name" value={name} onChange={setName} placeholder={t("showpony:public.rsvp.name-placeholder")} required disabled={state.kind === "submitting"} /> </Field> <ModeSwitch value={status} onChange={setStatus} options={(Object.keys(statusLabels) as RsvpStatus[]).map((s) => ({ value: s, label: statusLabels[s], }))} /> <Field id="rsvp-plus-n" label={t("showpony:public.rsvp.plus-guests")}> <Input kind="number" id="rsvp-plus-n" name="rsvp-plus-n" value={plusN} onChange={(v) => setPlusN(Math.min(20, Math.max(0, v ?? 0)))} disabled={state.kind === "submitting"} /> </Field> <Field id="rsvp-email" label={t("showpony:public.rsvp.email")}> <Input kind="email" id="rsvp-email" name="rsvp-email" value={email} onChange={setEmail} placeholder={t("showpony:public.rsvp.email-placeholder")} disabled={state.kind === "submitting"} /> </Field> {state.kind === "error" && ( <Banner variant="error"> {t("showpony:public.rsvp.error", { reason: state.reason })} </Banner> )} <Button type="submit" loading={state.kind === "submitting"} disabled={name.length === 0}> {t("showpony:public.rsvp.submit")} </Button> </Form> </div> );}📄 On GitHub: src/public/EventPage.tsx · src/public/InviteHero.tsx