'use strict';
/*
* openvibe-shared/email — one frame for every mail the network sends (the owner, 2026-11-10, with a "Browser check
* failed" mail open: "Improve the entire email system so it uses openvibe.design … uses the theme applied by the user
* … explain more details … show the words, what they can do, what they can click").
*
* const mail = require('openvibe-shared/email');
* const { html, text } = mail.frame({
* theme: 'vibe', // a built-in theme's id (the person's own), or its { mode, variables }
* site: { name: 'OpenVibe', url: 'https://openvibe.network', logo: 'https://…/logo-192.png' },
* tone: 'alert', // 'plain' | 'alert' | 'warning' | 'good'
* kicker: 'Still failing', // a few words over the title (optional)
* title: 'openvibe.domains: 1 problem found by the browser check',
* lead: 'After release 74bdff4, the automatic check found a problem on the page /.',
* body: ['Two fold headings have no name a screen reader can say.'],
* facts: [{ label: 'Page', value: '/', href: 'https://openvibe.domains/' }, { label: 'Release', value: '74bdff4' }],
* code: '482 913', // a code to type somewhere (sign-in, two-step), drawn large (optional)
* actions: [{ label: 'Open the page', href: 'https://…' }, { label: 'See the release', href: 'https://…' }],
* next: ['The check runs again after every release and once a day.'],
* why: 'You get this because you run this site.',
* manage: { label: 'Change what is mailed to you', href: 'https://openvibe.network/notifications' },
* preheader: 'Two fold headings have no name.', // the line a mail list shows beside the subject
* });
*
* What a mail client allows decides the build: tables for layout, every style written on its element, no script, no
* web font, no SVG (the mark is a hosted PNG, with the site's name beside it as text), nothing that moves. The colours
* are the person's theme (dark or light as the theme is; the client is told so and does not invert it). A mail's tone is
* never a hard coloured border: the card leans toward the tone at its top and glows in it, the kicker is a pill with a
* round mark, the facts stand in a box with a tinted hairline. Where a client draws no shadow or gradient the tints and
* hairlines remain. Every string is
* text: it is escaped, and a link is kept only if it is https or mailto. The first action is the one button; the others
* are plain links under it. `text` says the same in plain words for clients that show no HTML.
*/
const themes = require('./builtin-themes');
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const link = (u) => { const s = String(u == null ? '' : u).trim(); return /^https:\/\/[^\s<>"']+$/i.test(s) || /^mailto:[^\s<>"']+$/i.test(s) ? s : ''; };
const rgb = (h) => { const x = String(h).replace('#', ''); const f = x.length === 3 ? x.split('').map((ch) => ch + ch).join('') : x.slice(0, 6); return [0, 2, 4].map((i) => parseInt(f.slice(i, i + 2), 16)); };
/** A colour part of the way from one to another, as a plain hex: a mail client has no colour mixing and little transparency. */
const mix = (a, b, t) => { const q = rgb(b); return '#' + rgb(a).map((v, i) => Math.round(v + (q[i] - v) * t).toString(16).padStart(2, '0')).join(''); };
const ratio = (a, b) => themes.contrast(themes.hexToRgb(a), themes.hexToRgb(b));
const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace";
/** The colours a mail is written in, from a theme's id or its { mode, variables }. An unknown id is the default theme. */
function palette(theme) {
let t = theme && typeof theme === 'object' ? theme : themes.BUILTIN_THEMES.find((x) => x.id === theme || x.slug === theme);
if (!t || !t.variables) t = themes.BUILTIN_THEMES.find((x) => x.id === themes.DEFAULT_THEME) || themes.BUILTIN_THEMES[0];
const mode = t.mode === 'light' ? 'light' : 'dark';
const v = { ...t.variables, ...themes.deriveTokens(t.variables, mode) };
const pick = (k, d) => (/^#[0-9a-f]{3,8}$/i.test(String(v[k] || '')) ? v[k] : d);
const dark = mode === 'dark';
return {
mode,
page: pick('--bg-primary', dark ? '#0a0f1c' : '#f4f6fb'), card: pick('--bg-card', dark ? '#131c2e' : '#ffffff'), well: pick('--bg-secondary', dark ? '#101828' : '#f1f4f9'),
line: pick('--border', dark ? '#1f2d47' : '#d9e0ec'), text: pick('--text-primary', dark ? '#e6edf7' : '#0f172a'), dim: pick('--text-secondary', dark ? '#96a7c2' : '#475569'), faint: pick('--text-muted', dark ? '#8192ac' : '#64748b'),
accent: pick('--accent-light', pick('--accent', '#3b82f6')), button: pick('--accent-strong', '#3472d8'), onButton: pick('--on-accent-strong', '#ffffff'),
alert: pick('--danger', '#ef4444'), warning: pick('--warning', '#f59e0b'), good: pick('--success', '#22c55e'),
};
}
/** One mail: { html, text }. See the head of this file for what it takes. */
function frame(o = {}) {
const c = palette(o.theme);
const site = { name: 'OpenVibe', url: 'https://openvibe.network', ...(o.site || {}) };
const tone = ['alert', 'warning', 'good'].includes(o.tone) ? o.tone : 'plain';
const mark = tone === 'plain' ? c.accent : c[tone];
const tint = (t) => mix(c.card, mark, t); // the card, leaning toward the tone
let ink = mark; // the tone as words on its own tint: readable in every theme
for (let i = 0; i < 8 && ratio(ink, tint(0.14)) < 4.5; i += 1) ink = mix(ink, c.text, 0.25);
const onMark = ratio(mark, '#ffffff') >= ratio(mark, c.page) ? '#ffffff' : c.page;
const glyph = { alert: '!', warning: '!', good: '✓', plain: 'i' }[tone];
const body = (Array.isArray(o.body) ? o.body : o.body ? [o.body] : []).filter(Boolean);
const facts = (o.facts || []).filter((f) => f && f.label && f.value != null && f.value !== '');
const actions = (o.actions || []).map((a) => ({ label: a && a.label, href: link(a && a.href) })).filter((a) => a.label && a.href);
const next = (Array.isArray(o.next) ? o.next : o.next ? [o.next] : []).filter(Boolean);
const manage = o.manage && link(o.manage.href) ? { label: o.manage.label || 'Change what is mailed to you', href: link(o.manage.href) } : null;
const home = link(site.url) || 'https://openvibe.network';
const logo = link(site.logo);
const p = (s, style) => `
${esc(s)}
`;
const a = (label, href, style) => `${esc(label)}`;
const head = ` |
`;
const title = `${o.kicker ? `` : ''}${esc(o.title || '')}${o.lead ? p(o.lead, `color:${c.text};`) : ''}${body.map((s) => p(s)).join('')} |
`;
const code = o.code ? ` |
` : '';
const factRows = facts.map((f, i) => `| ${esc(f.label)} | ${link(f.href) ? a(f.value, link(f.href)) : esc(f.value)} |
`).join('');
const factsBlock = facts.length ? ` |
` : '';
const first = actions[0];
const actionsBlock = first ? ` |
${actions.length > 1 ? `| ${actions.slice(1).map((x) => a(x.label, x.href)).join(' · ')} |
` : ''}` : '';
const nextBlock = next.length ? `What happens next ${next.map((s) => ` | ${esc(s)} | `).join('')} |
` : '';
const foot = `${o.why ? `${esc(o.why)} ` : ''}${manage ? a(manage.label, manage.href, `color:${c.faint};`) : ''}${o.why || manage ? ' ' : ''}${a(site.name, home, `color:${c.faint};text-decoration:none;`)} |
|
`;
const html = `
${esc(o.title || site.name)}
${o.preheader ? `${esc(o.preheader)}
` : ''}
${head}${title}${code}${factsBlock}${actionsBlock}${nextBlock}${foot}
|
`;
const lines = [];
if (o.kicker) lines.push(String(o.kicker).toUpperCase());
lines.push(String(o.title || ''), '');
if (o.lead) lines.push(String(o.lead));
body.forEach((s) => lines.push(String(s)));
if (o.code) lines.push('', `Your code: ${o.code}`);
if (facts.length) { lines.push(''); facts.forEach((f) => lines.push(`${f.label}: ${f.value}${link(f.href) ? ` (${link(f.href)})` : ''}`)); }
if (actions.length) { lines.push(''); actions.forEach((x) => lines.push(`${x.label}: ${x.href}`)); }
if (next.length) { lines.push('', 'What happens next'); next.forEach((s) => lines.push(`- ${s}`)); }
lines.push('', '--');
if (o.why) lines.push(String(o.why));
if (manage) lines.push(`${manage.label}: ${manage.href}`);
lines.push(`${site.name} · ${home}`);
return { html, text: lines.join('\n').replace(/\n{3,}/g, '\n\n') };
}
module.exports = { frame, palette, mix };