Skip to content

Look and feel

Before domain logic: make the app yours. ShowPony ships with its own lavender/violet brand — not the neutral gray Kumiko renderer default you see in a bare scaffold, and not the neon Vice City palette from the kumiko.rocks hero demo. Two small files control all of it.

Import the renderer stylesheet, then override design tokens in @theme and on :root:not(.dark) / .dark so light and dark both carry your palette:

@theme {
--color-primary: #7c3aed;
--color-primary-foreground: #ffffff;
--color-background: #f8f6ff;
--color-ring: #8b5cf6;
}
@layer base {
:root:not(.dark) {
--color-primary: #7c3aed;
--color-background: #f8f6ff;
/* …sidebar, border, muted — full surface, not just buttons */
}
}
— from src/styles.css

Override background, card, border, and sidebar tokens — not just --color-primary. Otherwise the admin shell still reads as generic Kumiko chrome with a tinted button. Tailwind v4 picks the tokens up everywhere. The shell also exposes a theme toggle (light/dark); no extra wiring.

Toggle dark mode on the host dashboard
Lavender dashboard after branding — two seeded events visible

The public RSVP page shares the same stylesheet. The guest HTML adds class="show-pony-public" on <body> for a slightly warmer invite canvas while keeping the same violet CTAs.

Every nav item, section title, and field label is an i18n key, not a string in the feature:

"showpony:nav.events": { de: "Events", en: "Events" },
"showpony:section.event-basics": { de: "Eckdaten", en: "Basics" },
"showpony:entity:event:field:title": { de: "Titel", en: "Title" },
— from src/features/show-pony/i18n/keys.ts

Wire translations in showPonyClient (src/features/show-pony/web/index.ts). A new locale is another block in keys.ts — the schema-driven screens in chapter 8 pick them up automatically.

  • Lavender surfaces + violet primary on light (deep violet chrome on dark)
  • de + en strings for nav, host forms, and the public invite page
  • Per-tenant invite branding (hero image, accent, layout preset)
  • A dashboard that reads as ShowPony, not the Kumiko platform default

Next: mount auth, tenant, and user — chapter 3.

