react-email-locale-lab 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -5
- package/dist/core/types.d.ts +1 -0
- package/dist/core/watch.d.ts +1 -0
- package/dist/index.js +66 -63
- package/dist/styles.css +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# React Email Locale Lab
|
|
2
2
|
|
|
3
|
-
React Email Locale Lab is a
|
|
3
|
+
React Email Locale Lab is a development utility for previewing [React Email](https://react.email/) templates in multiple languages while editing them.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[Live demo](https://react-email-locale-lab-integration.vercel.app/) · [Integration example](https://github.com/hducati/react-email-locale-lab-integration-example)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
I developed this library after encountering this need while working at an organization that used React Email templates in multiple languages. The templates were usually created first in English, but we also needed to understand how the same layout behaved in languages such as German and Russian, where translated text could change line wrapping, spacing and element dimensions.
|
|
8
|
+
|
|
9
|
+
During local development, the library renders a React Email template and generates on-demand previews for the selected languages. When the source template changes, Vite refreshes it and the selected previews are generated again, allowing the versions to be inspected side by side in near real time.
|
|
10
|
+
|
|
11
|
+
The generated translations are intended only for visual inspection of the templates. The library does not replace the localization workflow or the reviewed translations used by the application.
|
|
8
12
|
|
|
9
13
|
## Install
|
|
10
14
|
|
|
@@ -12,7 +16,7 @@ This project exists to test the technical and product viability of that workflow
|
|
|
12
16
|
pnpm add -D react-email-locale-lab
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
The package
|
|
19
|
+
The package supports React and React DOM 18 or 19 as peer dependencies.
|
|
16
20
|
|
|
17
21
|
Create a configuration in the consuming project:
|
|
18
22
|
|
|
@@ -26,6 +30,7 @@ import 'react-email-locale-lab/styles.css';
|
|
|
26
30
|
|
|
27
31
|
const config = defineEmailLab({
|
|
28
32
|
routeBasePath: '/preview',
|
|
33
|
+
watchPaths: ['src/emails'],
|
|
29
34
|
sourceLocale: { code: 'en', label: 'English' },
|
|
30
35
|
locales: [{ code: 'de', label: 'Deutsch' }],
|
|
31
36
|
provider: browserTranslatorProvider(),
|
|
@@ -55,6 +60,7 @@ Open `http://localhost:4173/preview/welcome`, select up to three languages, then
|
|
|
55
60
|
```tsx
|
|
56
61
|
export default defineEmailLab({
|
|
57
62
|
routeBasePath: '/preview',
|
|
63
|
+
watchPaths: ['src/emails'],
|
|
58
64
|
sourceLocale: { code: 'en', label: 'English' },
|
|
59
65
|
locales: [
|
|
60
66
|
{ code: 'de', label: 'Deutsch' },
|
|
@@ -79,6 +85,29 @@ templates: {
|
|
|
79
85
|
|
|
80
86
|
Set `props` on the template entry to override `PreviewProps`. A custom `render` function remains available for templates that need bespoke setup.
|
|
81
87
|
|
|
88
|
+
## Template locations and source watching
|
|
89
|
+
|
|
90
|
+
Templates can be imported from anywhere in the consuming project. They do not need to live in `src/emails` or follow a specific folder structure.
|
|
91
|
+
|
|
92
|
+
Use `watchPaths` to select the directories that should trigger preview regeneration during Vite development. Each entry is matched as a path fragment:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
export default defineEmailLab({
|
|
96
|
+
watchPaths: [
|
|
97
|
+
'src/domains/billing/notifications',
|
|
98
|
+
'src/shared/message-templates',
|
|
99
|
+
'packages/transactional-mail',
|
|
100
|
+
],
|
|
101
|
+
templates: {
|
|
102
|
+
invoice: { name: 'Invoice', component: InvoiceEmail },
|
|
103
|
+
welcome: { name: 'Welcome', component: WelcomeEmail },
|
|
104
|
+
},
|
|
105
|
+
// sourceLocale, locales and provider...
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
When `watchPaths` is omitted, the lab reloads for changes to JavaScript and TypeScript modules anywhere in the Vite application. In a larger codebase, configure it to avoid regenerating previews for unrelated source changes.
|
|
110
|
+
|
|
82
111
|
## Template routes
|
|
83
112
|
|
|
84
113
|
Every configured template has a stable preview route:
|
|
@@ -109,4 +138,4 @@ The included provider uses the browser's built-in Translator API. Language packs
|
|
|
109
138
|
|
|
110
139
|
## Current scope
|
|
111
140
|
|
|
112
|
-
React Email Locale Lab is currently a
|
|
141
|
+
React Email Locale Lab is currently a development-time proof of concept. It automatically translates rendered React Email content for visual preview only; it is not a production localization system or a replacement for testing in real email clients. Future iterations may protect placeholders, improve RTL coverage, expose language-pack progress and support additional translation providers.
|
package/dist/core/types.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const shouldReloadForUpdates: (updatePaths: string[], watchPaths?: string[]) => boolean;
|
package/dist/index.js
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
import { jsxs as d, jsx as c } from "react/jsx-runtime";
|
|
2
|
-
import { createElement as I, useMemo as A, useState as
|
|
2
|
+
import { createElement as I, useMemo as A, useState as L, useEffect as T } from "react";
|
|
3
3
|
import { renderToStaticMarkup as M } from "react-dom/server";
|
|
4
4
|
const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").test(e), j = (e) => {
|
|
5
|
-
const t = [],
|
|
6
|
-
let n =
|
|
5
|
+
const t = [], a = e.createTreeWalker(e.body, NodeFilter.SHOW_TEXT);
|
|
6
|
+
let n = a.nextNode();
|
|
7
7
|
for (; n; ) {
|
|
8
|
-
const s = n,
|
|
9
|
-
if (
|
|
8
|
+
const s = n, r = s.parentElement, l = s.data.trim();
|
|
9
|
+
if (r && !["STYLE", "SCRIPT"].includes(r.tagName) && $(l)) {
|
|
10
10
|
const o = s.data.match(/^\s*/)?.[0] ?? "", u = s.data.match(/\s*$/)?.[0] ?? "";
|
|
11
11
|
t.push({ value: l, apply: (w) => {
|
|
12
12
|
s.data = `${o}${w}${u}`;
|
|
13
13
|
} });
|
|
14
14
|
}
|
|
15
|
-
n =
|
|
15
|
+
n = a.nextNode();
|
|
16
16
|
}
|
|
17
17
|
for (const s of e.body.querySelectorAll("*"))
|
|
18
|
-
for (const
|
|
19
|
-
const l = s.getAttribute(
|
|
20
|
-
l && $(l) && t.push({ value: l, apply: (o) => s.setAttribute(
|
|
18
|
+
for (const r of k) {
|
|
19
|
+
const l = s.getAttribute(r)?.trim();
|
|
20
|
+
l && $(l) && t.push({ value: l, apply: (o) => s.setAttribute(r, o) });
|
|
21
21
|
}
|
|
22
22
|
return t;
|
|
23
|
-
}, H = async (e, t,
|
|
24
|
-
if (
|
|
23
|
+
}, H = async (e, t, a, n) => {
|
|
24
|
+
if (a === t) return e;
|
|
25
25
|
const s = new DOMParser().parseFromString(e, "text/html");
|
|
26
|
-
s.documentElement.lang =
|
|
27
|
-
const
|
|
28
|
-
return
|
|
26
|
+
s.documentElement.lang = a;
|
|
27
|
+
const r = j(s), l = await n(r.map((o) => o.value));
|
|
28
|
+
return r.forEach((o, u) => o.apply(l[u] ?? o.value)), `<!doctype html>${s.documentElement.outerHTML}`;
|
|
29
29
|
}, U = async (e) => {
|
|
30
|
-
const t = new TextEncoder().encode(e),
|
|
31
|
-
return Array.from(new Uint8Array(
|
|
30
|
+
const t = new TextEncoder().encode(e), a = await crypto.subtle.digest("SHA-256", t);
|
|
31
|
+
return Array.from(new Uint8Array(a).slice(0, 6), (n) => n.toString(16).padStart(2, "0")).join("");
|
|
32
32
|
}, O = (e) => {
|
|
33
33
|
const t = new DOMParser().parseFromString(e, "text/html");
|
|
34
34
|
return Array.from(t.body.querySelectorAll("[style]")).find(
|
|
35
35
|
(s) => s.style.display === "none" && s.textContent?.trim()
|
|
36
36
|
)?.textContent?.replace(/[\u00A0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, "").trim() || void 0;
|
|
37
|
-
}, _ = ({ locale: e, preview: t, sourceRevision:
|
|
37
|
+
}, _ = ({ locale: e, preview: t, sourceRevision: a, onRefresh: n }) => /* @__PURE__ */ d("article", { className: "preview-card", children: [
|
|
38
38
|
/* @__PURE__ */ d("header", { className: "preview-header", children: [
|
|
39
39
|
/* @__PURE__ */ d("div", { children: [
|
|
40
40
|
/* @__PURE__ */ c("strong", { children: e.label }),
|
|
@@ -47,7 +47,7 @@ const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").t
|
|
|
47
47
|
] }),
|
|
48
48
|
/* @__PURE__ */ d("div", { className: "revision", children: [
|
|
49
49
|
"source ",
|
|
50
|
-
|
|
50
|
+
a || "…",
|
|
51
51
|
" · preview ",
|
|
52
52
|
t.revision ?? "pending"
|
|
53
53
|
] }),
|
|
@@ -62,26 +62,26 @@ const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").t
|
|
|
62
62
|
if ("render" in e && e.render) return e.render();
|
|
63
63
|
const t = e.props ?? e.component.PreviewProps ?? {};
|
|
64
64
|
return I(e.component, t);
|
|
65
|
-
}, x = "/preview", F = (e = x) => `/${e}`.replace(/\/+/g, "/").replace(/\/$/, "") || x,
|
|
66
|
-
const n = F(
|
|
67
|
-
return
|
|
68
|
-
}, C = (e, t = 3) => e.searchParams.get("langs")?.split(",").filter(Boolean).slice(0, t) ?? [], B = (e, t,
|
|
65
|
+
}, x = "/preview", F = (e = x) => `/${e}`.replace(/\/+/g, "/").replace(/\/$/, "") || x, N = (e, t, a) => {
|
|
66
|
+
const n = F(a), r = (e.pathname.startsWith(`${n}/`) ? decodeURIComponent(e.pathname.slice(n.length + 1)) : void 0) || e.searchParams.get("template") || void 0;
|
|
67
|
+
return r && t.includes(r) ? r : t[0];
|
|
68
|
+
}, C = (e, t = 3) => e.searchParams.get("langs")?.split(",").filter(Boolean).slice(0, t) ?? [], B = (e, t, a) => {
|
|
69
69
|
const n = new URL(e);
|
|
70
|
-
return n.pathname = `${F(
|
|
70
|
+
return n.pathname = `${F(a)}/${encodeURIComponent(t)}`, n.searchParams.delete("template"), n;
|
|
71
71
|
}, q = (e, t) => {
|
|
72
|
-
const
|
|
73
|
-
return t.length ?
|
|
74
|
-
},
|
|
75
|
-
const t = A(() => Object.keys(e.templates), [e.templates]), [
|
|
72
|
+
const a = new URL(e);
|
|
73
|
+
return t.length ? a.searchParams.set("langs", t.join(",")) : a.searchParams.delete("langs"), a;
|
|
74
|
+
}, R = 3, D = (e) => {
|
|
75
|
+
const t = A(() => Object.keys(e.templates), [e.templates]), [a, n] = L(() => N(new URL(window.location.href), t, e.routeBasePath)), [s, r] = L(() => C(new URL(window.location.href), R)), [l, o] = L({}), [u, w] = L(""), [g, y] = L(0), b = e.templates[a] ?? e.templates[t[0]], h = A(() => `<!doctype html>${M(z(b))}`, [b]), P = e.locales.filter((i) => s.includes(i.code)), S = [e.sourceLocale, ...P];
|
|
76
76
|
return T(() => {
|
|
77
|
-
const i =
|
|
77
|
+
const i = N(new URL(window.location.href), t, e.routeBasePath);
|
|
78
78
|
window.history.replaceState({}, "", B(new URL(window.location.href), i, e.routeBasePath));
|
|
79
79
|
const v = () => {
|
|
80
|
-
n(
|
|
80
|
+
n(N(new URL(window.location.href), t, e.routeBasePath)), r(C(new URL(window.location.href), R));
|
|
81
81
|
};
|
|
82
82
|
return window.addEventListener("popstate", v), () => window.removeEventListener("popstate", v);
|
|
83
83
|
}, [e.routeBasePath, t]), T(() => () => {
|
|
84
|
-
}, []), T(() => {
|
|
84
|
+
}, [e.watchPaths]), T(() => {
|
|
85
85
|
let i = !1;
|
|
86
86
|
return U(h).then((v) => {
|
|
87
87
|
i || w(v);
|
|
@@ -90,7 +90,7 @@ const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").t
|
|
|
90
90
|
};
|
|
91
91
|
}, [h]), T(() => {
|
|
92
92
|
let i = !1;
|
|
93
|
-
return o((m) => Object.fromEntries(
|
|
93
|
+
return o((m) => Object.fromEntries(S.map((p) => [
|
|
94
94
|
p.code,
|
|
95
95
|
p.code === e.sourceLocale.code ? { html: h, status: "source" } : { html: m[p.code]?.html ?? h, status: "stale", revision: m[p.code]?.revision }
|
|
96
96
|
]))), (async () => {
|
|
@@ -124,33 +124,36 @@ const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").t
|
|
|
124
124
|
}, [h, g, s.join(",")]), {
|
|
125
125
|
activeLocaleCodes: s,
|
|
126
126
|
activeLocales: P,
|
|
127
|
-
previewLocales:
|
|
127
|
+
previewLocales: S,
|
|
128
128
|
previews: l,
|
|
129
129
|
refreshPreviews: () => y((i) => i + 1),
|
|
130
130
|
selectTemplate: (i) => {
|
|
131
131
|
n(i), window.history.pushState({}, "", B(new URL(window.location.href), i, e.routeBasePath));
|
|
132
132
|
},
|
|
133
|
-
selectedTemplateId:
|
|
133
|
+
selectedTemplateId: a,
|
|
134
134
|
sourceHtml: h,
|
|
135
135
|
sourceRevision: u,
|
|
136
136
|
templateIds: t,
|
|
137
137
|
toggleLocale: (i) => {
|
|
138
|
-
const v = s.includes(i) ? s.filter((m) => m !== i) : [...s, i].slice(0,
|
|
139
|
-
|
|
138
|
+
const v = s.includes(i) ? s.filter((m) => m !== i) : [...s, i].slice(0, R);
|
|
139
|
+
r(v), window.history.replaceState({}, "", q(new URL(window.location.href), v));
|
|
140
140
|
}
|
|
141
141
|
};
|
|
142
142
|
}, J = ({ config: e }) => {
|
|
143
143
|
const t = D(e);
|
|
144
|
-
return /* @__PURE__ */ d("main", { children: [
|
|
144
|
+
return /* @__PURE__ */ d("main", { className: "email-lab", children: [
|
|
145
145
|
/* @__PURE__ */ d("header", { className: "topbar", children: [
|
|
146
|
-
/* @__PURE__ */ d("div", { children: [
|
|
147
|
-
/* @__PURE__ */
|
|
146
|
+
/* @__PURE__ */ d("div", { className: "topbar-copy", children: [
|
|
147
|
+
/* @__PURE__ */ d("div", { className: "brand", children: [
|
|
148
|
+
/* @__PURE__ */ c("span", { className: "brand-mark" }),
|
|
149
|
+
/* @__PURE__ */ c("p", { className: "eyebrow", children: "React Email Locale Lab" })
|
|
150
|
+
] }),
|
|
148
151
|
/* @__PURE__ */ c("h1", { children: "See every language while you build." })
|
|
149
152
|
] }),
|
|
150
153
|
/* @__PURE__ */ d("div", { className: "controls", children: [
|
|
151
154
|
/* @__PURE__ */ d("label", { children: [
|
|
152
155
|
"Template",
|
|
153
|
-
/* @__PURE__ */ c("select", { value: t.selectedTemplateId, onChange: (
|
|
156
|
+
/* @__PURE__ */ c("select", { value: t.selectedTemplateId, onChange: (a) => t.selectTemplate(a.target.value), children: t.templateIds.map((a) => /* @__PURE__ */ c("option", { value: a, children: e.templates[a].name }, a)) })
|
|
154
157
|
] }),
|
|
155
158
|
/* @__PURE__ */ d("span", { className: "provider", children: [
|
|
156
159
|
"Provider: ",
|
|
@@ -163,18 +166,18 @@ const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").t
|
|
|
163
166
|
/* @__PURE__ */ c("strong", { children: "Preview languages" }),
|
|
164
167
|
/* @__PURE__ */ c("span", { children: "Select up to three. Language packs load only when selected." })
|
|
165
168
|
] }),
|
|
166
|
-
/* @__PURE__ */ c("div", { className: "locale-options", children: e.locales.map((
|
|
169
|
+
/* @__PURE__ */ c("div", { className: "locale-options", children: e.locales.map((a) => /* @__PURE__ */ d(
|
|
167
170
|
"button",
|
|
168
171
|
{
|
|
169
|
-
className: t.activeLocaleCodes.includes(
|
|
170
|
-
disabled: !t.activeLocaleCodes.includes(
|
|
171
|
-
onClick: () => t.toggleLocale(
|
|
172
|
+
className: t.activeLocaleCodes.includes(a.code) ? "locale-active" : "",
|
|
173
|
+
disabled: !t.activeLocaleCodes.includes(a.code) && t.activeLocaleCodes.length >= 3,
|
|
174
|
+
onClick: () => t.toggleLocale(a.code),
|
|
172
175
|
children: [
|
|
173
176
|
/* @__PURE__ */ c("i", {}),
|
|
174
|
-
|
|
177
|
+
a.label
|
|
175
178
|
]
|
|
176
179
|
},
|
|
177
|
-
|
|
180
|
+
a.code
|
|
178
181
|
)) })
|
|
179
182
|
] }),
|
|
180
183
|
/* @__PURE__ */ d("section", { className: "notice", children: [
|
|
@@ -185,55 +188,55 @@ const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").t
|
|
|
185
188
|
/* @__PURE__ */ c("strong", { children: "Select a language to generate a preview." }),
|
|
186
189
|
/* @__PURE__ */ c("span", { children: "The source template renders immediately; translation starts only on demand." })
|
|
187
190
|
] }),
|
|
188
|
-
/* @__PURE__ */ c("section", { className: "grid", children: t.previewLocales.map((
|
|
191
|
+
/* @__PURE__ */ c("section", { className: "grid", children: t.previewLocales.map((a) => /* @__PURE__ */ c(
|
|
189
192
|
_,
|
|
190
193
|
{
|
|
191
|
-
locale:
|
|
192
|
-
preview: t.previews[
|
|
194
|
+
locale: a,
|
|
195
|
+
preview: t.previews[a.code] ?? { html: t.sourceHtml, status: "stale" },
|
|
193
196
|
sourceRevision: t.sourceRevision,
|
|
194
197
|
onRefresh: t.refreshPreviews
|
|
195
198
|
},
|
|
196
|
-
|
|
199
|
+
a.code
|
|
197
200
|
)) })
|
|
198
201
|
] });
|
|
199
202
|
}, K = () => {
|
|
200
|
-
const e = /* @__PURE__ */ new Map(), t = /* @__PURE__ */ new Map(),
|
|
201
|
-
const o =
|
|
203
|
+
const e = /* @__PURE__ */ new Map(), t = /* @__PURE__ */ new Map(), a = /* @__PURE__ */ new Map(), n = async (r, l) => {
|
|
204
|
+
const o = a.get(r) ?? Promise.resolve();
|
|
202
205
|
let u = () => {
|
|
203
206
|
};
|
|
204
207
|
const w = new Promise((y) => {
|
|
205
208
|
u = y;
|
|
206
209
|
}), g = o.catch(() => {
|
|
207
210
|
}).then(() => w);
|
|
208
|
-
|
|
211
|
+
a.set(r, g), await o.catch(() => {
|
|
209
212
|
});
|
|
210
213
|
try {
|
|
211
214
|
return await l();
|
|
212
215
|
} finally {
|
|
213
|
-
u(),
|
|
216
|
+
u(), a.get(r) === g && a.delete(r);
|
|
214
217
|
}
|
|
215
|
-
}, s = async (
|
|
218
|
+
}, s = async (r, l) => {
|
|
216
219
|
if (!window.Translator)
|
|
217
220
|
throw new Error("This browser does not support the built-in Translator API. Use a current Chrome build or configure a remote provider.");
|
|
218
|
-
const o = `${
|
|
221
|
+
const o = `${r}:${l}`;
|
|
219
222
|
let u = e.get(o);
|
|
220
223
|
if (!u) {
|
|
221
|
-
if (await window.Translator.availability({ sourceLanguage:
|
|
222
|
-
u = window.Translator.create({ sourceLanguage:
|
|
224
|
+
if (await window.Translator.availability({ sourceLanguage: r, targetLanguage: l }) === "unavailable") throw new Error(`The browser cannot translate ${r} → ${l}.`);
|
|
225
|
+
u = window.Translator.create({ sourceLanguage: r, targetLanguage: l }), e.set(o, u);
|
|
223
226
|
}
|
|
224
227
|
return u;
|
|
225
228
|
};
|
|
226
229
|
return {
|
|
227
230
|
name: "Browser Translator · on-device",
|
|
228
|
-
async translate({ texts:
|
|
229
|
-
if (
|
|
231
|
+
async translate({ texts: r, sourceLocale: l, targetLocale: o }) {
|
|
232
|
+
if (r.length === 0) return [];
|
|
230
233
|
const u = await s(l, o), w = `${l}:${o}`;
|
|
231
234
|
return n(w, async () => {
|
|
232
235
|
const g = [];
|
|
233
|
-
for (const y of
|
|
234
|
-
const
|
|
235
|
-
let h = t.get(
|
|
236
|
-
h || (h = await u.translate(y), t.set(
|
|
236
|
+
for (const y of r) {
|
|
237
|
+
const b = `${w}:${y}`;
|
|
238
|
+
let h = t.get(b);
|
|
239
|
+
h || (h = await u.translate(y), t.set(b, h)), g.push(h);
|
|
237
240
|
}
|
|
238
241
|
return g;
|
|
239
242
|
});
|
package/dist/styles.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
:
|
|
1
|
+
.email-lab{--lab-bg: #050505;--lab-panel: #0a0a0a;--lab-panel-raised: #111111;--lab-border: #262626;--lab-border-strong: #3a3a3a;--lab-text: #ededed;--lab-muted: #8f8f8f;--lab-dim: #666666;--lab-accent: #ffffff;--lab-success: #45d483;--lab-warning: #f5a524;--lab-error: #ff6369;background:radial-gradient(circle at 50% -18%,rgba(255,255,255,.09),transparent 30rem),var(--lab-bg);color-scheme:dark;color:var(--lab-text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-synthesis:none;min-height:100vh;padding:40px clamp(18px,3vw,56px) 72px}.email-lab,.email-lab *{box-sizing:border-box}.email-lab button,.email-lab select{font:inherit}.email-lab .topbar{align-items:end;display:flex;gap:32px;justify-content:space-between;margin:0 auto;max-width:2200px}.email-lab .topbar-copy{max-width:760px}.email-lab .brand{align-items:center;display:flex;gap:9px;margin-bottom:14px}.email-lab .brand-mark{border-bottom:8px solid #fff;border-left:6px solid transparent;border-right:6px solid transparent;display:block;filter:drop-shadow(0 0 10px rgba(255,255,255,.35));height:0;width:0}.email-lab .eyebrow{color:#b8b8b8;font-size:12px;font-weight:600;letter-spacing:.08em;margin:0;text-transform:uppercase}.email-lab h1{font-size:clamp(32px,4vw,58px);font-weight:560;letter-spacing:-.055em;line-height:.98;margin:0;text-wrap:balance}.email-lab .controls{align-items:end;display:flex;flex:0 0 auto;gap:10px}.email-lab label{color:var(--lab-muted);display:grid;font-size:11px;font-weight:550;gap:7px}.email-lab select{background:#0d0d0d;border:1px solid var(--lab-border);border-radius:7px;color:var(--lab-text);min-width:220px;outline:none;padding:10px 34px 10px 12px;transition:border-color .16s ease,background-color .16s ease,box-shadow .16s ease}.email-lab select:hover,.email-lab select:focus-visible{background:#121212;border-color:var(--lab-border-strong);box-shadow:0 0 0 3px #ffffff0d}.email-lab .provider{background:#111;border:1px solid var(--lab-border);border-radius:999px;color:#b5b5b5;font-size:11px;font-weight:550;padding:10px 13px;white-space:nowrap}.email-lab .locale-picker,.email-lab .notice,.email-lab .grid,.email-lab .empty-state{margin-left:auto;margin-right:auto;max-width:2200px}.email-lab .locale-picker{align-items:start;background:#0c0c0ce0;border:1px solid var(--lab-border);border-radius:10px;display:grid;gap:24px;grid-template-columns:minmax(180px,.3fr) minmax(0,1fr);margin-top:34px;padding:16px}.email-lab .locale-picker strong,.email-lab .locale-picker span{display:block}.email-lab .locale-picker strong{font-size:13px;font-weight:580}.email-lab .locale-picker span{color:var(--lab-muted);font-size:11px;line-height:1.45;margin-top:4px}.email-lab .locale-options{display:flex;flex-wrap:wrap;gap:7px;justify-content:flex-end;max-height:118px;overflow:auto;padding:1px 3px 3px 1px;scrollbar-color:#333 transparent;scrollbar-width:thin}.email-lab .locale-options button{align-items:center;background:#111;border:1px solid var(--lab-border);border-radius:6px;color:#a8a8a8;cursor:pointer;display:flex;font-size:11px;font-weight:550;gap:7px;padding:7px 10px;transition:background-color .14s ease,border-color .14s ease,color .14s ease,transform .14s ease}.email-lab .locale-options button:hover:not(:disabled){background:#181818;border-color:#444;color:#fff;transform:translateY(-1px)}.email-lab .locale-options button i{border:1px solid #555;border-radius:50%;height:7px;transition:background-color .14s ease,border-color .14s ease,box-shadow .14s ease;width:7px}.email-lab .locale-options button.locale-active{background:#f2f2f2;border-color:#fff;color:#0a0a0a}.email-lab .locale-options button.locale-active i{background:#0a0a0a;border-color:#0a0a0a;box-shadow:0 0 0 2px #0000001f}.email-lab .locale-options button:disabled{cursor:not-allowed;opacity:.35}.email-lab .notice{align-items:center;border-bottom:1px solid var(--lab-border);color:var(--lab-muted);display:flex;font-size:11px;margin-bottom:18px;padding:14px 2px}.email-lab .live-dot{animation:lab-pulse 1.8s ease-in-out infinite;background:var(--lab-success);border-radius:50%;box-shadow:0 0 10px #45d4838c;height:7px;margin-right:9px;width:7px}.email-lab .grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(min(100%,410px),1fr))}.email-lab .preview-card{animation:lab-enter .32s ease both;background:var(--lab-panel);border:1px solid var(--lab-border);border-radius:10px;box-shadow:0 16px 50px #0000003d;min-width:0;overflow:hidden;position:relative;transition:border-color .18s ease,box-shadow .18s ease,transform .18s ease}.email-lab .preview-card:hover{border-color:#393939;box-shadow:0 20px 65px #00000057;transform:translateY(-2px)}.email-lab .preview-header{align-items:center;border-bottom:1px solid var(--lab-border);display:flex;justify-content:space-between;padding:13px 14px 11px}.email-lab .preview-header strong{display:block;font-size:13px;font-weight:580}.email-lab .preview-header span{color:var(--lab-dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px}.email-lab .status{align-items:center;border:1px solid currentColor;border-radius:999px;display:flex;font-size:9px;font-weight:650;gap:6px;letter-spacing:.045em;opacity:.88;padding:4px 7px;text-transform:uppercase}.email-lab .status i{background:currentColor;border-radius:50%;height:5px;width:5px}.email-lab .status-source,.email-lab .status-ready{color:var(--lab-success)}.email-lab .status-stale{color:var(--lab-warning)}.email-lab .status-translating{color:#8da4ff}.email-lab .status-error{color:var(--lab-error)}.email-lab .status-translating i{animation:lab-pulse .8s ease-in-out infinite}.email-lab .revision{background:#080808;border-bottom:1px solid #1b1b1b;color:#5f5f5f;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:9px;padding:7px 14px}.email-lab .preheader{align-items:baseline;border-bottom:1px solid var(--lab-border);display:flex;gap:9px;min-width:0;padding:9px 14px}.email-lab .preheader span{color:#666;flex:0 0 auto;font-size:9px;font-weight:650;letter-spacing:.08em;text-transform:uppercase}.email-lab .preheader strong{color:#aaa;font-size:11px;font-weight:480;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.email-lab iframe{background:#e9e9e9;border:0;display:block;height:620px;width:100%}.email-lab .refresh{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#0a0a0ae0;border:1px solid #3a3a3a;border-radius:6px;bottom:11px;color:#d3d3d3;cursor:pointer;font-size:10px;font-weight:580;padding:7px 9px;position:absolute;right:11px;transition:background-color .14s ease,border-color .14s ease,color .14s ease}.email-lab .refresh:hover{background:#fff;border-color:#fff;color:#000}.email-lab .preview-error{background:#23080af5;border-top:1px solid #5c2227;bottom:0;color:#ff969a;font-size:11px;left:0;line-height:1.45;padding:11px 128px 11px 13px;position:absolute;right:0}.email-lab .empty-state{align-items:center;background:linear-gradient(180deg,#0d0d0d,#080808);border:1px dashed #303030;border-radius:10px;display:flex;flex-direction:column;justify-content:center;min-height:230px;text-align:center}.email-lab .empty-state strong{font-size:14px;font-weight:580}.email-lab .empty-state span{color:var(--lab-muted);font-size:12px;margin-top:7px}@keyframes lab-pulse{50%{opacity:.35;transform:scale(.82)}}@keyframes lab-enter{0%{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}}@media(min-width:1500px){.email-lab .grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:2050px){.email-lab .grid{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(max-width:900px){.email-lab{padding-top:26px}.email-lab .topbar{align-items:start;flex-direction:column}.email-lab .controls{align-items:stretch;flex-direction:column;width:100%}.email-lab select{width:100%}.email-lab .locale-picker{grid-template-columns:1fr}.email-lab .locale-options{justify-content:flex-start}}@media(prefers-reduced-motion:reduce){.email-lab *,.email-lab *:before,.email-lab *:after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
|