diff --git a/docs/superpowers/plans/2026-06-26-email-templates-brand-hygiene.md b/docs/superpowers/plans/2026-06-26-email-templates-brand-hygiene.md new file mode 100644 index 0000000..c91e89e --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-email-templates-brand-hygiene.md @@ -0,0 +1,712 @@ +# Email Templates: Brand + Hygiene Pass — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reskin all 10 `email/` templates to the Westgate brand, eliminate ~390 lines/file of duplication by wiring up real shared `header.html`/`footer.html` partials via Benchpress `IMPORT`, and close template-level inbox-hygiene gaps (non-empty ``, preheader preview text). + +**Architecture:** Each template collapses from ~400 lines to ~40: `IMPORT emails/partials/header.html` → preheader + body → `IMPORT emails/partials/footer.html`. The shared head/letterhead/footer carry the brand. The body keeps only this template's content and loops. Palette is applied as literal inline hex (no `var()` — Outlook/Gmail strip it); `scss/westgate/_tokens.scss` is the value source, copied as literals. + +**Tech Stack:** Benchpress templating (`{var}`, `{{{ if }}}`, `[[ns:key]]`, `<!-- IMPORT … -->`), HTML email (table layout, inline CSS, MSO conditional comments), NodeBB language JSON files, a tiny dependency-free Node assertion script for regression. + +## Global Constraints + +Copied verbatim from the spec — every task implicitly includes these: + +- **No CSS custom properties.** `var(--…)` is stripped by Outlook/Gmail. All brand color/spacing is **literal hex, inline on each element**. +- **Keep table layout, inline CSS, MSO conditional comments.** No flex/grid/modern CSS refactor. The shared `<style>` block holds only what *must* be in a stylesheet: `:hover` states and `@media` queries. +- **No hardcoded user-facing copy.** Every visible string stays a `[[email:…]]` key. Strings with no existing key (preheaders) get a new key added to the theme language file. +- **Web fonts barely load in email** (Cinzel/Jost only in Apple/iOS Mail). The serif/sans *fallback* stacks carry the look everywhere else. +- **Markup must stay balanced and build cleanly** (`./nodebb build` from the NodeBB root where this theme is installed). +- Reuse the current feature branch (`email-brand-hygiene-spec`). Never commit to `main`. + +### Brand palette — literal hex (the single source of truth for every task) + +| Slot | Old value | New value | Token | +|------|-----------|-----------|-------| +| Page background | `#f6f6f6` | `#ece6da` | adapted neutral | +| Letterhead band | — | `#18141d` | `--wg-panel` | +| Header/footer hairline | — | `#c2a35a` | `--wg-gold` | +| Card background | `#ffffff` | `#fbf7ef` | adapted parchment | +| H1 / greeting | `#333333` | `#1a1418` | ink | +| Sub-heading | `#aaaaaa` | `#9a9086` | `--wg-text-muted` | +| Body text | `#555555` | `#3a3340` | ink | +| Button bg | `#222222` | `#2a1222` | `--wg-plum` | +| Button border | — | `#c2a35a` | `--wg-gold` | +| Button text | `#ffffff` | `#f4ecd8` | parchment | +| Button hover | `#555555` | `#3a1830` | `--wg-plum-soft` | +| Links | default | `#a8893f` | `--wg-gold-soft` | +| Footer text | `#888888` | `#9a9086` | `--wg-text-muted` | + +### Font stacks — literal, inline + +- **Headings:** `Cinzel, Georgia, 'Times New Roman', serif` +- **Body / UI:** `Jost, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif` + +Every existing `-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol` stack becomes the **Body** stack, except `<h1>` greeting/heading elements which become the **Headings** stack. + +--- + +## Decisions & deviations from the spec (review before executing) + +Two points the spec leaves open; resolved here, flag to the user if either is wrong: + +1. **`<title>` is `{site_title}`, not per-email.** Benchpress `IMPORT` is a static include with no argument passing, so a *shared* `header.html` cannot receive a per-template title. Using `{site_title}` makes the title **non-empty and graceful everywhere** (the actual hygiene gap), with zero fragile per-template wiring. Per-message specificity lives in the **preheader**, which *is* per-template and trivial. This satisfies the spec's "populate per email / falls back gracefully" intent without a brittle mechanism. If true per-email titles are required, the alternative is keeping `<head>` out of the partial — reject unless asked. +2. **Preheaders need a theme language file.** The theme currently ships no `languages/` dir and no `languages` key in `plugin.json`. New preheader copy can't be hardcoded (Global Constraint), so Task 5 creates `languages/en-GB/email.json` and registers it. Titles need no key (decision 1). + +--- + +## File structure + +``` +email/ + partials/ + header.html # MODIFY: doctype, <head>, brand <style>, letterhead band, open container, title={site_title} + footer.html # MODIFY: gold hairline, reskinned unsubscribe block, closing tags + post-queue-body.html # MODIFY: reskinned (links/text colors) + reset.html # MODIFY: thin body — IMPORT header + body + IMPORT footer + preheader (worked example) + welcome.html # MODIFY: same transform + verify-email.html # MODIFY + reset_notify.html # MODIFY + registration_accepted.html # MODIFY + invitation.html # MODIFY + banned.html # MODIFY + test.html # MODIFY + notification.html # MODIFY: keeps {body}; post queue flow supplies rendered post-queue body + digest.html # MODIFY: keeps loops; reskinned + README.md # MODIFY: partials now real; document IMPORT + literal-hex constraint +languages/ + en-GB/email.json # CREATE: preheader keys +plugin.json # MODIFY: add "languages": "languages" +scripts/check-emails.js # CREATE: dependency-free regression guard +``` + +--- + +### Task 1: Reskin shared `header.html` (letterhead band, palette, title, styles) + +**Files:** +- Modify: `email/partials/header.html` + +**Interfaces:** +- Produces: an `IMPORT`-able partial that opens `<html><head>…</head><body>…<center>…<div class="email-container">` and renders the dark letterhead band with `{logo.src}`. Every template `IMPORT`s it first. It does **not** open the body content table — templates do that. +- Consumes (Benchpress params, already provided by NodeBB): `{site_title}`, `{logo.src}`, `{logo.width}`, `{{{ if rtl }}}`, `{{{ if logo.src }}}`. + +- [x] **Step 1: Replace the empty title with `{site_title}`** + +In `email/partials/header.html`, change line 17 from: + +```html + <title> +``` + +to: + +```html + {site_title} +``` + +- [x] **Step 2: Add the guarded webfont link** (low-risk progressive enhancement) + +Replace the empty webfont placeholder block (currently lines 30–32): + +```html + + + +``` + +with: + +```html + + + +``` + +- [x] **Step 3: Update the button hover color in the progressive-enhancement ` + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + +
+ {{{ if logo.src }}} + {site_title} + {{{ else }}} + {site_title} + {{{ end }}} +
 
