skills/brand-config-authoring/SKILL.md
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.
npx skillsauth add motleyai/agent-skills brand-config-authoringInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
storyline/storyline/api/schemas/report_style_schemas.py (source of truth)storyline/playground/styles/samplead/samplead-save-style-args.json (most complete)storyline/playground/styles/evalart/evalart-save-style-args.jsonstoryline/playground/styles/cledara/cledara-save-style-args.jsonThese are real bugs discovered during development. Every one of them caused broken presentations in production. Read this section BEFORE writing any config.
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.
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.
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".
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.
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.
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.
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:
.visible class to slideschrome.has_progress_bar is true)chrome.has_nav_dots is true)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.
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); } /* --text-muted is rgba(255,255,255,0.6) → invisible on white */
.dtbl td { color: var(--text-primary); } /* --text-primary is #ffffff → invisible on white */
Right — use colors appropriate for the table's actual background context:
.dtbl th { color: #6B7280; } /* gray, readable on white */
.dtbl td { color: var(--bg-deep); } /* dark brand color, readable on white */
.dtbl td { border-bottom: 1px solid #E5E7EB; } /* light gray border on white */
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.
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"><!-- logo --></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>
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).
| 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 |
{
"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.
{
"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.{
"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).{
"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.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).[
{
"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 -->).{
"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.{
"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".{
"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.
{
"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).<style> block.{
"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.{
"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.
[
"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.
Collect from the client or brand guidelines:
currentColor fills)Create the color token list. At minimum you need:
clamp() for responsiveness)Write these as colors.tokens. Each token becomes a CSS variable --<name>.
If the brand uses gradients, define them in colors.gradients. Include the full CSS gradient value with direction and color stops.
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.
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.
Plan 4-8 slide types. Typical set:
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 LLMreset_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.
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.
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.
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:
table_css_class at the top level (e.g. "dtbl" or "data-table").dtbl, .dtbl th, .dtbl td)overflow: hidden on table wrappers (see Pitfall 5)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.
Before uploading, verify:
shared_css.reset_and_base includes body font, html scroll-snap, heading rulescss_blockstagger_delay matches the pattern ^\d+(\.\d+)?(s|ms)$overflow: hiddentable_css_class is set and its CSS is defined in a slide type css_blockvar(--...) reference in any CSS field is defined in brand_tokens_cssfill="currentColor" (if uses_current_color is true)html_template fields demonstrate correct class usage for tables, charts, metricsfirst_slide_type and last_slide_type match a css_class in the types arrayWrap 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.
A valid BrandConfig must have at least:
brand_name and brand_descriptioncolors.tokenstypography.fonts (role "body")logo.primary_svg with inline SVGfirst_slide_type, one matching last_slide_typecss_blockanimations.presetsfooter.kind set to one of "text", "svg_wave", "none"shared_css.reset_and_base with full typography setupshared_css.brand_tokens_css defining all referenced CSS variablesdevelopment
Create branded HTML presentations using structured slide specs. Outputs JSON DeckSpec instead of raw HTML — the server handles all rendering, styling, and viewport fitting.
data-ai
Create or modify text blocks using update_text_block. Covers template syntax with variable references, LLM generation, and constrained outputs.
data-ai
Create or modify table blocks using update_table_block. Covers template syntax, target_shape constraints, and table generation patterns.
data-ai
Create or modify numerical query blocks within text or table blocks using update_query_block. Queries provide data values referenced as {query_name} in parent templates.