Editable Text

SkillDev tools

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Editable Text skill

What this skill tells your AI

The instructions your AI receives, as published by thesampadilla/montaj in skills/editable-text/SKILL.md and read by ahel’s review.

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:
    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.

Worked examples

Hook headline — large, bold, sans, centered:

// 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:

// 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:

// 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.

// 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:

// 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"
    }
  }
}
// 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.

Signals

GitHub stars
25
Forks
11
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
editable-text
Source
github.com/thesampadilla/montaj