+ diff --git a/email/partials/post-queue-body.html b/email/partials/post-queue-body.html new file mode 100644 index 0000000..c95df5d --- /dev/null +++ b/email/partials/post-queue-body.html @@ -0,0 +1,18 @@ +

[[post-queue:category]]

+

{category.name}

+

+ {{{ if topic.tid }}}[[post-queue:topic]]{{{ else }}}[[post-queue:title]]{{{ + end }}} +

+

+ {{{ if topic.url }}}{topic.title}{{{ else + }}}{topic.title}{{{ end }}} +

+

[[post-queue:user]]

+

+ {{{ if user.url }}}{user.username}{{{ else + }}}{user.username}{{{ end }}} +

+

{content}

diff --git a/email/registration_accepted.html b/email/registration_accepted.html new file mode 100644 index 0000000..c21cd5a --- /dev/null +++ b/email/registration_accepted.html @@ -0,0 +1,40 @@ + + +
[[email:registration_accepted.preheader]]
+
 ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
+ + + + + +
+ + + + + + + + + + +
+

[[email:welcome.text1, {site_title}]]

+
+

[[email:welcome.text3]]

+
+ + + + + +
+ +[[email:digest.cta, {site_title}]] → + +
+ +
+
+ + diff --git a/email/reset.html b/email/reset.html new file mode 100644 index 0000000..04c3244 --- /dev/null +++ b/email/reset.html @@ -0,0 +1,45 @@ + + +
[[email:reset.preheader]]
+
 ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
+ + + + + +
+ + + + + + + + + + + + + +
+

[[email:greeting-no-name]]

+
+

[[email:reset.text1]]

+
+

[[email:reset.text2]]

+
+ + + + + +
+ +[[email:reset.cta]] → + +
+ +
+
+ + diff --git a/email/reset_notify.html b/email/reset_notify.html new file mode 100644 index 0000000..29e09fe --- /dev/null +++ b/email/reset_notify.html @@ -0,0 +1,30 @@ + + +
[[email:reset_notify.preheader]]
+
 ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
