editable-text · diff
git:20260527.b87eb5f to git:20261008.376147f
4 added, 0 removed. Audit A to A.
---
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:
1. Have an `export default function …` declaration (named or anonymous fine; arrow `export default (props) => …` is **not** allowed — destructuring with defaults is required).
2. Destructure all 9 props with string defaults in the function signature:
```jsx
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',
}) {
// …
}
```
3. 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).
4. 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.
+ ### Effects and fit (optional, editor-backed).
+
+ Five more props light up editor controls when an overlay declares them: `effect` (`none` | `shadow` | `outline` | `glow`), `effectColor`, `effectStrength` (`"0"`..`"100"`), `fit` (`"true"` shrinks the text until it fits the box) and `minFontSize` (the size fit never goes below). Copy their handling from the canonical reference rather than writing your own: it sizes the effects in `em`, so they follow a fitted size, and it fits through a callback ref with no React hooks, because the editor preview calls the overlay function directly and a hook there breaks. Prefer `fit: "true"` with a `minFontSize` at the role's floor (see the carousel skill's sizes) over guessing a size for long copy.
+
## Worked examples
**Hook headline** — large, bold, sans, centered:
```jsx
// overlays/hook.jsx — slide 1 hook headline.
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:
```jsx
// overlays/body.jsx — middle-slide body block.
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:
```jsx
// overlays/eyebrow.jsx — section label / kicker. Accepts an extra accentColor
// prop beyond the 9-prop contract; that's fine — additional props are allowed.
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.
```jsx
// DON'T — hardcoded styles, `copy` instead of `text`, no destructured defaults.
// The editor toolbar's bold/italic/font-size/color buttons all no-op on this.
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:
```jsonc
// Correct — keys match the template's destructured args.
{
"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"
}
}
}
```
```jsonc
// WRONG — legacy prop names. Every one of these is silently ignored;
// the template renders 'Your headline' / 64px / system-ui / etc.
{
"type": "overlay",
"overlay": {
"template": "/abs/path/to/overlays/headline.jsx",
"props": {
"copy": "What 'best' actually means.", // ← should be `text`
"size": 58, // ← should be `fontSize`, and a string
"weight": 700, // ← should be `fontWeight`, and a string
"accent": "#0a0a0a" // ← should be `color`
}
}
}
```
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.