diff --git a/apps/web/src/components/branding/branding-form.tsx b/apps/web/src/components/branding/branding-form.tsx
index e22a2d2..aefddf6 100644
--- a/apps/web/src/components/branding/branding-form.tsx
+++ b/apps/web/src/components/branding/branding-form.tsx
@@ -5,6 +5,7 @@ import { Button, Card, Field, Input, Textarea } from "@msk-forms/ui";
import { useRouter } from "next/navigation";
import { useState } from "react";
+import { CssPreview } from "@/components/branding/css-preview";
import type { Dictionary } from "@/i18n";
type BrandingDict = Dictionary["branding"];
@@ -12,6 +13,21 @@ type BrandingDict = Dictionary["branding"];
const DEFAULT_ACCENT = "#5eb131";
const HEX = /^#[0-9a-fA-F]{6}$/;
+/**
+ * Beginner-friendly starter rules. Each targets `.msk-form` or a CSS variable
+ * so it behaves identically in the preview and on the live page (the two
+ * documented, stable hooks). `key` maps to a localized label.
+ */
+const CSS_SNIPPETS: { key: keyof BrandingDict["snip"]; css: string }[] = [
+ { key: "rounded", css: ".msk-form {\n --radius: 1.25rem;\n}" },
+ {
+ key: "tint",
+ css: ".msk-form {\n background: hsl(var(--primary) / 0.05);\n padding: 1.5rem;\n border-radius: 1rem;\n}",
+ },
+ { key: "text", css: ".msk-form {\n font-size: 1.05rem;\n}" },
+ { key: "heading", css: ".msk-form h1 {\n color: hsl(var(--primary));\n font-weight: 800;\n}" },
+];
+
export function BrandingForm({
guildId,
initial,
@@ -58,6 +74,11 @@ export function BrandingForm({
}
}
+ function addSnippet(snippet: string) {
+ setCss((prev) => (prev.trim() ? `${prev.trimEnd()}\n\n${snippet}` : snippet));
+ setSaved(false);
+ }
+
const swatch = HEX.test(color.trim()) ? color.trim() : DEFAULT_ACCENT;
return (
@@ -101,17 +122,51 @@ export function BrandingForm({
{canCss ? (
-
) : (
diff --git a/apps/web/src/components/branding/css-preview.tsx b/apps/web/src/components/branding/css-preview.tsx
new file mode 100644
index 0000000..87795d8
--- /dev/null
+++ b/apps/web/src/components/branding/css-preview.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { sanitizeCustomCss } from "@msk-forms/shared";
+import { useTheme } from "next-themes";
+import { useMemo } from "react";
+
+import { hexToHslChannels } from "@/lib/branding";
+
+const HEX = /^#[0-9a-fA-F]{6}$/;
+
+export interface CssPreviewLabels {
+ guild: string;
+ title: string;
+ desc: string;
+ field: string;
+ field2: string;
+ submit: string;
+}
+
+/**
+ * Base stylesheet for the preview document. Mirrors the public form's token
+ * system (the same `--primary`, `--card`, `--radius` … names and light/dark
+ * values from globals.css) and the `.msk-form` wrapper, so custom CSS that
+ * targets `.msk-form` or overrides a CSS variable behaves here exactly as it
+ * will on the live page. Deliberately self-contained (no Tailwind) so the
+ * preview renders in an isolated iframe.
+ */
+const BASE_CSS = `
+:root{--background:0 0% 96.5%;--foreground:0 0% 10%;--card:0 0% 100%;--card-foreground:0 0% 10%;--primary:101 62% 40%;--primary-foreground:0 0% 100%;--secondary:0 0% 93%;--muted-foreground:0 0% 42%;--border:0 0% 88%;--input:0 0% 88%;--ring:101 62% 40%;--radius:0.75rem;}
+.dark{--background:0 0% 9%;--foreground:0 0% 97%;--card:0 0% 12%;--card-foreground:0 0% 97%;--primary:99 57% 44%;--primary-foreground:0 0% 100%;--secondary:0 0% 17%;--muted-foreground:0 0% 66%;--border:0 0% 20%;--input:0 0% 23%;--ring:99 57% 44%;}
+*{box-sizing:border-box;}
+html,body{margin:0;}
+body{background:hsl(var(--background));color:hsl(var(--foreground));font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased;padding:1.25rem;}
+.msk-form{max-width:42rem;margin:0 auto;display:flex;flex-direction:column;gap:1.25rem;}
+.msk-form .brand{font-size:.875rem;font-weight:500;color:hsl(var(--primary));}
+.msk-form h1{font-size:1.75rem;font-weight:700;margin:.25rem 0 0;color:hsl(var(--foreground));}
+.msk-form .desc{color:hsl(var(--muted-foreground));margin:.25rem 0 0;}
+.msk-form .card{border:1px solid hsl(var(--border));background:hsl(var(--card));border-radius:var(--radius);padding:1.5rem;box-shadow:0 1px 2px rgba(0,0,0,.06);display:flex;flex-direction:column;gap:1rem;}
+.msk-form label{display:block;font-size:.875rem;font-weight:500;margin-bottom:.375rem;color:hsl(var(--foreground));}
+.msk-form input,.msk-form textarea{width:100%;border:1px solid hsl(var(--input));background:hsl(var(--background));border-radius:calc(var(--radius) - .25rem);padding:.5rem .75rem;font:inherit;color:inherit;}
+.msk-form .btn{align-self:flex-start;background:hsl(var(--primary));color:hsl(var(--primary-foreground));border:none;border-radius:calc(var(--radius) - .25rem);padding:.55rem 1.1rem;font-weight:500;font-size:.9rem;cursor:default;}
+`;
+
+/** Escape text for safe interpolation into the preview HTML. */
+function esc(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/**
+ * Live, isolated preview of a guild's public form with the current accent color
+ * and custom CSS applied. Rendered in a sandboxed iframe so the CSS can't leak
+ * into the dashboard or run scripts; the CSS is sanitized the same way it is on
+ * save/render, so what you see matches the live page.
+ */
+export function CssPreview({
+ css,
+ accentColor,
+ labels,
+}: {
+ css: string;
+ accentColor: string;
+ labels: CssPreviewLabels;
+}) {
+ const { resolvedTheme } = useTheme();
+ const dark = resolvedTheme === "dark";
+
+ const srcDoc = useMemo(() => {
+ const safe = sanitizeCustomCss(css);
+ const trimmed = accentColor.trim();
+ const channels = HEX.test(trimmed) ? hexToHslChannels(trimmed) : null;
+ const accentStyle = channels ? ` style="--primary:${channels};--ring:${channels}"` : "";
+ return `${esc(labels.guild)}
${esc(labels.title)}
${esc(labels.desc)}
`;
+ }, [css, accentColor, dark, labels]);
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts
index 1e07f01..ac61bb6 100644
--- a/apps/web/src/i18n/dictionaries.ts
+++ b/apps/web/src/i18n/dictionaries.ts
@@ -328,6 +328,10 @@ const en = {
logoUploading: "Uploading…", logoRemove: "Remove logo", logoFailed: "Could not update the logo.",
customCss: "Custom CSS",
customCssHint: "Applied to your public form and status pages. Style classes like .msk-form or override CSS variables. Scripts and @import are stripped.",
+ cssSnippets: "Quick styles", cssSnippetsHint: "Add a starter rule, then tweak the values.",
+ cssPreviewTitle: "Live preview", cssPreviewNote: "A sample of your public form. Your accent color and CSS apply here instantly. Nothing is saved until you press Save.",
+ snip: { rounded: "Rounded corners", tint: "Accent background", text: "Larger text", heading: "Colored heading" },
+ sample: { guild: "Your community", title: "Application form", desc: "Tell us a bit about yourself.", field: "Your name", field2: "Why do you want to join?", submit: "Submit" },
},
botConfig: {
title: "Bot settings",
@@ -869,6 +873,10 @@ const de: Dictionary = {
logoUploading: "Wird hochgeladen…", logoRemove: "Logo entfernen", logoFailed: "Logo konnte nicht aktualisiert werden.",
customCss: "Eigenes CSS",
customCssHint: "Wird auf deinen öffentlichen Formular- und Status-Seiten angewendet. Style Klassen wie .msk-form oder überschreibe CSS-Variablen. Skripte und @import werden entfernt.",
+ cssSnippets: "Schnellstile", cssSnippetsHint: "Füge eine Vorlage ein und passe die Werte an.",
+ cssPreviewTitle: "Live-Vorschau", cssPreviewNote: "Ein Muster deines öffentlichen Formulars. Akzentfarbe und CSS werden hier sofort angewendet. Gespeichert wird erst mit Klick auf Speichern.",
+ snip: { rounded: "Runde Ecken", tint: "Akzent-Hintergrund", text: "Größerer Text", heading: "Farbige Überschrift" },
+ sample: { guild: "Deine Community", title: "Bewerbungsformular", desc: "Erzähl uns etwas über dich.", field: "Dein Name", field2: "Warum möchtest du beitreten?", submit: "Absenden" },
},
botConfig: {
title: "Bot-Einstellungen",
@@ -1408,6 +1416,10 @@ const hu: Dictionary = {
logoUploading: "Feltöltés…", logoRemove: "Logó eltávolítása", logoFailed: "A logó frissítése sikertelen.",
customCss: "Egyéni CSS",
customCssHint: "A nyilvános űrlap- és állapotoldalakon érvényesül. Stílusozz osztályokat, pl. .msk-form, vagy írj felül CSS-változókat. A szkriptek és az @import eltávolításra kerülnek.",
+ cssSnippets: "Gyors stílusok", cssSnippetsHint: "Adj hozzá egy kezdő szabályt, majd finomítsd az értékeket.",
+ cssPreviewTitle: "Élő előnézet", cssPreviewNote: "Egy minta a nyilvános űrlapodról. A kiemelőszín és a CSS azonnal érvényesül itt. Semmi nem mentődik el a Mentés gombig.",
+ snip: { rounded: "Lekerekített sarkok", tint: "Kiemelő háttér", text: "Nagyobb szöveg", heading: "Színes címsor" },
+ sample: { guild: "A közösséged", title: "Jelentkezési űrlap", desc: "Mesélj egy kicsit magadról.", field: "A neved", field2: "Miért szeretnél csatlakozni?", submit: "Küldés" },
},
botConfig: {
title: "Bot beállítások",
@@ -1947,6 +1959,10 @@ const fr: Dictionary = {
logoUploading: "Chargement…", logoRemove: "Supprimer le logo", logoFailed: "Impossible de mettre à jour le logo.",
customCss: "CSS personnalisé",
customCssHint: "Appliqué sur vos pages de formulaire et de statut publiques. Stylisez des classes comme .msk-form ou remplacez des variables CSS. Les scripts et @import sont supprimés.",
+ cssSnippets: "Styles rapides", cssSnippetsHint: "Ajoutez une règle de départ, puis ajustez les valeurs.",
+ cssPreviewTitle: "Aperçu en direct", cssPreviewNote: "Un exemple de votre formulaire public. Votre couleur d'accent et votre CSS s'appliquent ici instantanément. Rien n'est enregistré avant de cliquer sur Enregistrer.",
+ snip: { rounded: "Coins arrondis", tint: "Fond d'accent", text: "Texte plus grand", heading: "Titre coloré" },
+ sample: { guild: "Votre communauté", title: "Formulaire de candidature", desc: "Parlez-nous un peu de vous.", field: "Votre nom", field2: "Pourquoi voulez-vous nous rejoindre ?", submit: "Envoyer" },
},
botConfig: {
title: "Paramètres du bot",
@@ -2487,6 +2503,10 @@ const es: Dictionary = {
logoUploading: "Subiendo…", logoRemove: "Eliminar logo", logoFailed: "No se pudo actualizar el logo.",
customCss: "CSS personalizado",
customCssHint: "Se aplica a tus páginas públicas de formulario y estado. Estiliza clases como .msk-form o sobrescribe variables CSS. Los scripts y @import se eliminan.",
+ cssSnippets: "Estilos rápidos", cssSnippetsHint: "Añade una regla inicial y luego ajusta los valores.",
+ cssPreviewTitle: "Vista previa en vivo", cssPreviewNote: "Una muestra de tu formulario público. Tu color de acento y tu CSS se aplican aquí al instante. No se guarda nada hasta que pulses Guardar.",
+ snip: { rounded: "Esquinas redondeadas", tint: "Fondo de acento", text: "Texto más grande", heading: "Título con color" },
+ sample: { guild: "Tu comunidad", title: "Formulario de solicitud", desc: "Cuéntanos un poco sobre ti.", field: "Tu nombre", field2: "¿Por qué quieres unirte?", submit: "Enviar" },
},
botConfig: {
title: "Configuración del bot",
@@ -3027,6 +3047,10 @@ const pt: Dictionary = {
logoUploading: "Enviando…", logoRemove: "Remover logo", logoFailed: "Não foi possível atualizar o logo.",
customCss: "CSS personalizado",
customCssHint: "Aplicado às suas páginas públicas de formulário e status. Estilize classes como .msk-form ou substitua variáveis CSS. Scripts e @import são removidos.",
+ cssSnippets: "Estilos rápidos", cssSnippetsHint: "Adicione uma regra inicial e depois ajuste os valores.",
+ cssPreviewTitle: "Pré-visualização ao vivo", cssPreviewNote: "Uma amostra do seu formulário público. Sua cor de destaque e seu CSS são aplicados aqui na hora. Nada é salvo até você clicar em Salvar.",
+ snip: { rounded: "Cantos arredondados", tint: "Fundo de destaque", text: "Texto maior", heading: "Título colorido" },
+ sample: { guild: "Sua comunidade", title: "Formulário de inscrição", desc: "Conte um pouco sobre você.", field: "Seu nome", field2: "Por que você quer entrar?", submit: "Enviar" },
},
botConfig: {
title: "Configurações do bot",
@@ -3567,6 +3591,10 @@ const pl: Dictionary = {
logoUploading: "Przesyłanie…", logoRemove: "Usuń logo", logoFailed: "Nie udało się zaktualizować logo.",
customCss: "Własne CSS",
customCssHint: "Stosowane na publicznych stronach formularzy i statusów. Stylizuj klasy jak .msk-form lub nadpisuj zmienne CSS. Skrypty i @import są usuwane.",
+ cssSnippets: "Szybkie style", cssSnippetsHint: "Dodaj gotową regułę, a potem dostosuj wartości.",
+ cssPreviewTitle: "Podgląd na żywo", cssPreviewNote: "Przykład twojego publicznego formularza. Kolor akcentu i CSS są tu stosowane od razu. Nic nie zostaje zapisane, dopóki nie klikniesz Zapisz.",
+ snip: { rounded: "Zaokrąglone rogi", tint: "Tło akcentu", text: "Większy tekst", heading: "Kolorowy nagłówek" },
+ sample: { guild: "Twoja społeczność", title: "Formularz zgłoszeniowy", desc: "Opowiedz nam trochę o sobie.", field: "Twoje imię", field2: "Dlaczego chcesz dołączyć?", submit: "Wyślij" },
},
botConfig: {
title: "Ustawienia bota",