| name | brand-config-authoring |
| description | Create and validate BrandConfig JSON files that define the complete visual identity for HTML slide presentations. Use when setting up a new brand, onboarding a new client, or debugging styling issues in the frontend-slides system. |
Brand Config Authoring
Create BrandConfig JSON files that define the complete visual identity for the frontend-slides presentation system. The server-side enrichment pipeline uses the BrandConfig to wrap body-only HTML (generated by an LLM) with full CSS, JS, fonts, logos, and chrome.
Where things live
- Pydantic schema:
storyline/storyline/api/schemas/report_style_schemas.py (source of truth)
- Working examples:
storyline/playground/styles/samplead/samplead-save-style-args.json (most complete)
storyline/playground/styles/evalart/evalart-save-style-args.json
storyline/playground/styles/cledara/cledara-save-style-args.json
Common Pitfalls
These are real bugs discovered during development. Every one of them caused broken presentations in production. Read this section BEFORE writing any config.
Pitfall 1: reset_and_base missing body/heading typography
Wrong -- bare reset with no font or scroll-snap declarations:
* { margin: 0; padding: 0; box-sizing: border-box; }
Right -- must include html scroll-snap, body font, and heading font rules at minimum:
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow-x: hidden; }
html { scroll-snap-type: y mandatory; scroll-behavior: smooth; }
.slide { width: 100vw; height: 100vh; height: 100dvh; overflow: hidden;
scroll-snap-align: start; display: flex; flex-direction: column;
position: relative; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
html { scroll-behavior: auto; }
}
body { font-family: var(--font-body); font-size: var(--body-size);
line-height: 1.4; color: var(--text-primary); background: var(--bg-deep);
overflow-x: hidden; }
h1, h2, h3 { font-family: var(--font-display); font-weight: 700; line-height: 1.15; }
h1 { font-size: var(--title-size); }
h2 { font-size: var(--h2-size); }
Without this, no fonts are applied and text renders unstyled.
Pitfall 2: Slide types with null css_block
Wrong -- css_block set to null:
{
"name": "Content Slide",
"css_class": "slide slide-content",
"css_block": null
}
Right -- every slide type MUST have a populated css_block with at minimum its background and text styling:
{
"name": "Content Slide",
"css_class": "slide slide-content",
"css_block": ".slide-content { background: var(--bg-white); color: var(--text-primary); padding: var(--sp); } .slide-content h2 { color: var(--brand-primary); margin-bottom: var(--sp-sm); }"
}
Without this, slides have no background, no text colors, no layout.
Pitfall 3: stagger_delay must be a simple CSS duration
Wrong -- descriptive string:
"stagger_delay": "0.09s per child (nth-child(1): 0.04s, nth-child(2): 0.13s, ...)"
Right -- a bare CSS duration value only:
"stagger_delay": "0.09s"
The server parses this as a float and generates nth-child rules automatically. Anything other than a pattern matching ^\d+(\.\d+)?(s|ms)$ will crash the validator. Valid examples: "0.1s", "100ms", "0.09s".
Pitfall 4: Chart containers need explicit height
Wrong -- flex-based centering without explicit height:
.chart-container { flex: 1; min-height: 0; display: flex;
align-items: center; justify-content: center; }
Right -- explicit height, no flex centering:
.chart-container { width: 100%; height: min(60vh, 500px); position: relative; }
eCharts needs an explicit pixel/viewport height to render its canvas. Flex centering conflicts with the canvas positioning.
Pitfall 5: Table overflow must be hidden
Wrong -- auto overflow causes scrollbars inside slides:
.tbl-wrap { overflow-y: auto; overflow-x: auto; }
Right -- hidden overflow, size tables to fit:
.tbl-wrap { overflow: hidden; }
Tables should be sized to fit the slide. If a table does not fit, reduce rows or split across slides. Never scroll.
Pitfall 6: brand_tokens_css must define ALL referenced CSS variables
Every CSS variable used anywhere in any css_block must be defined in shared_css.brand_tokens_css. If a slide type css_block references var(--font-body), var(--bg-gradient), or var(--text-primary), and those variables are not in brand_tokens_css, the styling breaks silently -- CSS treats undefined variables as empty strings.
Checklist: After writing the config, search all css_block values, reset_and_base, utility_classes_css, and responsive_overrides_css for var(-- references. Every variable name found must appear in brand_tokens_css.
Pitfall 7: controller_js can be null
If javascript.controller_js is null, the server uses a built-in default navigation controller. This is fine and often preferred.
If you DO provide a custom controller_js, it must be a complete, self-contained script that handles:
- IntersectionObserver to add
.visible class to slides
- Keyboard navigation (arrow keys, Space, Page Up/Down)
- Touch and wheel navigation
- Progress bar updates (if
chrome.has_progress_bar is true)
- Nav dot updates (if
chrome.has_nav_dots is true)
Pitfall 8: Logo SVG must use currentColor
Wrong -- hardcoded fill colors prevent color switching on different backgrounds:
<path d="M10 20..." fill="#6D22E0"/>
Right -- use currentColor so CSS controls the fill:
<path d="M10 20..." fill="currentColor"/>
Then define color variants in CSS:
.logo.on-light { color: #6D22E0; }
.logo.on-dark { color: rgba(255,255,255,0.9); }
Set uses_current_color: true in the logo config. If the SVG has multiple colors that cannot be simplified to currentColor, set uses_current_color: false and skip color_variants.
Pitfall 9: Table CSS must use colors visible on the table's background
The server resolves data-table-block containers by injecting <table> elements with the brand's table_css_class. The table CSS must use colors that are readable against the background where tables actually render — NOT the global theme colors.
Wrong — dark-theme brand using global color tokens for tables that render on a white panel:
.dtbl th { color: var(--text-muted); }
.dtbl td { color: var(--text-primary); }
Right — use colors appropriate for the table's actual background context:
.dtbl th { color: #6B7280; }
.dtbl td { color: var(--bg-deep); }
.dtbl td { border-bottom: 1px solid #E5E7EB; }
Rule: If your brand is dark-themed (global --text-primary is white/light) but tables appear on light panels (e.g. inside .content-card on a white .split-light panel), the table CSS must use dark text colors — either hardcoded hex values or dark-side brand tokens like var(--bg-deep). Check where tables will actually be placed and style accordingly.
Pitfall 10: html_template must demonstrate proper class usage
The LLM sees the css_block in the slim config and SHOULD use the CSS classes defined there, but it tends to write inline styles instead. The html_template field on each slide type is the primary way to steer the LLM toward correct class usage.
Wrong -- template with no class examples:
<section class="slide slide-data"><div class="content">...</div></section>
Right -- template showing exact class names for tables, charts, metrics:
<section class="slide wslide" id="slide-N">
<div class="topbar"><div class="logo on-white rv"></div><span class="pg-num rv">03 / 07</span></div>
<div class="s-hdr rv"><span class="sect-bar"></span><span class="s-title">Section Title</span></div>
<div class="tbl-wrap rv">
<table class="dtbl">
<thead><tr><th>Name</th><th class="n">Value</th></tr></thead>
<tbody><tr><td>Row</td><td class="n hi">42</td></tr></tbody>
</table>
</div>
</section>
BrandConfig Structure Reference
File wrapper
The JSON file that gets uploaded has this outer structure:
{
"scope": "organization",
"style_name": "my-brand",
"payload": {
"brand_name": "...",
"brand_description": "...",
...all BrandConfig fields...
}
}
scope is either "organization" (org-wide default) or "user" (user-specific override).
Top-level fields
| Field | Type | Required | Description |
|---|
brand_name | string | Yes | Display name of the brand |
brand_description | string | Yes | 1-2 sentence description of the visual style |
colors | ColorPalette | Yes | Complete color system |
typography | Typography | Yes | Font definitions and settings |
logo | LogoConfig | Yes | SVG logo with color/size variants |
slide_types | SlideTypeSystem | Yes | All slide archetypes |
decorative_elements | list | No | Brand-specific visual motifs (waves, tiles, grids) |
animations | AnimationConfig | Yes | Allowed animation patterns |
footer | FooterConfig | Yes | Footer appearance |
chrome | ChromeConfig | No | Navigation chrome (progress bar, nav dots, topbar) |
shared_css | SharedCSS | No | CSS that applies across all slides |
javascript | JavaScriptConfig | No | Navigation controller and counter animations |
workflow | WorkflowConfig | No | Which workflow phases are enabled |
default_chart_color_scheme | string | No | Named color scheme for eCharts |
table_css_class | string | No | CSS class applied to server-resolved <table> elements (e.g. "dtbl", "data-table") |
forbidden_patterns | list[string] | No | Rules the LLM must never violate |
colors (ColorPalette)
{
"tokens": [
{ "name": "brand-blue", "value": "#016FFF", "usage": "Primary brand color for headings and accents" },
{ "name": "text-primary", "value": "#1e1b4b", "usage": "Main body text color" },
{ "name": "bg-deep", "value": "#0A0F1C", "usage": "Dark slide backgrounds" }
],
"gradients": [
{ "name": "brand", "css_value": "linear-gradient(135deg, #016FFF 0%, #7C3AED 100%)", "usage": "Cover slide background" }
],
"gradient_direction_constraint": null
}
Each token becomes a CSS variable --<name> in brand_tokens_css. The usage field helps the LLM choose the right color for each context.
typography (Typography)
{
"fonts": [
{ "role": "display", "family": "'Space Grotesk', sans-serif", "weights": [700, 800], "source": "google_fonts" },
{ "role": "body", "family": "'Inter', sans-serif", "weights": [400, 500, 600], "source": "google_fonts" },
{ "role": "mono", "family": "'JetBrains Mono', monospace", "weights": [400], "source": "google_fonts" }
],
"import_html": "<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@700;800&family=Inter:wght@400;500;600&display=swap\" rel=\"stylesheet\">",
"heading_letter_spacing": "-0.022em",
"body_line_height": 1.62
}
role: "display" (headings), "body" (paragraph text), "mono" (code/data)
source: "google_fonts", "fontshare", or "system"
import_html: the <link> tag for loading the fonts. Set to null for system fonts.
logo (LogoConfig)
{
"primary_svg": "<svg width=\"80\" height=\"15\" viewBox=\"...\" fill=\"none\" xmlns=\"...\"><path d=\"...\" fill=\"currentColor\"/></svg>",
"icon_only_svg": null,
"uses_current_color": true,
"color_variants": [
{ "on_background": "light", "css_color": "var(--brand-blue)", "css_class": "on-light" },
{ "on_background": "dark", "css_color": "rgba(255,255,255,0.9)", "css_class": "on-dark" }
],
"size_variants": [
{ "context": "topbar", "css_width": "80px" },
{ "context": "cover", "css_width": "clamp(250px, 45vw, 550px)" },
{ "context": "closing", "css_width": "80px" }
],
"placement_css": null
}
primary_svg: full inline SVG markup, not a URL. Must use fill="currentColor" if uses_current_color is true (see Pitfall 8).
color_variants: how the logo appears on light, dark, and gradient backgrounds. The css_class is used in HTML: <div class="logo on-light"><!-- logo --></div>.
size_variants: width for each placement context (topbar, cover, closing, footer).
slide_types (SlideTypeSystem)
{
"types": [
{
"name": "Cover",
"css_class": "slide slide-cover",
"background_kind": "gradient",
"background_value": "var(--grad-brand)",
"layout_description": "Full-bleed gradient background with centered logo and title",
"when_to_use": "Always the first slide",
"has_decorative_elements": false,
"has_footer": false,
"logo_placement": "center-top",
"logo_variant": "on-dark",
"css_block": ".slide-cover { background: var(--grad-brand); color: #fff; ... }",
"html_template": "<section class=\"slide slide-cover\" id=\"slide-1\">..."
}
],
"first_slide_type": "slide slide-cover",
"last_slide_type": "slide slide-closing"
}
Key rules:
first_slide_type and last_slide_type must match the css_class of a type in the types array.
- Every type MUST have a non-null
css_block (see Pitfall 2).
background_kind: one of "solid_color", "gradient", "gradient_split", "white".
html_template: example HTML that demonstrates proper class usage for the LLM (see Pitfall 9).
decorative_elements
[
{
"name": "Wave Footer",
"description": "An SVG wave that sits at the bottom of white slides",
"css_block": ".wave { position: absolute; bottom: 0; left: 0; width: 100%; ... }",
"html_template": "<div class=\"wave\"><!-- wave --></div>",
"variation_instructions": "Each wave must use a unique SVG gradient ID",
"applies_to_slide_types": ["slide wslide"]
}
]
applies_to_slide_types: list of css_class values this element should appear on. Empty list means all slides.
html_template: must include the marker the server replaces (e.g. <!-- wave -->).
animations (AnimationConfig)
{
"presets": [
{ "name": "Reveal Up", "css_class": "rv", "css_block": ".rv { opacity: 0; transform: translateY(40px); ... }" },
{ "name": "Reveal Left", "css_class": "rv-l", "css_block": ".rv-l { opacity: 0; transform: translateX(-40px); ... }" }
],
"stagger_delay": "0.09s",
"forbidden_effects": ["bounce", "spin", "3D transforms", "parallax scroll", "background video"],
"general_guidance": "All animations are reveal-on-scroll only, triggered by IntersectionObserver adding .visible class."
}
stagger_delay: MUST be a bare CSS duration like "0.1s" or "100ms" (see Pitfall 3).
css_block on each preset: the full CSS rule. The server injects this; the LLM only needs the class name.
forbidden_effects: the LLM is told to never use these.
footer (FooterConfig)
{
"kind": "svg_wave",
"text_content": null,
"css_block": ".wave { position: absolute; bottom: 0; ... }",
"html_template": "<div class=\"wave\"><!-- wave --></div>",
"variation_instructions": "Each wave needs a unique SVG gradient ID (wf1, wf2, wf3...)"
}
kind: "text" (static text footer), "svg_wave" (decorative SVG wave), or "none".
chrome (ChromeConfig)
{
"has_progress_bar": true,
"progress_bar_css": ".progress-bar { ... }",
"has_nav_dots": true,
"nav_dots_css": ".nav-dots { ... }",
"has_topbar": true,
"topbar_css": ".topbar { ... }",
"topbar_html": "<div class=\"topbar\">...</div>"
}
Boolean flags tell the server what chrome to inject. CSS/HTML fields define the styling. All are optional -- defaults are used if omitted.
shared_css (SharedCSS)
{
"reset_and_base": "* { margin: 0; ... } html { scroll-snap-type: ... } body { font-family: ... } h1, h2, h3 { ... }",
"brand_tokens_css": ":root { --brand-blue: #016FFF; --text-primary: #1e1b4b; ... }",
"utility_classes_css": ".visually-hidden { ... }",
"responsive_overrides_css": "@media (max-height: 700px) { ... }"
}
reset_and_base: MUST include full typography setup (see Pitfall 1).
brand_tokens_css: MUST define every CSS variable referenced anywhere in the config (see Pitfall 6).
- Both are injected server-side into the final HTML
<style> block.
javascript (JavaScriptConfig)
{
"controller_js": null,
"has_counter_animation": true
}
controller_js: set to null to use the built-in default controller (see Pitfall 7). If provided, must be a complete navigation controller script.
has_counter_animation: if true, the server adds counter animation JS for KPI numbers.
workflow (WorkflowConfig)
{
"include_style_discovery": false,
"include_ppt_conversion": true,
"include_pdf_generation": false,
"include_inline_editing": true,
"output_path_template": "/sessions/.../mnt/outputs/<client-name>-<topic>.html",
"html_title_template": "<Client> -- <Topic> | BrandName",
"content_discovery_questions": [
"What is the title/subject of this report?",
"What are the key metrics to feature?",
"How many slides should this be?"
]
}
Controls which phases of the frontend-slides skill workflow are enabled for this brand.
forbidden_patterns
[
"Never substitute fonts -- Brand Font only",
"Never modify the brand color tokens",
"Never allow scrolling within a slide",
"Cover slide must always be first, Closing slide must always be last"
]
These are passed to the LLM as hard constraints during HTML generation.
Step-by-Step: Creating a New BrandConfig
Step 1: Gather brand assets
Collect from the client or brand guidelines:
- Logo as SVG (ideally with
currentColor fills)
- Primary and secondary brand colors (hex values)
- Font choices (Google Fonts names, or system font families)
- Any gradient definitions
- Example presentations or style references
Step 2: Define color tokens
Create the color token list. At minimum you need:
- Primary brand color
- Text colors (primary, secondary, muted)
- Background colors (for light and dark slides)
- Spacing tokens (using
clamp() for responsiveness)
- Border/accent colors
Write these as colors.tokens. Each token becomes a CSS variable --<name>.
Step 3: Define gradients
If the brand uses gradients, define them in colors.gradients. Include the full CSS gradient value with direction and color stops.
Step 4: Set up typography
Define fonts with roles (display, body, optionally mono). Generate the import_html link tag for Google Fonts or Fontshare. Set heading_letter_spacing and body_line_height.
Step 5: Prepare the logo
Paste the full SVG inline as primary_svg. Convert hardcoded fill colors to currentColor (see Pitfall 8). Define color_variants for light and dark backgrounds, and size_variants for each placement context.
Step 6: Design slide types
Plan 4-8 slide types. Typical set:
- Cover -- gradient or hero background, first slide
- Content -- white/light background, general content
- Data/Table -- optimized for tables and metrics
- Chart -- optimized for chart containers
- Split -- two-column layout
- Closing -- gradient background, last slide
For each type, write:
css_block with all CSS rules (NEVER leave null -- see Pitfall 2)
html_template showing proper class usage (see Pitfall 9)
layout_description and when_to_use for the LLM
Step 7: Write shared_css
reset_and_base: Start from the template in Pitfall 1. Include the .slide base rule, prefers-reduced-motion media query, body font declaration, and heading rules.
brand_tokens_css: Define a :root { } block with EVERY CSS variable referenced in any css_block (see Pitfall 6). Map color tokens to --<name> variables, add font-family variables (--font-display, --font-body), font-size variables (--title-size, --h2-size, --body-size), spacing variables, and gradient variables.
utility_classes_css: Optional helper classes (e.g. .visually-hidden).
responsive_overrides_css: Media queries for max-height: 700px, 600px, 500px.
Step 8: Configure animations
Define 2-4 animation presets (typically reveal-up, reveal-left, reveal-right). Set stagger_delay to a simple duration like "0.1s" (see Pitfall 3). List forbidden_effects.
Step 9: Configure footer and chrome
Choose footer kind ("svg_wave", "text", or "none"). If using svg_wave, provide the css_block and html_template with the <!-- wave --> marker.
Set chrome boolean flags and provide CSS for progress bar, nav dots, and topbar if needed.
Step 10: Set chart and table container CSS
If any slide type includes charts, make sure the css_block includes:
.chart-container { width: 100%; height: min(60vh, 500px); position: relative; }
Never use flex centering for chart containers (see Pitfall 4).
If any slide type includes tables:
- Set
table_css_class at the top level (e.g. "dtbl" or "data-table")
- Define CSS for that class in a slide type's css_block (e.g.
.dtbl, .dtbl th, .dtbl td)
- Use
overflow: hidden on table wrappers (see Pitfall 5)
- Critical: Make sure the table CSS uses colors that are visible against the background where tables render — NOT the global theme color tokens if they're designed for dark backgrounds (see Pitfall 9)
The server resolves <div class="table-container" data-table-block="BLOCK_NAME"> containers by looking up the table block in the source document and injecting a <table class="<table_css_class>"> element with the rendered data.
Step 11: Validate
Before uploading, verify:
Step 12: Upload
Wrap the BrandConfig payload in the file wrapper:
{
"scope": "organization",
"style_name": "my-brand",
"payload": { ... }
}
Upload via curl:
curl -sk -X POST \
"https://localhost:5173/api/v1/admin/style/upload?clerk_id=CLERK_ID" \
-H "Content-Type: application/json" \
-d @path/to/config.json
Replace CLERK_ID with the Clerk user ID for the target organization. The endpoint upserts -- if a style with the same (org, style_name) already exists, it is updated.
Quick Reference: Mandatory Minimums
A valid BrandConfig must have at least:
brand_name and brand_description
- At least one color token in
colors.tokens
- At least one font in
typography.fonts (role "body")
- A
logo.primary_svg with inline SVG
- At least two slide types: one matching
first_slide_type, one matching last_slide_type
- Every slide type with a non-null
css_block
- At least one animation preset in
animations.presets
footer.kind set to one of "text", "svg_wave", "none"
shared_css.reset_and_base with full typography setup
shared_css.brand_tokens_css defining all referenced CSS variables