feat: packages/email — Maizzle v6 + Tailwind CSS v4 HTML email templates

- New @sexy.pivoine.art/email package with @maizzle/framework@6.0.0-15
- Uses @maizzle/tailwindcss (TW v4 preset) with @theme brand tokens
  derived from the frontend's app.css oklch primary color
- LightningCSS automatically lowers oklch/lab to hex for email clients
- Real HTML template files (templates/layouts/main.html, verification.html,
  password-reset.html) — not JS template strings
- PostCSS `from` override so @import "@maizzle/tailwindcss" resolves from
  the email package's own node_modules
- Backend lib/email.ts now calls renderVerification/renderPasswordReset
  instead of inline HTML strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 10:41:12 +01:00
parent bb6bf7ca11
commit 60531771cf
11 changed files with 2639 additions and 19 deletions

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="x-apple-disable-message-reformatting" />
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<![endif]-->
<title>{{ page.title || 'sexy.pivoine.art' }}</title>
<style>
@import "@maizzle/tailwindcss";
@theme {
/* Brand colors — match frontend app.css */
--color-brand: oklch(56.971% 0.27455 319.257);
--color-brand-foreground: oklch(0.98 0.01 320);
--color-brand-dark: oklch(48% 0.26 319);
/* Surface */
--color-surface: oklch(0.98 0.01 320);
--color-on-dark: oklch(0.98 0.01 280);
}
</style>
</head>
<body class="bg-[oklch(0.95_0.01_310)] m-0 p-0 font-sans">
<!-- Preview text (hidden) -->
<if condition="page.previewText || previewText">
<div class="hidden max-h-0 overflow-hidden">
{{ page.previewText || previewText }}
<!-- padding to push any trailing content out of preview -->
&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
</div>
</if>
<div class="py-8 px-4">
<table class="w-full max-w-[600px] mx-auto" role="presentation" cellpadding="0" cellspacing="0" border="0">
<!-- Brand header -->
<tr>
<td class="bg-[oklch(0.08_0.02_280)] rounded-t-2xl px-8 py-6 text-center">
<a href="{{ baseUrl }}" style="text-decoration: none">
<span class="text-sm font-semibold tracking-[0.22em] uppercase text-on-dark">
sexy<span class="text-brand">.</span>pivoine<span class="text-brand">.</span>art
</span>
</a>
</td>
</tr>
<!-- Body -->
<tr>
<td class="bg-white px-8 py-10 text-[14px] text-zinc-700 leading-relaxed">
<yield />
</td>
</tr>
<!-- Footer -->
<tr>
<td class="bg-zinc-50 border-t border-zinc-100 rounded-b-2xl px-8 py-6 text-center">
<p class="text-[11px] text-zinc-400 m-0">
&copy; {{ new Date().getFullYear() }} sexy.pivoine.art &mdash; For adults only (18+)
</p>
<p class="text-[11px] text-zinc-400 mt-2 mb-0">
If you did not request this email, you can safely ignore it.
</p>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,44 @@
---
title: "Reset your password — sexy.pivoine.art"
previewText: "You requested a password reset. Use the link below to set a new one."
---
<x-main>
<h1 class="text-[22px] font-semibold text-zinc-900 m-0 mb-2">
Reset your password
</h1>
<p class="text-zinc-500 m-0 mb-6">
We received a request to reset the password for your account. Click the button below to choose a new one.
</p>
<!-- CTA button -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mb-6">
<tr>
<td class="rounded-lg" style="background: #b700d9">
<a href="{{ url }}"
class="inline-block px-8 py-[14px] text-[14px] font-semibold text-white no-underline rounded-lg"
style="background: #b700d9">
Reset my password
</a>
</td>
</tr>
</table>
<p class="text-[13px] text-zinc-500 m-0 mb-6">
This link expires in <strong class="text-zinc-700">1 hour</strong>.
If you did not request a password reset, no action is needed — your account remains secure.
</p>
<hr class="border-0 border-t border-zinc-100 my-6" />
<p class="text-[12px] text-zinc-400 m-0">
Button not working? Copy and paste this link into your browser:
</p>
<p class="text-[12px] m-0 mt-1">
<a href="{{ url }}" class="text-brand break-all" style="color: #b700d9">
{{ url }}
</a>
</p>
</x-main>

View File

@@ -0,0 +1,43 @@
---
title: "Verify your email — sexy.pivoine.art"
previewText: "Almost there — confirm your email address to activate your account."
---
<x-main>
<h1 class="text-[22px] font-semibold text-zinc-900 m-0 mb-2">
Verify your email address
</h1>
<p class="text-zinc-500 m-0 mb-6">
Thanks for signing up! Click the button below to confirm your email address and activate your account.
</p>
<!-- CTA button -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mb-6">
<tr>
<td class="rounded-lg" style="background: #b700d9">
<a href="{{ url }}"
class="inline-block px-8 py-[14px] text-[14px] font-semibold text-white no-underline rounded-lg"
style="background: #b700d9">
Verify my email
</a>
</td>
</tr>
</table>
<p class="text-[13px] text-zinc-500 m-0 mb-6">
This link expires in <strong class="text-zinc-700">24 hours</strong>.
</p>
<hr class="border-0 border-t border-zinc-100 my-6" />
<p class="text-[12px] text-zinc-400 m-0">
Button not working? Copy and paste this link into your browser:
</p>
<p class="text-[12px] m-0 mt-1">
<a href="{{ url }}" class="text-brand break-all" style="color: #b700d9">
{{ url }}
</a>
</p>
</x-main>