From shadowtek-edm
Pre-send QA for compiled email templates. Renders every email at 4 viewports (desktop/tablet/mobile/narrow), runs 18 checks (size, images/alt, merge fields, compliance, spam triggers, link health, horizontal overflow, typography, contrast, and more), and produces an HTML report with screenshots inline. Use before sending or deploying any email.
How this skill is triggered — by the user, by Claude, or both
Slash command
/shadowtek-edm:email-preflightThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
The pre-publish QA for the Shadowtek email-components system. Lives at `<REPO_PATH>/scripts/preflight.mjs`.
The pre-publish QA for the Shadowtek email-components system. Lives at <REPO_PATH>/scripts/preflight.mjs.
For every compiled HTML email under build/:
build/preflight/report.html — visual report with all screenshots + issues annotatedbuild/preflight/report.json — machine-readable per-email resultsSize — flags emails over Gmail clip (102 KB). Separates HTML-only vs base64-image bytes so you know whether the R2 export shim will fix it OR whether the HTML itself needs trimming. Hard fail only at >800 KB.
Images + alt text — counts <img> tags, sums base64-encoded image bytes, flags any image missing alt text.
Merge fields — surfaces unresolved {{ ... }} tokens. Known ones like {{first_name}} / {{unsubscribe_url}} are expected. Anything else means a build-time token wasn't substituted.
Compliance — verifies an unsubscribe link is present and a sender email is visible somewhere. Required for CAN-SPAM / Australian Spam Act compliance.
Spam triggers — scans body text for ~25 common spam-trigger words. 1–3 hits warns, 4+ fails.
Link health — extracts every href, sends a HEAD request to each, flags 4xx/5xx. Slow — skip with --no-links if iterating quickly.
Horizontal overflow — for each of the 4 viewports, compares document scrollWidth to viewport width. Flags if content overflows.
Side-by-side header detection — at mobile (390px) and narrow (320px), scans for two visible sibling elements that are both >20% of viewport width, short (height <80px), near top (y<400px), and adjacent. Catches the exact bug where a header with logo + CTA doesn't stack on mobile. To opt-out, add css-class="preflight-skip" to any element.
Typography — at desktop, flags wrapped-text elements with line-height too tight for their font-size (display ≥1.05, body ≥1.4, handwriting ≥1.6). At mobile, flags stacked td[class*="stack"] siblings with mismatched text-align.
WCAG contrast — every visible text element's foreground vs effective background must meet AA: 4.5:1 normal, 3:1 large text (≥24px regular OR ≥18.66px bold). Skips gradients.
Missing href on button-styled CTAs — finds elements whose direct text matches a CTA verb pattern (get / apply / book / start / try / continue / contact / call / …) AND look visually like a button (padding ≥12/24px, background or border-radius), but have no <a> ancestor with a real href.
Brand palette adherence — loads brand-kits/<slug>.json, walks every element's computed color / background / border, reports any non-palette colour used ≥2× that isn't declared in the brand kit.
Heading hierarchy — walks <h1>…<h6> in document order, flags any level jump >1 (e.g. h1 → h3).
Padding outliers — identifies top-level section-like divs, computes median of padding-top + padding-bottom, flags any section >3× or <0.34× the median.
Invalid HTML <img> width/height attribute (HARD FAIL — the only supercritical check). HTML5 requires width="240" (unitless integer). width="240px" is invalid — Gmail and Outlook strip the attribute and the image renders at natural size. Scanned via regex against compiled HTML.
Logo size adherence vs brand kit — loads brand-kits/<slug>.json, plucks logo.width and filename basenames. Finds any <img> whose src contains either basename, measures rendered width at desktop AND mobile. Flags if rendered < 60% of declared.
Sparse top section — for every top-level section in the first 400px of the email, checks ratio of (content bbox height) / (section height). If <50% AND section is >80px tall, flag. Catches "huge empty card with tiny logo."
Inline-block baseline gap — for any <img> wrapped in an <a> whose display is inline-block, checks whether the grandparent cell has non-zero line-height AND whether the cell-bottom is ≥6px below the image-bottom with no text sibling explaining the gap. Flags the invisible vertical space that inline-block images pick up from the cell's line-height. Fix: line-height:0; font-size:0 on the cell OR display:block on the <a>.
cd <REPO_PATH>
# Full preflight on everything (slowest — pings every link)
npm run preflight
# Fast preflight — skip link probes
npm run preflight:fast
# One brand
node scripts/preflight.mjs --brand sitelaunch --no-links
# One file
node scripts/preflight.mjs --file sitelaunch/onboarding/01-welcome.html --no-links
# Quiet mode — only show FAIL/WARN, not every PASS
node scripts/preflight.mjs --quiet
Output:
build/preflight/report.html — open this for the visual reviewbuild/preflight/<brand>/<sequence>/<email>/{desktop,tablet,mobile,narrow}.pngnpm run preflight && npm run deploy)mj-group doesn't auto-stack on mobileSymptom: 4-up stat row shows as 3-in-row + 1-wrapped at mobile. Preflight flags Horizontal overflow at: mobile.
Fix: remove <mj-group> wrapper. Use plain <mj-column width="25%"> x 4. MJML auto-stacks plain mj-columns to 100% at 480px. To force 2×2 instead of 4-stacked:
@media only screen and (max-width: 480px) {
.stat-col { display: inline-block !important; width: 50% !important; max-width: 50% !important; }
}
Then css-class="stat-col" on each column.
Symptom: Preflight flags N possible side-by-side layout(s) on mobile AND mobile.png shows logo + CTA side-by-side.
Trying to stack two mj-column siblings via a custom @media query doesn't reliably work — MJML compiles each column to a <td> inside a <tr>, and TDs in a row stay side-by-side regardless of display: block overrides.
Fix: rewrite the header as a single mj-column containing a raw HTML <table> with two <td> cells:
<mj-section background-color="{{ colours.primary }}" padding="20px 0">
<mj-column>
<mj-text padding="0 32px">
<table class="header-table" role="presentation" width="100%">
<tr>
<td class="header-logo-cell" align="left">
<img src="..." width="150" style="display:block;" />
</td>
<td class="header-cta-cell" align="right">
<a href="..." style="white-space:nowrap;display:inline-block;">Book a Call</a>
</td>
</tr>
</table>
</mj-text>
</mj-column>
</mj-section>
@media (max-width: 480px) {
.header-logo-cell, .header-cta-cell {
display: block !important; width: 100% !important;
text-align: center !important; padding: 0 !important;
}
.header-logo-cell img { margin: 0 auto !important; }
.header-cta-cell { padding-top: 14px !important; }
}
Symptom: Horizontal overflow at: narrow (340px vs 320px).
Open build/preflight/<email>/narrow.png to see which element bleeds. Common culprits:
<mj-image> with hardcoded width="500px" exceeding 320px — switch to width="100%"padding="0 48px" leaving <300px content area — reduce via .mobile-tight-pad { padding: 0 16px !important }box-sizing: border-box on TDs that use both width: 100% and paddingSymptom: Size 250 KB total (60 KB HTML + 190 KB inlined images).
Expected during preview — all images are base64-inlined. Run npm run build:production to swap references for Cloudflare R2 hosted URLs. Emails drop back under 102 KB.
Two possible causes, both caught by preflight:
<img> attribute (check 15) — width="150px" instead of width="150". Fix: remove px from brand-kit's logo.width.getbbox() check. If visible content is <80% of image dimensions, crop to content bbox + ~12px breathing room, reduce brand-kit logo.width proportionally.Word like "winner" used in legitimate context triggers a spam warning. Low-priority — acknowledge in the report. Mailtester / GlockApps will give a more accurate spam-score in production.
build/preflight/report.json:
{
"file": "sitelaunch/onboarding/01-welcome.html",
"size_kb": "104.0",
"size_html_only_kb": "63.9",
"image_count": 6,
"base64_kb": "40",
"missing_alt": 0,
"merge_fields_known": ["first_name", "unsubscribe_url"],
"merge_fields_unknown": [],
"has_unsubscribe": true,
"has_sender": true,
"spam_triggers": [],
"link_count": 14,
"broken_links": [],
"viewports": ["desktop", "tablet", "mobile", "narrow"],
"overflow": [],
"passed": true
}
After every manual layout/visual bug fix that preflight missed, ask:
preflight-skip opt-out.If 1+2 are yes and 3 is manageable, write the check and document it here. The annotated history of all 18 checks (which bug session motivated each one) is in the reference: lead-laundry-edm-plugin-main/skills/email-preflight/SKILL.md lines 220+.
branded-edm-builder — the design system this preflightsedm-new-project — the intake skill that triggers preflight at end of project setupnpx 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.