Unify brand palette across site emails previews and sign defaults
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { brandPalette } from "@album/contracts";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -9,11 +10,11 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
|
||||
import type { SignDesign } from "@/lib/guest-sign";
|
||||
|
||||
const presets = {
|
||||
linenCard: { layout: "compact", paper: "card6x4", background: "#f1eadf", accent: "#473a30", decoration: "minimal", font: "funnel" },
|
||||
indigo: { layout: "typography", background: "#4055b5", accent: "#ffffff", decoration: "minimal", font: "funnel" },
|
||||
linen: { layout: "photo", background: "#f1eadf", accent: "#473a30", decoration: "minimal", font: "funnel" },
|
||||
linenCard: { layout: "compact", paper: "card6x4", background: brandPalette.background, accent: brandPalette.ink, decoration: "minimal", font: "funnel" },
|
||||
indigo: { layout: "typography", background: brandPalette.primary, accent: "#ffffff", decoration: "minimal", font: "funnel" },
|
||||
linen: { layout: "photo", background: brandPalette.background, accent: brandPalette.ink, decoration: "minimal", font: "funnel" },
|
||||
forest: { layout: "photo", background: "#153e35", accent: "#ffffff", decoration: "minimal", font: "funnel" },
|
||||
mono: { layout: "typography", background: "#f4f2ec", accent: "#171717", decoration: "minimal", font: "funnel" },
|
||||
mono: { layout: "typography", background: brandPalette.background, accent: brandPalette.ink, decoration: "minimal", font: "funnel" },
|
||||
} as const;
|
||||
|
||||
export function SignDesignControls({ design, onChange, onBusy }: { design: SignDesign; onChange: (patch: Partial<SignDesign>) => void; onBusy: (busy: boolean) => void }) {
|
||||
@@ -52,12 +53,12 @@ export function SignDesignControls({ design, onChange, onBusy }: { design: SignD
|
||||
<div className="grid grid-cols-2 gap-3">{(["customWidth", "customHeight"] as const).map((key, i) => <Field key={key}><FieldLabel htmlFor={`sign-${key}`}>{i === 0 ? "Width" : "Height"}</FieldLabel><Input id={`sign-${key}`} type="number" step="0.01" min={design.unit === "mm" ? 76.2 : 3} max={design.unit === "mm" ? 1219.2 : 48} value={design[key] ?? ""} onChange={e => onChange({ [key]: e.target.value === "" ? 0 : Number(e.target.value) })} /></Field>)}</div>
|
||||
<FieldDescription>Portrait, square, or landscape. 3–48 inches per edge. Small signs should be scanned up close.</FieldDescription>
|
||||
</> : null}
|
||||
<Field><FieldLabel htmlFor="sign-preset">Design preset</FieldLabel><Select value={preset} onValueChange={value => { onChange(presets[value as keyof typeof presets]); }}><SelectTrigger id="sign-preset" className="w-full"><SelectValue placeholder="Custom design" /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="linenCard">Card · Linen (no photo)</SelectItem><SelectItem value="indigo">Typography · Indigo</SelectItem><SelectItem value="linen">Photo · Linen</SelectItem><SelectItem value="forest">Photo · Forest</SelectItem><SelectItem value="mono">Typography · Paper</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||
<Field><FieldLabel htmlFor="sign-preset">Design preset</FieldLabel><Select value={preset} onValueChange={value => { onChange(presets[value as keyof typeof presets]); }}><SelectTrigger id="sign-preset" className="w-full"><SelectValue placeholder="Custom design" /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="linenCard">Card · Linen (no photo)</SelectItem><SelectItem value="indigo">Typography · Cobalt</SelectItem><SelectItem value="linen">Photo · Linen</SelectItem><SelectItem value="forest">Photo · Forest</SelectItem><SelectItem value="mono">Typography · Paper</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||
<Field><FieldLabel htmlFor="sign-font">Heading font</FieldLabel><Select value={design.font ?? "funnel"} onValueChange={font => onChange({ font: font as SignDesign["font"] })}><SelectTrigger id="sign-font" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="funnel">Funnel Display</SelectItem><SelectItem value="geologica">Geologica</SelectItem><SelectItem value="inter">Inter</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||
<FieldDescription>{design.layout === "compact" ? "The Linen text-and-QR design without a photo. Starts at 6 × 4 inches; choose any paper size above." : design.layout === "photo" ? "A large event photo with a compact QR section. Add a photo below to enable sign export." : "Bold typography, a full-color background, and a prominent QR. Your uploaded photo is kept for photo presets."}</FieldDescription>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field><FieldLabel htmlFor="sign-background">Background</FieldLabel><Input id="sign-background" type="color" value={design.background ?? "#eeece5"} onChange={e => onChange({ background: e.target.value })} /></Field>
|
||||
<Field><FieldLabel htmlFor="sign-accent">Accent</FieldLabel><Input id="sign-accent" type="color" value={design.accent ?? "#4055b5"} onChange={e => onChange({ accent: e.target.value })} /><FieldDescription>Text adapts for contrast against the background.</FieldDescription></Field>
|
||||
<Field><FieldLabel htmlFor="sign-background">Background</FieldLabel><Input id="sign-background" type="color" value={design.background ?? brandPalette.background} onChange={e => onChange({ background: e.target.value })} /></Field>
|
||||
<Field><FieldLabel htmlFor="sign-accent">Accent</FieldLabel><Input id="sign-accent" type="color" value={design.accent ?? brandPalette.primary} onChange={e => onChange({ accent: e.target.value })} /><FieldDescription>Text adapts for contrast against the background.</FieldDescription></Field>
|
||||
</div>
|
||||
{([{ key: "backgroundImage", label: "Background photo" }, { key: "logoImage", label: "Your logo" }] as const).map(field => <Field key={field.key}>
|
||||
<FieldLabel htmlFor={`sign-${field.key}`}>{field.label}</FieldLabel><Input id={`sign-${field.key}`} type="file" accept="image/jpeg,image/png,image/webp" disabled={busy} onChange={e => { const file = e.target.files?.[0]; e.target.value = ""; if (file) void upload(file, field.key); }} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { brandPalette } from "@album/contracts";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -23,7 +24,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
|
||||
eventId: string; title: string; guestUrl: string; qr: SignQr; warning: string | null;
|
||||
savedUrl: string | null; savedRevision: string | null; canSave: boolean;
|
||||
}) {
|
||||
const [design, setDesign] = useState<SignDesign>({ title, headline: "Share your favorite moments", message: "Help us see the day through your eyes. Add your photos to our shared album.", paper: "letter", ink: "indigo", font: "funnel", layout: "typography", background: "#4055b5", accent: "#ffffff", decoration: "minimal", showBrand: true, customWidth: 8.5, customHeight: 11, unit: "in" });
|
||||
const [design, setDesign] = useState<SignDesign>({ title, headline: "Share your favorite moments", message: "Help us see the day through your eyes. Add your photos to our shared album.", paper: "letter", ink: "indigo", font: "funnel", layout: "typography", background: brandPalette.primary, accent: "#ffffff", decoration: "minimal", showBrand: true, customWidth: 8.5, customHeight: 11, unit: "in" });
|
||||
const [fontError, setFontError] = useState(false);
|
||||
const [assetBusy, setAssetBusy] = useState(false);
|
||||
const [loadingDesign, setLoadingDesign] = useState(Boolean(savedUrl));
|
||||
|
||||
@@ -51,52 +51,52 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #f4f1ea;
|
||||
--foreground: #171716;
|
||||
--card: #fffefa;
|
||||
--card-foreground: #171716;
|
||||
--popover: #fffefa;
|
||||
--popover-foreground: #171716;
|
||||
--primary: #3156c8;
|
||||
--background: var(--brand-background);
|
||||
--foreground: var(--brand-ink);
|
||||
--card: var(--brand-surface);
|
||||
--card-foreground: var(--brand-ink);
|
||||
--popover: var(--brand-surface);
|
||||
--popover-foreground: var(--brand-ink);
|
||||
--primary: var(--brand-primary);
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #e7e4dc;
|
||||
--secondary-foreground: #252526;
|
||||
--muted: #e9e5dc;
|
||||
--muted-foreground: #5e5a52;
|
||||
--accent: #e4e9f8;
|
||||
--muted-foreground: var(--brand-muted);
|
||||
--accent: var(--brand-accent);
|
||||
--accent-foreground: #203b90;
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: #c9c3b7;
|
||||
--border: var(--brand-border);
|
||||
--input: #c7c1b6;
|
||||
--ring: #3156c8;
|
||||
--chart-1: #3156c8;
|
||||
--ring: var(--brand-primary);
|
||||
--chart-1: var(--brand-primary);
|
||||
--chart-2: oklch(0.64 0.12 210);
|
||||
--chart-3: oklch(0.7 0.13 145);
|
||||
--chart-4: oklch(0.72 0.13 75);
|
||||
--chart-5: oklch(0.56 0.12 300);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: #fffefa;
|
||||
--sidebar-foreground: #171716;
|
||||
--sidebar-primary: #3156c8;
|
||||
--sidebar: var(--brand-surface);
|
||||
--sidebar-foreground: var(--brand-ink);
|
||||
--sidebar-primary: var(--brand-primary);
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #e4e9f8;
|
||||
--sidebar-accent: var(--brand-accent);
|
||||
--sidebar-accent-foreground: #203b90;
|
||||
--sidebar-border: #c9c3b7;
|
||||
--sidebar-ring: #3156c8;
|
||||
--sidebar-border: var(--brand-border);
|
||||
--sidebar-ring: var(--brand-primary);
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: #0d1018;
|
||||
--foreground: #f4f1ea;
|
||||
--card: #171b26;
|
||||
--card-foreground: #f4f1ea;
|
||||
--background: var(--brand-darkBackground);
|
||||
--foreground: var(--brand-background);
|
||||
--card: var(--brand-darkSurface);
|
||||
--card-foreground: var(--brand-background);
|
||||
--popover: #1b202d;
|
||||
--popover-foreground: #f4f1ea;
|
||||
--primary: #7f9cff;
|
||||
--popover-foreground: var(--brand-background);
|
||||
--primary: var(--brand-darkPrimary);
|
||||
--primary-foreground: #0c1634;
|
||||
--secondary: #262b37;
|
||||
--secondary-foreground: #f4f1ea;
|
||||
--secondary-foreground: var(--brand-background);
|
||||
--muted: #242936;
|
||||
--muted-foreground: #bbb8b1;
|
||||
--accent: #202b50;
|
||||
@@ -104,20 +104,20 @@
|
||||
--destructive: oklch(0.7 0.18 25);
|
||||
--border: #3b4150;
|
||||
--input: #4a5161;
|
||||
--ring: #7f9cff;
|
||||
--chart-1: #7f9cff;
|
||||
--ring: var(--brand-darkPrimary);
|
||||
--chart-1: var(--brand-darkPrimary);
|
||||
--chart-2: oklch(0.7 0.12 210);
|
||||
--chart-3: oklch(0.72 0.13 145);
|
||||
--chart-4: oklch(0.75 0.13 75);
|
||||
--chart-5: oklch(0.68 0.12 300);
|
||||
--sidebar: #171b26;
|
||||
--sidebar-foreground: #f4f1ea;
|
||||
--sidebar-primary: #7f9cff;
|
||||
--sidebar: var(--brand-darkSurface);
|
||||
--sidebar-foreground: var(--brand-background);
|
||||
--sidebar-primary: var(--brand-darkPrimary);
|
||||
--sidebar-primary-foreground: #0c1634;
|
||||
--sidebar-accent: #202b50;
|
||||
--sidebar-accent-foreground: #b9c7ff;
|
||||
--sidebar-border: #3b4150;
|
||||
--sidebar-ring: #7f9cff;
|
||||
--sidebar-ring: var(--brand-darkPrimary);
|
||||
}
|
||||
|
||||
@keyframes reveal-up {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { CSSProperties } from "react";
|
||||
import { brandPalette } from "@album/contracts";
|
||||
import { Funnel_Display, Geologica, Inter } from "next/font/google";
|
||||
import { TRPCReactProvider } from "@/trpc/react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
@@ -52,6 +54,7 @@ export default function RootLayout({
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html
|
||||
style={Object.fromEntries(Object.entries(brandPalette).map(([name, value]) => [`--brand-${name}`, value])) as CSSProperties}
|
||||
lang="en"
|
||||
className={`${inter.variable} ${funnelDisplay.variable} ${geologica.variable} font-sans`}
|
||||
suppressHydrationWarning
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { brandPalette } from "@album/contracts";
|
||||
import { chrome } from "@album/email";
|
||||
import { EMAIL_LOGO_BASE64 } from "../../../../packages/email/src/logo";
|
||||
import sharp from "sharp";
|
||||
|
||||
test("email chrome shares the site palette and embeds a matching monochrome logo", async () => {
|
||||
const html = chrome("<h1>Welcome</h1>");
|
||||
for (const color of [brandPalette.background, brandPalette.surface, brandPalette.ink, brandPalette.primary, brandPalette.border]) expect(html).toContain(color);
|
||||
expect(html).not.toContain("#8b5a4a");
|
||||
const { data } = await sharp(Buffer.from(EMAIL_LOGO_BASE64, "base64")).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
const expected = [23, 23, 22];
|
||||
let opaque = 0;
|
||||
for (let i = 0; i < data.length; i += 4) if (data[i + 3] === 255) {
|
||||
expect([...data.subarray(i, i + 3)]).toEqual(expected);
|
||||
opaque++;
|
||||
}
|
||||
expect(opaque).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SIGN_PAPERS, type SavedSign } from "@album/contracts";
|
||||
import { brandPalette } from "@album/contracts";
|
||||
export { SIGN_PAPERS };
|
||||
export type SignPaper = keyof typeof SIGN_PAPERS | "custom";
|
||||
export type SignDesign = SavedSign & {
|
||||
@@ -49,16 +50,16 @@ export function signLines(value: string, limit: number) {
|
||||
export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
|
||||
const paper = signPaper(design);
|
||||
const color = (value: string | undefined, fallback: string) => /^#[\da-f]{6}$/i.test(value ?? "") ? value! : fallback;
|
||||
const ink = color(design.accent, design.ink === "black" ? "#171717" : "#4055b5");
|
||||
const ink = color(design.accent, design.ink === "black" ? brandPalette.ink : brandPalette.primary);
|
||||
const luminance = (hex: string) => {
|
||||
const channels = [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||
return channels[0]! * .2126 + channels[1]! * .7152 + channels[2]! * .0722;
|
||||
};
|
||||
const background = color(design.background, "#eeece5");
|
||||
const background = color(design.background, brandPalette.background);
|
||||
const raster = (value?: string) => /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(value ?? "") ? value : undefined;
|
||||
const photo = raster(design.backgroundImage), logo = raster(design.logoImage);
|
||||
const surfaceLuminance = luminance(background);
|
||||
const bodyInk = photo || surfaceLuminance < .18 ? "#ffffff" : "#171717";
|
||||
const bodyInk = photo || surfaceLuminance < .18 ? "#ffffff" : brandPalette.ink;
|
||||
const accentContrast = (Math.max(luminance(ink), surfaceLuminance) + .05) / (Math.min(luminance(ink), surfaceLuminance) + .05);
|
||||
const headingInk = !photo && accentContrast >= 4.5 ? ink : bodyInk;
|
||||
const landscape = paper.viewHeight < 850;
|
||||
@@ -75,7 +76,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
|
||||
if (design.layout) {
|
||||
const compact = design.layout === "compact";
|
||||
const photoLed = design.layout === "photo";
|
||||
const foreground = surfaceLuminance < .18 ? "#ffffff" : "#171717";
|
||||
const foreground = surfaceLuminance < .18 ? "#ffffff" : brandPalette.ink;
|
||||
const headingColor = accentContrast >= 4.5 ? ink : foreground;
|
||||
const left = landscape && photoLed ? 650 : 70;
|
||||
const top = photoLed ? (landscape ? 80 : 600) : 85;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ImageResponse } from "next/og";
|
||||
import sharp from "sharp";
|
||||
import { brandPalette as palette } from "@album/contracts";
|
||||
import { BRAND_NAME, BRAND_TAGLINE } from "@/lib/brand";
|
||||
|
||||
export async function renderSocialImage(event?: { title: string; bannerUrl: string | null }) {
|
||||
@@ -10,18 +11,20 @@ export async function renderSocialImage(event?: { title: string; bannerUrl: stri
|
||||
readFile(path.join(process.cwd(), "public/fonts/geologica-semibold.ttf")),
|
||||
event?.bannerUrl ? loadBanner(event.bannerUrl) : null,
|
||||
]);
|
||||
const ink = banner ? palette.onPrimary : palette.ink;
|
||||
const title = event?.title ?? BRAND_TAGLINE;
|
||||
return new ImageResponse(
|
||||
<div style={{ width: "100%", height: "100%", display: "flex", background: "#263854", color: "#ffffff", position: "relative" }}>
|
||||
<div style={{ width: "100%", height: "100%", display: "flex", background: palette.background, color: ink, position: "relative" }}>
|
||||
{banner ? <img alt="" src={banner} width={1200} height={630} style={{ position: "absolute", top: 0, left: 0, objectFit: "cover" }} /> : null}
|
||||
<div style={{ position: "absolute", top: 0, left: 0, width: 1200, height: 630, background: banner ? "linear-gradient(to bottom, rgba(12,18,29,0) 15%, rgba(12,18,29,0.55) 55%, rgba(12,18,29,0.96) 100%)" : "linear-gradient(140deg, #405777 0%, #172335 100%)" }} />
|
||||
<div style={{ position: "absolute", top: 0, left: 0, width: 1200, height: 630, background: banner ? "linear-gradient(to bottom, rgba(12,18,29,0) 15%, rgba(12,18,29,0.55) 55%, rgba(12,18,29,0.96) 100%)" : `linear-gradient(140deg, ${palette.surface} 0%, ${palette.background} 100%)` }} />
|
||||
<div style={{ position: "absolute", top: 0, left: 0, width: 1200, height: 10, background: palette.primary }} />
|
||||
<div style={{ display: "flex", flexDirection: "column", justifyContent: "flex-end", padding: "52px 64px", width: "100%", height: "100%" }}>
|
||||
<div style={{ display: "flex", fontFamily: "Funnel", fontSize: title.length > 65 ? 48 : 64, lineHeight: 1.08, maxWidth: 1060, marginBottom: 30 }}>{title.length > 140 ? `${title.slice(0, 137)}…` : title}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<svg width="42" height="42" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="white" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx="21" cy="11" r="2" fill="white" />
|
||||
<path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke={ink} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx="21" cy="11" r="2" fill={ink} />
|
||||
<path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke={ink} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<div style={{ fontFamily: "Geologica", fontSize: 32 }}>{BRAND_NAME}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Shared brand colors for web, transactional mail, social previews and signs. */
|
||||
export const brandPalette = {
|
||||
background: "#f4f1ea",
|
||||
surface: "#fffefa",
|
||||
ink: "#171716",
|
||||
muted: "#5e5a52",
|
||||
border: "#d8d2c7",
|
||||
primary: "#3156c8",
|
||||
onPrimary: "#ffffff",
|
||||
accent: "#e4e9f8",
|
||||
darkBackground: "#0d1018",
|
||||
darkSurface: "#171b26",
|
||||
darkPrimary: "#7f9cff",
|
||||
} as const;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
export { brandPalette } from "./brand";
|
||||
export { SIGN_PAPERS, savedSignSchema, type SavedSign } from "./sign";
|
||||
|
||||
export const eventStatusSchema = z.enum(["draft", "published", "closed"]);
|
||||
|
||||
+13
-12
@@ -4,7 +4,8 @@ import { Resend } from "resend";
|
||||
import { EMAIL_LOGO_CID, emailLogoAttachment } from "./logo";
|
||||
export { emailBrowserPreview } from "./logo";
|
||||
|
||||
const PRIMARY = "#8b5a4a";
|
||||
import { brandPalette as palette } from "@album/contracts";
|
||||
const PRIMARY = palette.primary;
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
@@ -27,22 +28,22 @@ export function chrome(bodyHtml: string) {
|
||||
<link href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@500;600;700&family=Geologica:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>h1,h2 {font-family:'Funnel Display',Arial,Helvetica,sans-serif;line-height:1.15;letter-spacing:-.035em} @media(max-width:480px){body{padding:12px 6px!important}}</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:24px 12px;background:#f6f1ea">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;color:#292524;background:#fff;font-family:'Geologica',Arial,Helvetica,sans-serif"><tr><td>
|
||||
<div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}">
|
||||
<body style="margin:0;padding:24px 12px;background:${palette.background}">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;color:${palette.ink};background:${palette.surface};font-family:'Geologica',Arial,Helvetica,sans-serif"><tr><td>
|
||||
<div style="padding:20px 24px;border:1px solid ${palette.border};border-top:6px solid ${PRIMARY}">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr>
|
||||
<td style="vertical-align:middle;padding-right:8px"><img src="cid:${EMAIL_LOGO_CID}" width="32" height="32" alt="" style="display:block;border:0;width:32px;height:32px"></td>
|
||||
<td style="vertical-align:middle"><strong style="font-size:26px;line-height:32px;letter-spacing:-.04em;color:${PRIMARY}">Manyangles</strong></td>
|
||||
<td style="vertical-align:middle"><strong style="font-size:26px;line-height:32px;letter-spacing:-.04em;color:${palette.ink}">Manyangles</strong></td>
|
||||
</tr></table>
|
||||
<p style="margin:8px 0 0;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#78716c">Every angle. One shared album.</p>
|
||||
<p style="margin:8px 0 0;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:${palette.muted}">Every angle. One shared album.</p>
|
||||
</div>
|
||||
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0;font-size:16px;line-height:1.65">
|
||||
<div style="padding:32px;border:1px solid ${palette.border};border-top:0;font-size:16px;line-height:1.65">
|
||||
${bodyHtml}
|
||||
</div>
|
||||
<div style="padding:18px 24px;text-align:center;background:#faf7f2;border:1px solid #e8dfd2;border-top:0">
|
||||
<strong style="font-size:18px;color:${PRIMARY}">Manyangles</strong>
|
||||
<p style="margin:8px 0;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#7a6e5e">Keep the moment.</p>
|
||||
<p style="margin:0;font-size:11px;color:#7a6e5e">Hadlock Technologies LLC</p>
|
||||
<div style="padding:18px 24px;text-align:center;background:${palette.background};border:1px solid ${palette.border};border-top:0">
|
||||
<strong style="font-size:18px;color:${palette.ink}">Manyangles</strong>
|
||||
<p style="margin:8px 0;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:${palette.muted}">Keep the moment.</p>
|
||||
<p style="margin:0;font-size:11px;color:${palette.muted}">Hadlock Technologies LLC</p>
|
||||
</div>
|
||||
</td></tr></table>
|
||||
</body>
|
||||
@@ -179,7 +180,7 @@ export async function sendPasswordResetEmail(message: {
|
||||
Reset password
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:12px;color:#7a6e5e">
|
||||
<p style="font-size:12px;color:${palette.muted}">
|
||||
If you did not request this, you can ignore this message.
|
||||
</p>
|
||||
`),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 3x PNG rendering of BrandMark. Keep v1 immutable for queued email retries.
|
||||
export const EMAIL_LOGO_CID = "manyangles-mark-v1";
|
||||
export const EMAIL_LOGO_BASE64 = "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";
|
||||
// 3x PNG rendering of BrandMark in brandPalette.ink. Queued messages retain their embedded image.
|
||||
export const EMAIL_LOGO_CID = "manyangles-mark-v2";
|
||||
export const EMAIL_LOGO_BASE64 = "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";
|
||||
export const emailLogoAttachment = { filename: "manyangles-logo.png", content: EMAIL_LOGO_BASE64, contentId: EMAIL_LOGO_CID };
|
||||
export function emailBrowserPreview(html: string) {
|
||||
return html.replaceAll(`cid:${EMAIL_LOGO_CID}`, `data:image/png;base64,${EMAIL_LOGO_BASE64}`);
|
||||
|
||||
Reference in New Issue
Block a user