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 — same tokens
as chapter 2, 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). Inline success — no redirect. One tap, done.
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 { useTranslation } from "@cosmicdrift/kumiko-renderer";import { type ReactElement, useEffect, useState } from "react";import { fetchDemoMode } from "../demo-mode-client";import { EMPTY_INVITE_BRANDING, 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 };
function splitInviteCanvasStyle(accent: string): Record<string, string> { return { "--color-primary": accent, "--color-ring": accent, backgroundColor: `color-mix(in srgb, ${accent} 10%, var(--color-background))`, backgroundImage: [ `radial-gradient(ellipse 90% 60% at 0% 0%, color-mix(in srgb, ${accent} 38%, transparent), transparent 55%)`, `radial-gradient(ellipse 75% 55% at 100% 100%, color-mix(in srgb, ${accent} 28%, transparent), transparent 50%)`, `linear-gradient(180deg, color-mix(in srgb, ${accent} 18%, var(--color-background)) 0%, color-mix(in srgb, ${accent} 10%, var(--color-background)) 50%, var(--color-background) 95%)`, ].join(", "), };}
export function EventPage(): ReactElement { const t = useTranslation(); 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: branding ?? EMPTY_INVITE_BRANDING } : { kind: "missing" }, ), ) .catch(() => setLoad({ kind: "missing" })); }, []);
const splitPage = load.kind === "ready" && load.branding.heroStyle === "split"; const splitAccent = load.kind === "ready" ? load.branding.accentColor : "";
// kumiko-lint-ignore no-raw-hooks sync split-page canvas + tenant accent onto body for page-wide gradient useEffect(() => { if (!splitPage || !splitAccent) return; const canvas = splitInviteCanvasStyle(splitAccent); document.body.classList.add("sp-invite-split-page"); document.body.style.backgroundColor = canvas.backgroundColor ?? ""; document.body.style.backgroundImage = canvas.backgroundImage ?? ""; document.body.style.setProperty("--color-primary", splitAccent); document.body.style.setProperty("--color-ring", splitAccent); return () => { document.body.classList.remove("sp-invite-split-page"); document.body.style.backgroundColor = ""; document.body.style.backgroundImage = ""; 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(undefined, { 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"> {event.description ? ( <section className="rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-6"> <p className="whitespace-pre-line text-[var(--color-foreground)] leading-relaxed"> {event.description} </p> <a href={icsHref(event)} download={`${event.slug}.ics`} className="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"> <a href={icsHref(event)} download={`${event.slug}.ics`} className="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 };}
function heroOverlayStyle(accent: string): CssVarStyle { const tint = accent || "#7c3aed"; 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 { useTranslation } from "@cosmicdrift/kumiko-renderer";import { type FormEvent, 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 [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(e: FormEvent<HTMLFormElement>): Promise<void> { e.preventDefault(); 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> ); }
const field = "w-full rounded-md border border-[var(--color-border)] bg-[var(--color-background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-ring)]";
return ( <form onSubmit={onSubmit} className="mt-4"> <div className="grid gap-3"> <input aria-label={t("showpony:public.rsvp.name")} required value={name} onChange={(e) => setName(e.target.value)} placeholder={t("showpony:public.rsvp.name-placeholder")} className={field} /> <div className="flex gap-2"> {(Object.keys(statusLabels) as RsvpStatus[]).map((s) => ( <button key={s} type="button" onClick={() => setStatus(s)} className={`flex-1 rounded-md border px-3 py-2 text-sm transition-colors duration-150 ${ status === s ? "border-[var(--color-primary)] bg-[var(--color-primary)] text-[var(--color-primary-foreground)]" : "border-[var(--color-border)] bg-[var(--color-background)] hover:border-[var(--color-primary)]/40" }`} > {statusLabels[s]} </button> ))} </div> <label className="text-sm text-[var(--color-muted-foreground)]"> {t("showpony:public.rsvp.plus-guests")} <input type="number" min={0} max={20} value={plusN} onChange={(e) => setPlusN(Number.parseInt(e.target.value, 10) || 0)} className={`${field} mt-1`} /> </label> <input aria-label={t("showpony:public.rsvp.email")} type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t("showpony:public.rsvp.email-placeholder")} className={field} /> <button type="submit" disabled={state.kind === "submitting" || name.length === 0} className="rounded-md bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-[var(--color-primary-foreground)] disabled:opacity-50" > {state.kind === "submitting" ? "…" : t("showpony:public.rsvp.submit")} </button> {state.kind === "error" && ( <p className="text-sm text-[var(--color-destructive)]"> {t("showpony:public.rsvp.error", { reason: state.reason })} </p> )} </div> </form> );}📄 On GitHub: src/public/EventPage.tsx · src/public/InviteHero.tsx