/* show-pony Theme — lavender/violet event-app identity. Deliberately NOT the
neutral Kumiko renderer chrome (gray/white) nor the kumiko.rocks Vice City
hero palette. Admin + public share this file; the guest page adds
`.show-pony-public` on <body> for a slightly warmer invite surface. */
@import "@cosmicdrift/kumiko-renderer-web/styles.css";
@source "./**/*.{ts,tsx}";
@theme {
/* Dark — deep violet chrome (not renderer neutral gray). */
--color-background: #140a24;
--color-foreground: #f5f0ff;
--color-card: #1f1235;
--color-card-foreground: #f5f0ff;
--color-popover: #1f1235;
--color-popover-foreground: #f5f0ff;
--color-border: #3d2a5c;
--color-input: #3d2a5c;
--color-muted: #261740;
--color-muted-foreground: #c4b5fd;
--color-secondary: #261740;
--color-secondary-foreground: #f5f0ff;
--color-accent: #372460;
--color-accent-foreground: #f5f0ff;
--color-destructive: #f87171;
--color-destructive-foreground: #ffffff;
--color-primary: #a78bfa;
--color-primary-foreground: #140a24;
--color-ring: #8b5cf6;
--color-sidebar: #1a1030;
--color-sidebar-foreground: #e9e0ff;
--color-sidebar-primary: #a78bfa;
--color-sidebar-primary-foreground: #140a24;
--color-sidebar-accent: #2d1b4e;
--color-sidebar-accent-foreground: #f5f0ff;
--color-sidebar-border: #3d2a5c;
--color-sidebar-ring: #8b5cf6;
}
/* Renderer-Web wins on :root:not(.dark) / .dark specificity — match its layer. */
@layer base {
:root:not(.dark) {
--color-background: #f8f6ff;
--color-foreground: #1e1033;
--color-card: #ffffff;
--color-card-foreground: #1e1033;
--color-popover: #ffffff;
--color-popover-foreground: #1e1033;
--color-border: #ddd6fe;
--color-input: #ddd6fe;
--color-muted: #f0edfa;
--color-muted-foreground: #6b5b8a;
--color-secondary: #ede9fe;
--color-secondary-foreground: #1e1033;
--color-accent: #ede9fe;
--color-accent-foreground: #1e1033;
--color-destructive: #dc2626;
--color-destructive-foreground: #ffffff;
--color-primary: #7c3aed;
--color-primary-foreground: #ffffff;
--color-ring: #8b5cf6;
--color-sidebar: #f3f0ff;
--color-sidebar-foreground: #4c3d6b;
--color-sidebar-primary: #7c3aed;
--color-sidebar-primary-foreground: #ffffff;
--color-sidebar-accent: #ede9fe;
--color-sidebar-accent-foreground: #1e1033;
--color-sidebar-border: #ddd6fe;
--color-sidebar-ring: #8b5cf6;
}
.dark {
--color-background: #140a24;
--color-foreground: #f5f0ff;
--color-card: #1f1235;
--color-card-foreground: #f5f0ff;
--color-popover: #1f1235;
--color-popover-foreground: #f5f0ff;
--color-border: #3d2a5c;
--color-input: #3d2a5c;
--color-muted: #261740;
--color-muted-foreground: #c4b5fd;
--color-secondary: #261740;
--color-secondary-foreground: #f5f0ff;
--color-accent: #372460;
--color-accent-foreground: #f5f0ff;
--color-destructive: #f87171;
--color-destructive-foreground: #ffffff;
--color-primary: #a78bfa;
--color-primary-foreground: #140a24;
--color-ring: #8b5cf6;
--color-sidebar: #1a1030;
--color-sidebar-foreground: #e9e0ff;
--color-sidebar-primary: #a78bfa;
--color-sidebar-primary-foreground: #140a24;
--color-sidebar-accent: #2d1b4e;
--color-sidebar-accent-foreground: #f5f0ff;
--color-sidebar-border: #3d2a5c;
--color-sidebar-ring: #8b5cf6;
}
/* Guest invite page — warmer lavender canvas, same violet CTAs. */
:root:not(.dark) body.show-pony-public {
--color-background: #faf8ff;
--color-muted: #f3effc;
--color-border: #e4dcff;
}
.dark body.show-pony-public {
--color-background: #120820;
--color-card: #1c1030;
}
body.show-pony-public,
.show-pony-public.min-h-screen {
background-color: var(--color-background);
background-image:
radial-gradient(
ellipse 90% 55% at 50% -8%,
color-mix(in srgb, var(--color-primary) 16%, transparent),
transparent 70%
),
radial-gradient(
ellipse 45% 35% at 100% 20%,
color-mix(in srgb, var(--color-primary) 10%, transparent),
transparent 65%
),
radial-gradient(
ellipse 40% 30% at 0% 80%,
color-mix(in srgb, var(--color-primary) 8%, transparent),
transparent 60%
);
}
}
/* Public invite polish — CSS-only motion; respects prefers-reduced-motion. */
@layer components {
@keyframes sp-hero-zoom {
from {
transform: scale(1.1);
}
to {
transform: scale(1.16);
}
}
@keyframes sp-check-pop {
0% {
opacity: 0;
transform: scale(0.6);
}
70% {
transform: scale(1.08);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.show-pony-public .sp-hero-immersive {
box-shadow: 0 12px 20px -6px color-mix(in srgb, var(--color-foreground) 11%, transparent);
min-height: 360px;
}
@media (min-width: 640px) {
.show-pony-public .sp-hero-immersive {
min-height: 420px;
}
}
.show-pony-public .sp-hero-split {
position: relative;
z-index: 1;
overflow: hidden;
box-shadow: 0 12px 20px -6px color-mix(in srgb, var(--color-foreground) 11%, transparent);
}
.show-pony-public .sp-hero-split-row {
display: flex;
flex-direction: column;
min-height: 280px;
}
.show-pony-public .sp-hero-split-copy {
flex: 1 1 50%;
background: linear-gradient(
145deg,
var(--color-card) 0%,
color-mix(in srgb, var(--color-primary) 7%, var(--color-card)) 55%,
color-mix(in srgb, var(--color-primary) 5%, var(--color-muted)) 100%
);
}
.show-pony-public .sp-hero-split-media {
position: relative;
flex: 1 1 50%;
min-height: 280px;
}
@media (min-width: 1024px) {
.show-pony-public .sp-hero-split-row {
flex-direction: row;
min-height: 380px;
}
.show-pony-public .sp-hero-split-media {
min-height: 380px;
}
}
.show-pony-public .sp-hero-media {
overflow: hidden;
}
.show-pony-public .sp-hero-focus-bottom {
object-position: center 88%;
}
.show-pony-public .sp-hero-focus-bottom.sp-hero-ken-burns {
transform-origin: 50% 50%;
}
.show-pony-public .sp-hero-focus-right {
object-position: 72% center;
}
.show-pony-public .sp-hero-focus-right.sp-hero-ken-burns {
transform-origin: 72% 50%;
}
.show-pony-public .sp-hero-ken-burns {
min-width: 100%;
min-height: 100%;
transform: scale(1.1);
animation: sp-hero-zoom 22s ease-out forwards;
will-change: transform;
}
.show-pony-public .sp-hero-grain::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.14;
mix-blend-mode: soft-light;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.show-pony-public .sp-rsvp-success {
box-shadow: 0 1px 3px color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.show-pony-public .sp-invite-card {
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.show-pony-public .sp-invite-card:hover {
box-shadow: 0 8px 28px color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.show-pony-public .sp-rsvp-check {
animation: sp-check-pop 0.45s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.show-pony-public .sp-hero-ken-burns {
animation: none;
transform: scale(1.1);
}
.show-pony-public .sp-rsvp-check {
animation: none;
}
.show-pony-public .sp-invite-card {
transition: none;
}
}
}
/* Split invite canvas — unlayered fallback when inline styles are stripped. */
body.show-pony-public.sp-invite-split-page,
.show-pony-public.min-h-screen.sp-invite-split-page {
background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-background));
background-image:
radial-gradient(
ellipse 90% 60% at 0% 0%,
color-mix(in srgb, var(--color-primary) 38%, transparent),
transparent 55%
),
radial-gradient(
ellipse 75% 55% at 100% 100%,
color-mix(in srgb, var(--color-primary) 28%, transparent),
transparent 50%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary) 18%, var(--color-background)) 0%,
color-mix(in srgb, var(--color-primary) 10%, var(--color-background)) 50%,
var(--color-background) 95%
);
}
// show-pony i18n keys. Convention: feature prefix `showpony:`
// + `entity:<e>:field:<name>` for field labels (columns + form),
// `screen:<id>.title` for page titles, `showpony:nav.<id>` for
// sidebar labels, `:option:<value>` for select values.
type LocaleEntry = { de: string; en: string };
export const showPonyTranslations = {
"showpony:nav.events": { de: "Events", en: "Events" },
"showpony:nav.event-new": { de: "Neues Event", en: "New event" },
"showpony:nav.guests": { de: "Gästeliste", en: "Guest list" },
"showpony:nav.appearance": { de: "Erscheinungsbild", en: "Appearance" },
"showpony:nav.invite-branding": { de: "Invite-Branding", en: "Invite branding" },
"showpony:nav.account": { de: "Konto", en: "Account" },
"showpony:nav.billing": { de: "Tarif & Abrechnung", en: "Plan & billing" },
"screen:event-list.title": { de: "Events", en: "Events" },
"screen:event-edit.title": { de: "Event bearbeiten", en: "Edit event" },
"screen:rsvp-list.title": { de: "Gästeliste", en: "Guest list" },
"screen:invite-branding-settings.title": {
de: "Invite-Branding",
en: "Invite branding",
},
"screen:billing.title": { de: "Tarif & Abrechnung", en: "Plan & billing" },
"showpony:branding.section.identity": { de: "Marke", en: "Brand" },
"showpony:branding.section.hero": { de: "Hero-Bild", en: "Hero image" },
"showpony:entity:__config-edit__:field:title": { de: "Markenname", en: "Brand name" },
"showpony:entity:__config-edit__:field:description": {
de: "Tagline / Beschreibung",
en: "Tagline / description",
},
"showpony:entity:__config-edit__:field:accentColor": {
de: "Akzentfarbe",
en: "Accent color",
},
"showpony:entity:__config-edit__:field:logoUrl": { de: "Logo-URL", en: "Logo URL" },
"showpony:entity:__config-edit__:field:heroImageUrl": {
de: "Hero-Bild-URL",
en: "Hero image URL",
},
"showpony:entity:__config-edit__:field:heroStyle": { de: "Hero-Layout", en: "Hero layout" },
"showpony:entity:__config-edit__:field:heroStyle:option:immersive": {
de: "Immersiv (Vollbild)",
en: "Immersive (full bleed)",
},
"showpony:entity:__config-edit__:field:heroStyle:option:split": {
de: "Geteilt (Text + Bild)",
en: "Split (text + image)",
},
"showpony:section.event-basics": { de: "Eckdaten", en: "Basics" },
"showpony:section.event-details": { de: "Beschreibung", en: "Description" },
"showpony:entity:event:field:title": { de: "Titel", en: "Title" },
"showpony:entity:event:field:slug": { de: "Link-Kürzel", en: "Link slug" },
"showpony:entity:event:field:startsAt": { de: "Beginn", en: "Starts at" },
"showpony:entity:event:field:location": { de: "Ort", en: "Location" },
"showpony:entity:event:field:description": { de: "Beschreibung", en: "Description" },
"showpony:entity:event:field:guestLimit": { de: "Gäste-Limit", en: "Guest limit" },
"showpony:entity:rsvp:field:name": { de: "Name", en: "Name" },
"showpony:entity:rsvp:field:email": { de: "E-Mail", en: "Email" },
"showpony:entity:rsvp:field:status": { de: "Status", en: "Status" },
"showpony:entity:rsvp:field:plusN": { de: "Begleitung", en: "Plus guests" },
"showpony:entity:rsvp:field:eventId": { de: "Event", en: "Event" },
"showpony:entity:rsvp:field:note": { de: "Notiz", en: "Note" },
"showpony:entity:rsvp:field:status:option:yes": { de: "Zusage", en: "Going" },
"showpony:entity:rsvp:field:status:option:no": { de: "Absage", en: "Not going" },
"showpony:entity:rsvp:field:status:option:maybe": { de: "Vielleicht", en: "Maybe" },
"showpony:public.event.missing": {
de: "Dieses Event gibt es nicht.",
en: "This event doesn't exist.",
},
"showpony:public.event.invited": { de: "Du bist eingeladen", en: "You're invited" },
"showpony:public.event.guest-limit": {
de: "Bis zu {limit} Gäste",
en: "Up to {limit} guests",
},
"showpony:public.event.add-to-calendar": {
de: "📅 Zum Kalender hinzufügen",
en: "📅 Add to calendar",
},
"showpony:public.rsvp.thanks": { de: "Danke, {name}!", en: "Thanks, {name}!" },
"showpony:public.rsvp.heading": { de: "RSVP", en: "RSVP" },
"showpony:public.rsvp.subheading": {
de: "Kein Account nötig — Name reicht.",
en: "No account needed — just your name.",
},
"showpony:public.rsvp.on-list": { de: "Du stehst auf der Liste.", en: "You're on the list." },
"showpony:public.rsvp.plus-guests": { de: "Begleitung?", en: "Bringing anyone?" },
"showpony:public.rsvp.name": { de: "Name", en: "Name" },
"showpony:public.rsvp.name-placeholder": { de: "Dein Name", en: "Your name" },
"showpony:public.rsvp.email": { de: "E-Mail (optional)", en: "Email (optional)" },
"showpony:public.rsvp.email-placeholder": {
de: "E-Mail (optional, für die Bestätigung)",
en: "Email (optional, for your confirmation)",
},
"showpony:public.rsvp.status.yes": { de: "Ich komme", en: "I'm in" },
"showpony:public.rsvp.status.maybe": { de: "Vielleicht", en: "Maybe" },
"showpony:public.rsvp.status.no": { de: "Kann leider nicht", en: "Can't make it" },
"showpony:public.rsvp.submit": { de: "RSVP senden", en: "Send RSVP" },
"showpony:public.rsvp.error": {
de: "Etwas ist schiefgelaufen ({reason}). Bitte nochmal versuchen.",
en: "Something went wrong ({reason}). Try again.",
},
"showpony:demo.login.title": { de: "Live-Demo", en: "Live demo" },
"showpony:demo.login.body": {
de: "Melde dich an, um Dashboard und Plattform-Workspace zu erkunden:",
en: "Sign in to explore the host dashboard and platform workspace:",
},
"showpony:demo.login.read-only": {
de: "Nur ansehen — Änderungen werden auf dieser Instanz nicht gespeichert.",
en: "Browse only — changes are not saved on this instance.",
},
"showpony:demo.banner": {
de: "Live-Demo (read-only). Zum Bauen lokal klonen —",
en: "Live demo (read-only). Clone locally to build your own —",
},
"showpony:demo.banner.link": { de: "Tutorial", en: "tutorial" },
"showpony:demo.public.title": { de: "Live-Demo", en: "Live demo" },
"showpony:demo.public.body": {
de: "RSVPs funktionieren nur in der lokalen Installation. Hier kannst du die Invite-Seite ansehen.",
en: "RSVPs work in the local install only. Here you can browse the invite page.",
},
"showpony:demo.public.host-link": { de: "Host-Login", en: "Host login" },
"showpony:demo.public.tutorial-link": { de: "Tutorial", en: "Tutorial" },
"showpony:demo.read-only.error": {
de: "Live-Demo ist read-only — lokal klonen zum Ausprobieren.",
en: "Live demo is read-only — clone locally to try writes.",
},
"showpony:caps.usageTitle": { de: "Verbrauch", en: "Usage" },
"showpony:caps.events": { de: "Events", en: "Events" },
"showpony:caps.guests": { de: "RSVPs", en: "RSVPs" },
"showpony:caps.unlimited": { de: "(unbegrenzt)", en: "(unlimited)" },
"showpony:caps.upgradeHint": { de: "Tarif upgraden", en: "Upgrade plan" },
"showpony:billing.title": { de: "Tarif & Abrechnung", en: "Plan & billing" },
"showpony:billing.currentTier": { de: "Aktueller Tarif", en: "Current plan" },
"showpony:billing.notConfigured": {
de: "Stripe-Checkout ist auf dieser Instanz nicht live (billingLive aus oder keine API-Keys).",
en: "Stripe checkout is not live on this instance (billingLive off or no API keys).",
},
"showpony:billing.upgradeTo": { de: "Wechseln zu", en: "Switch to" },
"showpony:billing.perMonth": { de: "€ / Monat", en: "€ / month" },
"showpony:billing.unlimited": { de: "Unbegrenzt", en: "Unlimited" },
"showpony:billing.benefit.events": { de: "Events", en: "events" },
"showpony:billing.benefit.guests": { de: "RSVPs gesamt", en: "total RSVPs" },
"showpony:billing.managePortal": { de: "Abrechnung verwalten", en: "Manage billing" },
"showpony:billing.portalHint": {
de: "Rechnungen und Zahlungsmittel verwaltest du im Stripe-Portal.",
en: "Manage invoices and payment methods in the Stripe portal.",
},
"showpony:billing.redirecting": {
de: "Weiterleitung zu Stripe …",
en: "Redirecting to Stripe …",
},
"showpony:billing.error": { de: "Checkout fehlgeschlagen", en: "Checkout failed" },
"showpony:billing.status.active": { de: "aktiv", en: "active" },
"showpony:billing.status.trialing": { de: "Testphase", en: "trial" },
"showpony:billing.status.past_due": { de: "Zahlung überfällig", en: "past due" },
"showpony:billing.status.canceled": { de: "gekündigt", en: "canceled" },
"showpony:billing.status.incomplete": { de: "unvollständig", en: "incomplete" },
"showpony:billing.status.incomplete_expired": {
de: "abgelaufen",
en: "expired",
},
"showpony:billing.status.unpaid": { de: "unbezahlt", en: "unpaid" },
"showpony:billing.status.paused": { de: "pausiert", en: "paused" },
"showpony:billing.switchInPortal": { de: "Im Portal wechseln zu", en: "Switch in portal to" },
"showpony:errors.eventLimitReached": {
de: "Event-Limit erreicht — Tarif upgraden.",
en: "Event limit reached — upgrade your plan.",
},
"showpony:errors.guestLimitReached": {
de: "Die Gästeliste ist voll.",
en: "The guest list is full.",
},
} as const satisfies Record<string, LocaleEntry>;

📄 On GitHub: src/styles.css · src/features/show-pony/i18n/keys.ts