From shadowtek-edm
Build MJML-based email/EDM templates that exactly match a brand's website visual language. Use when the user asks to design, build, or refine emails, EDMs, nurture sequences, or wants email templates that match a website's look-and-feel. The email-components directory is bundled in the plugin at email-components/ — set <EMAIL_COMPONENTS_PATH> to its full path on your machine.
How this skill is triggered — by the user, by Claude, or both
Slash command
/shadowtek-edm:branded-edm-builderThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
System for producing branded marketing emails (EDMs, nurtures, transactional) that **mirror the actual visual language of a brand's website** — not "good design in general." Assumes the email-components directory bundled in this plugin at `email-components/`. Set `<EMAIL_COMPONENTS_PATH>` to the full path on your machine — e.g. `~/Developer/GitHub/shadowtek-claude-plugin/plugins/shadowtek-edm/e...
System for producing branded marketing emails (EDMs, nurtures, transactional) that mirror the actual visual language of a brand's website — not "good design in general." Assumes the email-components directory bundled in this plugin at email-components/. Set <EMAIL_COMPONENTS_PATH> to the full path on your machine — e.g. ~/Developer/GitHub/shadowtek-claude-plugin/plugins/shadowtek-edm/email-components.
Match the brand's website. Do not invent design.
Every visual decision should answer "what does the brand's own site do here?" — never "what's tasteful generally?" Before designing anything new for a brand:
<EMAIL_COMPONENTS_PATH>/
├── brand-kits/ # JSON tokens per brand
│ ├── sitelaunch.json
│ ├── opo.json
│ ├── londyloans.json
│ └── ...
├── components/ # MJML partials (mj-include)
│ ├── _attributes.mjml # Per-brand mj-class system (hero/eyebrow/h1/h2/...)
│ ├── header.mjml
│ ├── footer.mjml
│ ├── divider.mjml
│ └── view-in-browser.mjml
├── templates/<brand>/<type>/ # e.g. templates/sitelaunch/onboarding/01-welcome.mjml
├── assets/
│ ├── source/ # Originals (logos, hero PNGs, founder portraits)
│ └── processed/ # Email-ready (compressed JPEGs, recoloured logos, round avatars)
├── refs/<brand>-site/ # Site-inspector output (screenshots + styles.json)
├── scripts/
│ ├── build.js # Token sub + mj-include resolve + MJML compile + base64 inlining
│ ├── preview.js # Generates build/index.html iframe preview
│ ├── preflight.mjs # QA runner (18 checks, 4 viewports)
│ ├── build-production.mjs # Swaps base64 → R2 CDN URLs, outputs *.production.html
│ ├── inspect-opo-site.mjs # Playwright site inspector (template for new brands)
│ └── process-*.py # Asset processing (logos, heroes, supplementary, icons)
└── build/ # Compiled HTML — gitignored
Adapt scripts/inspect-opo-site.mjs for the target brand. It uses Playwright to:
refs/<brand>-site/h1/h2/h3/p/buttonrefs/<brand>-site/styles.jsonRun: node scripts/inspect-<brand>-site.mjs
Then read the screenshots. Tokens alone miss layout patterns — alternating section bands, headline phrase-highlights, card styles, button shape language. Use the Read tool on the screenshots to actually see what the brand does.
brand-kits/<short>.json is flat dot-notation tokens. Required keys:
{
"client": "...", "short_code": "...", "domain": "...", "tagline": "...",
"colours": {
"primary": "#072E50",
"primary_alt": "#1B7EA6",
"primary_alt_soft": "#7DD3ED",
"text": "#1A1A2E",
"text_muted": "#6B7280",
"text_subtle": "rgba(26,26,46,0.65)",
"bg_alt": "#FFFFFF",
"bg_section": "#F3F2F2",
"bg_dark": "#072E50",
"border": "#E5E7EB"
},
"fonts": { "heading": {...}, "body": {...} },
"type_scale": { "hero": "42px", "h1": "32px", "h2": "26px", ... },
"logo": { "light_bg": "assets/processed/<brand>.png", "dark_bg": "...", "width": "150" },
"compliance": { "company_name": "...", "physical_address": "...", "phone": "...", "sender_email": "...", "unsubscribe_url": "{{ unsubscribe_url }}" },
"voice": { "trigger_phrases": [...], "avoid_phrases": [...] }
}
Critical: logo.width must be a unitless integer string (e.g. "150" not "150px"). HTML5 requires unitless integers on <img width=""> — Gmail and Outlook strip invalid px-suffixed values and the image renders at natural size.
Drop any tokens the site doesn't use. If the brand site uses one font, use one font.
The build script can inline any assets/... path as base64:
process-logos.py: white logos → recoloured for light bg + keep white for dark bgprocess-heroes.py: JPEG hero images at 1200px / q=72 (~80-150KB each)process-supplementary.py: split-card landscape JPEGs (600×400) and round avatars (320×320 PNG)Result lives in assets/processed/<...> and gets base64-inlined at build time.
components/_attributes.mjmlUse mj-class to encode brand typography. The OPO version is the reference:
hero-eyebrow — small uppercase accent-coloured with leading dashhero-headline — massive (42-56px) bold (800), tight letter-spacing (-0.5px)eyebrow — same as hero-eyebrow for light backgroundsh1, h2, h3 — site-aligned sizes/weightslead, card-body, muted — body variantsstat-number, stat-label, team-name, team-role, quote-body, quote-citeThese are template-agnostic — every template pulls from them via mj-class="...".
Hero with phrase-highlight headline (signature OPO pattern):
<mj-hero mode="fluid-height" background-url="assets/processed/heroes-opo/fiji-lagoon-aerial.jpg" background-color="{{ colours.primary }}" padding="88px 0">
<mj-text mj-class="hero-eyebrow">— CHAPTER TWO · CONNECTION</mj-text>
<mj-text font-size="42px" font-weight="800" line-height="1.08" color="#ffffff" padding="0 32px">
Close enough to work as one team.
</mj-text>
<mj-text font-size="42px" font-weight="800" color="{{ colours.primary_alt_soft }}" padding="12px 32px 0 32px">
Cost-effective enough to scale.
</mj-text>
</mj-hero>
Two adjacent mj-text blocks — second in the soft highlight colour. Reads as one headline with editorial phrase emphasis.
4-up stat row:
Single mj-column with raw HTML <table> containing 4 <td> cells at width="25%". CSS media query at <=480px makes TDs display: inline-block; width: 50% for 2×2 mobile grid. Always include box-sizing: border-box on the TDs to prevent overflow.
Card grid with left vertical accent:
<mj-column background-color="#ffffff" border-left="3px solid {{ colours.primary_alt }}">
<mj-text font-size="11px" font-weight="700" letter-spacing="2px" color="{{ colours.primary_alt }}" text-transform="uppercase">01</mj-text>
<mj-text font-size="16px" font-weight="700" color="{{ colours.primary }}">Card heading</mj-text>
<mj-text mj-class="card-body">Body copy.</mj-text>
</mj-column>
Big stats on dark panel: Wrap mj-group inside mj-section background-color="{{ colours.primary }}". Stat numbers use primary_alt_soft for high contrast on dark.
Team avatar cards: Round-crop founder photos at 320×320 PNG (alpha mask via PIL ellipse), display at 200px width.
Buttons: Always mj-button with brand defaults — solid brand colour, white uppercase 14px bold, 0.7px letter-spacing.
Any time you put 2+ mj-column siblings inside an mj-section, MJML compiles them to <td> cells inside a <tr>. Table-row layout keeps TDs side-by-side regardless of display: block overrides on the wrapper div.
The universal fix: rewrite the section as a single mj-column containing a raw HTML <table> with <td> cells. Then media queries CAN override display on the actual TDs.
<mj-section padding="32px 0">
<mj-column>
<mj-text padding="0 24px">
<table class="my-grid" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="my-cell" align="center" valign="top" width="25%" style="width:25%;padding:8px;box-sizing:border-box;">
<!-- cell content -->
</td>
<!-- repeat for each cell -->
</tr>
</table>
</mj-text>
</mj-column>
</mj-section>
@media (max-width: 480px) {
.my-cell {
display: inline-block !important;
width: 50% !important;
max-width: 50% !important;
box-sizing: border-box !important;
}
}
Always include box-sizing: border-box when using width: 100% + padding on TDs at mobile. TDs default to box-sizing: content-box — padding is added OUTSIDE the width, causing overflow. Symptom: preflight flags constant horizontal overflow at every viewport.
When to use multiple mj-column siblings instead: when 2-column at desktop AND auto-stacking to 100% on mobile is what you want. MJML's default auto-stack works reliably for 2 columns. For 3+, use the single-column-with-table pattern.
MJML applies background-color and border to the INNER <td>, not the outer <div>. So min-height on the outer wrapper doesn't equalize visible card heights.
Fix: descendant-select into the inner td and use height (grows with content, never shrinks below declared):
.bento-equal > table > tbody > tr > td {
height: 280px !important;
vertical-align: top !important;
}
.bento-equal-tall > table > tbody > tr > td {
height: 320px !important;
vertical-align: top !important;
}
@media only screen and (max-width: 480px) {
.bento-equal > table > tbody > tr > td,
.bento-equal-tall > table > tbody > tr > td { height: auto !important; }
}
Apply via css-class="bento-card bento-equal stack-on-mobile". Use height: not min-height: — min-height has spotty support in Outlook desktop.
.bento-card { border-radius: 16px !important; overflow: hidden !important; }
.bento-soft { box-shadow: 0 4px 14px rgba(11,22,40,0.06) !important; }
.bento-dark { box-shadow: 0 12px 36px rgba(11,22,40,0.18) !important; }
.bento-pricing { box-shadow: 0 16px 40px rgba(249,115,22,0.12) !important; }
.bento-equal { /* forces inner td to height: 280px */ }
.bento-equal-tall { /* forces inner td to height: 320px */ }
Every brand template should have a <mj-style> block with:
@media only screen and (max-width: 480px) {
.stack-on-mobile { display: block !important; width: 100% !important; max-width: 100% !important; padding-left: 0 !important; padding-right: 0 !important; }
.stack-on-mobile + .stack-on-mobile { margin-top: 12px !important; }
.bento-equal > table > tbody > tr > td,
.bento-equal-tall > table > tbody > tr > td { height: auto !important; }
.mobile-hero { font-size: 34px !important; line-height: 1.08 !important; }
.mobile-h1 { font-size: 28px !important; line-height: 1.15 !important; }
.stat-mobile { font-size: 36px !important; }
}
cd <EMAIL_COMPONENTS_PATH>
node scripts/build.js --brand sitelaunch # or omit --brand for all
node scripts/preview.js
cd build && python3 -m http.server 8765 &
open http://localhost:8765/
Preview shows a file list on the left, iframe on the right. Viewport toggle (Desktop / Tablet 768px / Mobile 390px) in the top-right.
# From the shadowtek-edm plugin directory:
./scripts/deploy.sh
# Or with a custom repo path:
./scripts/deploy.sh /path/to/ll-email-components
Update package.json deploy script to use Shadowtek's CF account ID (23eac34a3165f9a81ad87cfd5126bfcb) and project name (shadowtek-email-previews).
scripts/build-production.mjs swaps base64-inlined images for hosted CDN URLs:
# Dry run — show size drops without uploading:
cd <EMAIL_COMPONENTS_PATH> && node scripts/build.js && node scripts/build-production.mjs --dry-run
# Full production deploy (from the shadowtek-edm plugin directory):
./scripts/deploy-production.sh
Real size impact: 88-92% drops per email. All emails land under Gmail's 102 KB clip threshold.
R2 setup (one-time — already done for Shadowtek):
# Bucket: shadowtek-email-assets (APAC, public access enabled)
# Public URL: https://pub-e79ab8448ca84fe1a3a5c9b036fa4e43.r2.dev
export R2_PUBLIC_URL="https://pub-e79ab8448ca84fe1a3a5c9b036fa4e43.r2.dev"
Every email has <mj-include path="../../../components/view-in-browser.mjml" /> at the top. The build script auto-injects {{ web_url }} based on the file path.
The preview index Copy HTML button transforms merge fields per platform:
| Platform | {{first_name}} becomes | {{unsubscribe_url}} becomes |
|---|---|---|
| GoHighLevel | {{contact.first_name}} | {{unsubscribe_link}} |
| MailerLite | {$name} | {$unsubscribe} |
| HubSpot | {{ contact.firstname }} | {{ unsubscribe_link }} |
| Mailchimp | *|FNAME|* | *|UNSUB|* |
| Klaviyo | {{ first_name|default:'there' }} | {% unsubscribe_link %} |
| Standard / SendGrid | {{first_name}} | {{unsubscribe_url}} |
inspect-<brand>-site.mjs and check images.json for usable shots on the live siteStop here if you find what you need.
https://images.unsplash.com/photo-<id>?w=1600&auto=format&fit=crop&q=80)Use when the brand needs a specific product/place/person not available on stock libraries.
# Find nano-banana script: ~/.claude/plugins/cache/shadowtek-claude-plugin/shadowtek/<version>/skills/nano-banana/scripts/image.py
uv run <nano-banana-path>/image.py \
--prompt "<detailed prompt>" \
--output <EMAIL_COMPONENTS_PATH>/assets/source/<brand>/heroes/<name>.jpg \
--aspect "3:2" \
--model flash \
--size 2K \
--format jpeg \
--search \
--thinking high
Prompt craft:
| Element | Bad | Good |
|---|---|---|
| Subject | "a van" | "2024 Toyota HiAce commercial van, white, Australian tradesman fit-out" |
| Composition | "side view" | "three-quarter front angle, all four wheels visible, residential driveway" |
| Setting | "suburb" | "Australian suburban street, golden hour, warm afternoon light" |
| Style | "professional" | "high-end commercial photography, sharp, clean reflections, no people, no text" |
Always process generated imagery before email use:
python3 scripts/process-<brand>-assets.py
# Resize to 1200px max width, JPEG q=72-78, progressive → ~100-180KB per hero
Templates with full base64-inlined imagery land at 230-540 KB — over Gmail's 102 KB clip threshold during preview by design. Production deploy (R2 swap) drops back to ~30-68 KB. Don't compromise visual quality to fit the clip in preview.
| Brand | Sequence | Templates | Notes |
|---|---|---|---|
| OPO | Welcome Lead Nurture | 6 emails | reference — phrase-highlight headlines, stat rows, team cards |
| SiteLaunch | Onboarding | 4 emails | bento system, equal-height cards, social proof row |
| Londyloans | Promo | 1 email | AI-generated vehicle imagery, handwriting font signature |
cd <EMAIL_COMPONENTS_PATH>
# Capture brand DNA
cp scripts/inspect-opo-site.mjs scripts/inspect-<brand>-site.mjs
node scripts/inspect-<brand>-site.mjs
# Build everything
node scripts/build.js
# Build one brand
node scripts/build.js --brand sitelaunch
# Regenerate preview index
node scripts/preview.js
# Serve preview
cd build && python3 -m http.server 8765 &
open http://localhost:8765/
# Preflight (fast — skip link probes)
npm run preflight:fast
# Production build (base64 → R2)
cd <EMAIL_COMPONENTS_PATH> && node scripts/build.js && node scripts/build-production.mjs --dry-run
# Full production deploy:
./scripts/deploy-production.sh # run from shadowtek-edm plugin directory
min-height on the outer mj-column wrapper to equalise card heights. Use the bento-equal pattern with descendant selector → inner td → height: 280px.width="150px" on logo <img> tags. Must be unitless integer: width="150".bento-equal to cards with minimal content. It's opt-in per grid.min-height on TDs. Use height — it acts as min-height inside tables and is Outlook-safe.edm-new-project — structured intake that precedes a buildemail-preflight — pre-send QA that follows a buildnpx claudepluginhub shadowtek-dev/shadowtek-claude-pluginWhole-repo audit for over-engineering: finds dead code, unnecessary abstractions, stdlib-replaceable dependencies. Outputs ranked findings and net line/dep savings.