| name | editable-text |
| description | Author carousel text overlays whose font/size/color/etc. the operator can edit from the editor toolbar. Load when authoring text overlays inside a carousel project. |
Editable Text
Carousel text overlays must accept a fixed set of editor-facing props so the floating toolbar (bold / italic / case / align / font-size / font-family / color) works uniformly. This skill spells out the 9-prop contract.
When this applies
This skill applies to carousel projects only. It covers every overlay that renders text the operator should be able to edit — including overlays that destructure common text-content prop names (headline, body, eyebrow, subtitle, title, copy, caption, description, label, lede, kicker) and overlays that hardcode JSX text strings. hub.write_overlay treats all of those as text overlays and rejects them unless they implement the 9-prop contract below. Non-text overlays (logos, decorative shapes, image-based overlays, stylized brand marks where the text is literally part of the artwork and shouldn't be operator-restyleable) are exempt and follow skills/write-overlay/SKILL.md instead.
The contract
A carousel editable-text overlay is any JSX file under <project_dir>/overlays/*.jsx that displays text the operator should be able to restyle from the editor toolbar.
Such an overlay MUST:
- Have an
export default function … declaration (named or anonymous fine; arrow export default (props) => … is not allowed — destructuring with defaults is required).
- Destructure all 9 props with string defaults in the function signature:
export default function MyHeadline({
text = 'Your headline',
fontSize = '64',
fontFamily = 'system-ui, -apple-system, sans-serif',
fontWeight = '700',
fontStyle = 'normal',
color = '#111111',
textAlign = 'center',
textTransform = 'none',
bgColor = 'transparent',
}) {
}
- Apply those props via inline
style on the text element. Specifically:
text is the rendered string.
fontSize is coerced to a number with NaN-fallback (per static-text.jsx).
fontFamily, fontWeight, fontStyle, color, textAlign, textTransform go straight into style.
bgColor controls the overlay's backdrop (set via style.background on the outer wrapper; 'transparent' is the no-backdrop case).
- Not animate. Carousel slides are still images. No
frame, fps, interpolate, or spring.
Affordances
Defaults are yours.
The 9 props are the editable surface; the values of the defaults are the aesthetic. The agent picks the font, size, weight, color, and alignment per slot. Hook headline wants Inter bold 64px? Body copy wants Merriweather 28px regular? Eyebrow wants Space Mono uppercase 20px? Encode all of that in the defaults. The operator can deviate; the agent's choice is the starting point.
Additional props are unbounded.
The 9 props are a floor, not a ceiling. The agent may add any other props (gradients, accents, icon refs, subtitle blocks, animation timing for non-text-style features, etc.). The contract only says these 9 must be present and applied; nothing limits what else the overlay accepts.
Worked examples
Hook headline — large, bold, sans, centered:
export default function Hook({
text = 'The 3 ingredients that actually work',
fontSize = '72',
fontFamily = '"Inter", system-ui, sans-serif',
fontWeight = '800',
fontStyle = 'normal',
color = '#0a0a0a',
textAlign = 'center',
textTransform = 'none',
bgColor = 'transparent',
}) {
const sizeNum = Number(fontSize)
const safeSize = Number.isFinite(sizeNum) && sizeNum > 0 ? sizeNum : 72
return (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
background: bgColor, padding: '8% 10%', boxSizing: 'border-box',
}}>
<p style={{
margin: 0, color, fontFamily, fontWeight, fontStyle,
fontSize: safeSize, textAlign, textTransform,
lineHeight: 1.1, letterSpacing: '-0.02em',
width: '100%', whiteSpace: 'pre-wrap', overflowWrap: 'break-word',
}}>{text}</p>
</div>
)
}
Body copy — readable serif, left-aligned, on a card background:
export default function Body({
text = 'Niacinamide brightens. Retinol turns over cells. SPF protects what you have.',
fontSize = '28',
fontFamily = '"Merriweather", Georgia, serif',
fontWeight = '400',
fontStyle = 'normal',
color = '#222222',
textAlign = 'left',
textTransform = 'none',
bgColor = '#f5f0e8',
}) {
const sizeNum = Number(fontSize)
const safeSize = Number.isFinite(sizeNum) && sizeNum > 0 ? sizeNum : 28
return (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'flex-start',
background: bgColor, padding: '8% 9%', boxSizing: 'border-box',
}}>
<p style={{
margin: 0, color, fontFamily, fontWeight, fontStyle,
fontSize: safeSize, textAlign, textTransform,
lineHeight: 1.45, letterSpacing: '0',
width: '100%', whiteSpace: 'pre-wrap', overflowWrap: 'break-word',
}}>{text}</p>
</div>
)
}
Eyebrow / kicker — small, uppercase, tracked, with optional accent. This example also accepts accentColor beyond the 9-prop contract, illustrating the "additional props are unbounded" affordance:
export default function Eyebrow({
text = 'Step 02',
fontSize = '20',
fontFamily = '"Space Mono", ui-monospace, monospace',
fontWeight = '500',
fontStyle = 'normal',
color = '#0a0a0a',
textAlign = 'center',
textTransform = 'uppercase',
bgColor = 'transparent',
accentColor = '#ff5630',
}) {
const sizeNum = Number(fontSize)
const safeSize = Number.isFinite(sizeNum) && sizeNum > 0 ? sizeNum : 20
return (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
background: bgColor, padding: '4% 6%', boxSizing: 'border-box',
}}>
<span style={{ display: 'inline-block', width: 32, height: 2, background: accentColor, marginRight: 12 }} />
<p style={{
margin: 0, color, fontFamily, fontWeight, fontStyle,
fontSize: safeSize, textAlign, textTransform,
letterSpacing: '0.18em',
}}>{text}</p>
</div>
)
}
Don't do this
The overlay below renders correctly today, but the editor toolbar's bold, italic, font-size, and color buttons all no-op on it. It hardcodes styles directly, uses copy instead of text, and has no destructured defaults — none of the 9 contract props are present.
export default function Headline({ copy }) {
return (
<div style={{
fontFamily: 'Inter, system-ui',
fontWeight: 900,
fontSize: 72,
color: '#111',
}}>{copy}</div>
)
}
Rewrite to the contract.
Referencing this overlay from a slide
Writing the conformant JSX is half the job. The slide element that references the overlay must pass the same prop names the template destructures — otherwise the template silently falls back to its defaults, and the slide renders the placeholder values with no error.
When you compose a slide that uses a text overlay you authored, the slide element's overlay.props keys MUST match the JSX's destructured arg names exactly:
{
"type": "overlay",
"overlay": {
"template": "/abs/path/to/overlays/headline.jsx",
"props": {
"text": "What 'best' actually means.",
"fontSize": "72",
"fontFamily": "\"Inter\", system-ui, sans-serif",
"fontWeight": "800",
"fontStyle": "normal",
"color": "#0a0a0a",
"textAlign": "center",
"textTransform": "none",
"bgColor": "transparent"
}
}
}
{
"type": "overlay",
"overlay": {
"template": "/abs/path/to/overlays/headline.jsx",
"props": {
"copy": "What 'best' actually means.",
"size": 58,
"weight": 700,
"accent": "#0a0a0a"
}
}
}
Rules:
- Prop keys must match the JSX's destructuring exactly. No
copy for text, no size for fontSize, no weight for fontWeight, no accent for color. The 9 contract props (text, fontSize, fontFamily, fontWeight, fontStyle, color, textAlign, textTransform, bgColor) plus any extra props you declared on the template (like accentColor on the eyebrow worked example) are the complete legal key set.
- Every value in
overlay.props MUST be a JSON string — including all numerics. Always write "fontSize": "72", never "fontSize": 72. Always "fontWeight": "700", never "fontWeight": 700. This is non-negotiable: the editor's property panel filters props by typeof === 'string', so a raw number silently disappears from the operator's editable controls. The template's Number(fontSize) coercion makes the slide render fine either way, which makes this the most insidious failure mode in the contract — it looks correct in the preview, then the operator can't touch it.
- Hub's PUT-project handler defensively coerces any numeric or boolean prop value to a string before forwarding to Montaj (see
hub/backend/src/modules/mcp/overlay-contract.ts → coerceSlidePropValues), and logs a warning when it does. Treat that warning as a contract violation on your part — fix the agent output rather than relying on the coercion. The auto-heal exists to protect operators, not to license sloppy serialization.
- Don't set what you don't need to override. If you want the template's default for a given prop, just omit the key — the destructured default kicks in. Pass only the props whose value you're customizing for this slide.
Hub validates this at PUT-project time and rejects any slide whose overlay.props contains keys the referenced template doesn't accept.
Canonical reference
montaj_assets/render/templates/overlays/static-text/static-text.jsx is the canonical conformant implementation. If anything in this skill is unclear, read it.