+ + + + + +
+ + + + + + + + + + +
+

[[email:greeting-with-name, {username}]]

+
+

[[email:reset.notify.text1, {date}]]

+
+

[[email:reset.notify.text2]]

+
+
+ + diff --git a/email/test.html b/email/test.html new file mode 100644 index 0000000..34ac544 --- /dev/null +++ b/email/test.html @@ -0,0 +1,25 @@ + + +
[[email:test.preheader]]
+
 ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
+ + + + + +
+ + + + + + + +
+

[[email:greeting-no-name]]

+
+

[[email:test.text1]]

+
+
+ + diff --git a/email/verify-email.html b/email/verify-email.html new file mode 100644 index 0000000..a587ebf --- /dev/null +++ b/email/verify-email.html @@ -0,0 +1,50 @@ + + +
[[email:verify-email.preheader]]
+
 ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
+ + + + + +
+ + + + + + + + + + + + + + + + +
+

[[email:greeting-with-name, {username}]]

+
+

[[email:email.verify.text1]]

+
+

[[email:email.verify.text2]]

+
+

[[email:email.verify.text3, {email}]]

+
+ + + + + +
+ +[[email:welcome.cta]] → + +
+ +
+
+ + diff --git a/email/welcome.html b/email/welcome.html new file mode 100644 index 0000000..4c468ac --- /dev/null +++ b/email/welcome.html @@ -0,0 +1,50 @@ + + +
[[email:welcome.preheader]]
+
 ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
+ + + + + +
+ + + + + + + + + + + + + + + + +
+

[[email:greeting-with-name, {username}]]

+
+

[[email:email.verify.text1]]

+
+

[[email:email.verify.text2]]

+
+

[[email:email.verify.text3, {email}]]

+
+ + + + + +
+ +[[email:welcome.cta]] → + +
+ +
+
+ + diff --git a/languages/en-GB/email.json b/languages/en-GB/email.json new file mode 100644 index 0000000..9eb4142 --- /dev/null +++ b/languages/en-GB/email.json @@ -0,0 +1,12 @@ +{ + "reset.preheader": "Reset your Shadows Over Westgate password", + "welcome.preheader": "Confirm your email to enter Shadows Over Westgate", + "verify-email.preheader": "Confirm your new email address", + "reset_notify.preheader": "Your Shadows Over Westgate password was changed", + "registration_accepted.preheader": "Your Shadows Over Westgate account is approved", + "invitation.preheader": "You have been invited to Shadows Over Westgate", + "banned.preheader": "Your Shadows Over Westgate account status has changed", + "test.preheader": "Test email from Shadows Over Westgate", + "notification.preheader": "You have a new notification on Shadows Over Westgate", + "digest.preheader": "Your Shadows Over Westgate activity digest" +} diff --git a/plugin.json b/plugin.json index 6ac8810..de939a1 100644 --- a/plugin.json +++ b/plugin.json @@ -12,6 +12,7 @@ ], "scripts": ["public/client.js", "../nodebb-theme-harmony/public/harmony.js"], "templates": "templates", + "languages": "languages", "modules": { "../admin/plugins/theme-quickstart.js": "../nodebb-theme-harmony/public/admin.js", "../client/account/theme.js": "../nodebb-theme-harmony/public/settings.js" diff --git a/scripts/check-emails.js b/scripts/check-emails.js new file mode 100644 index 0000000..5b6c589 --- /dev/null +++ b/scripts/check-emails.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// ponytail: 30-line structural guard, not a render test. Catches drift, not pixels. +const fs = require('fs'); +const path = require('path'); + +const dir = path.join(__dirname, '..', 'email'); +const files = fs.readdirSync(dir).filter((f) => f.endsWith('.html')); + +const failures = []; +for (const f of files) { + const src = fs.readFileSync(path.join(dir, f), 'utf8'); + const trimmed = src.trim(); + const checks = [ + [ + trimmed.startsWith(''), + 'header IMPORT must be first', + ], + [ + trimmed.endsWith(''), + 'footer IMPORT must be last', + ], + [//, 'missing preheader marker'], + ]; + for (const [check, msg] of checks) { + if (check instanceof RegExp ? !check.test(src) : !check) failures.push(`${f}: ${msg}`); + } +} + +if (failures.length) { + console.error('Email template check FAILED:\n' + failures.join('\n')); + process.exit(1); +} + +console.log(`Email template check passed (${files.length} templates).`);