Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions THEME_AND_BRANDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,40 @@ colors: {

---

## Brand Marks

The brewery's primary brand mark is the **hoppy turtle** — a monochrome
line-art anchor/turtle/hop mark. The stacked wordmark is secondary and is
not used at icon sizes.

### Mark inventory (`public/`)

| File | Artwork | Used for |
| --- | --- | --- |
| `brand/hoppy-turtle-{black,white}-1600.png` | Full mark, 1600px master | Large placements only — never icons or email headers |
| `brand/email-mark-{black,white}.png` | Same mark, 600×503 | Site UI (`components/brand-mark.tsx`), outbound trade email header (black variant) |
| `brand/avatar-{light,dark}-512.png` | Circular badge on solid bg | App-icon source (apple-touch-icon, android-chrome) |
| `brand/avatar-transparent-{black,white}-512.png` | Circular badge, transparent | Small admin/UI badges (trade timeline outbound avatar) |
| `favicon.ico`, `favicon-{16x16,32x32}{,-dark}.png`, `favicon-48x48.png`, `favicon.svg` | Circular badge raster set | Browser favicons — the SVG adapts to dark browser chrome via `prefers-color-scheme` |

### Rules

* **Monochrome only.** Black artwork on light surfaces, white artwork on
dark surfaces. Never recolor, invert with CSS filters, or place color
variants.
* **Tiny contexts use the circular badge** (`avatar-*`, `favicon-*`),
never the full mark or wordmark — the linework collapses below ~32px.
* Site UI uses `components/brand-mark.tsx` (`tone="black"|"white"`,
`decorative` when adjacent text already names the brand).
* Email uses absolute URLs on the canonical site origin — relative paths
never resolve inside a mail client.
* App icons are `purpose: "any"`; no maskable assets exist yet (the badge
ring would clip in the safe zone) — a dedicated maskable asset is a
possible follow-up.
* BIMI (the turtle in the inbox sender column) is a future
email-infrastructure task — it needs a VMC + DNS work and is not
implemented.

## Success Criteria
The theme is correct when:
* The site is calm and readable
Expand Down
31 changes: 29 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,35 @@ export const metadata: Metadata = {
},
icons: {
icon: [
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
// SVG first — the circular badge mark adapts to light/dark browser
// chrome internally via prefers-color-scheme. PNG fallbacks below
// carry explicit media hints for browsers that ignore the SVG.
{ url: "/favicon.svg", type: "image/svg+xml" },
{
url: "/favicon-16x16.png",
sizes: "16x16",
type: "image/png",
media: "(prefers-color-scheme: light)",
},
{
url: "/favicon-16x16-dark.png",
sizes: "16x16",
type: "image/png",
media: "(prefers-color-scheme: dark)",
},
{
url: "/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
media: "(prefers-color-scheme: light)",
},
{
url: "/favicon-32x32-dark.png",
sizes: "32x32",
type: "image/png",
media: "(prefers-color-scheme: dark)",
},
{ url: "/favicon-48x48.png", sizes: "48x48", type: "image/png" },
{ url: "/favicon.ico" },
],
apple: "/apple-touch-icon.png",
Expand Down
150 changes: 104 additions & 46 deletions components/admin-trade-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
type TradeLeadView,
} from "@/lib/trade-leads-admin-common";
import {
splitQuotedEmailText,
TRADE_EMAIL_BODY_MAX,
TRADE_EMAIL_SUBJECT_MAX,
type TradeLeadCommunicationView,
Expand Down Expand Up @@ -1340,12 +1341,28 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) {
const comm = commId
? communicationsById.get(commId)
: undefined;
const isOutboundEmail =
comm?.direction === "outbound";
return (
<li key={activity.id} className="flex gap-2.5 text-sm">
<ActivityIcon
className={`mt-0.5 h-4 w-4 shrink-0 ${iconMeta.className}`}
aria-hidden="true"
/>
{isOutboundEmail ? (
// Outbound DDB mail gets the brand avatar so it
// reads as "from us" at a glance; inbound +
// system activity keep the generic icons.
// eslint-disable-next-line @next/next/no-img-element -- static 16px mark; Image adds no benefit here
<img
src="/brand/avatar-transparent-black-512.png"
alt=""
width={16}
height={16}
className="mt-0.5 h-4 w-4 shrink-0"
/>
) : (
<ActivityIcon
className={`mt-0.5 h-4 w-4 shrink-0 ${iconMeta.className}`}
aria-hidden="true"
/>
)}
<div className="min-w-0">
<p className="text-xs text-muted-foreground">
{formatAdminDateTime(activity.createdAt)}
Expand All @@ -1366,45 +1383,15 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) {
{comm?.direction === "outbound" && (
<DeliveryBadge state={comm.deliveryState} />
)}
{comm?.direction === "outbound" &&
(comm.deliveryState === "queued" ||
comm.deliveryState === "failed") &&
comm.subject &&
comm.textBody && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
disabled={saving === `retry-${comm.id}`}
onClick={() => retryEmail(comm)}
>
{saving === `retry-${comm.id}`
? "Resending…"
: "Resend"}
</Button>
)}
{comm?.direction === "inbound" &&
detail.lead.email && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() =>
openComposer({
subject: replySubject(comm.subject),
replyToCommunicationId: comm.id,
})
}
>
Reply
</Button>
)}
</div>
{comm && (
<div className="mt-1 rounded-md border border-stone bg-stone/10 p-2 text-muted-foreground">
<p className="text-xs">
{comm.subject && (
<p className="text-xs font-medium text-ink">
{comm.subject}
</p>
)}
<p className="mt-0.5 text-xs">
{comm.direction === "inbound"
? `From ${comm.from ?? "unknown sender"}`
: `To ${comm.to.join(", ") || "unknown"}`}
Expand All @@ -1415,12 +1402,83 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) {
} (not stored)`
: ""}
</p>
{comm.textBody && (
<p className="mt-1 whitespace-pre-wrap text-xs">
{comm.textBody}
{comm.truncated ? " …" : ""}
</p>
)}
{comm.textBody &&
(() => {
// Inbound mail usually quotes our
// earlier message — the reply text stays
// primary while the quote collapses.
const body =
comm.direction === "inbound"
? splitQuotedEmailText(comm.textBody)
: { fresh: comm.textBody, quoted: null };
return (
<>
<p className="mt-1 whitespace-pre-wrap text-xs">
{body.fresh}
{comm.truncated ? " …" : ""}
</p>
{body.quoted && (
<details className="mt-1">
<summary className="cursor-pointer text-[11px] text-muted-foreground/80 hover:text-muted-foreground">
Quoted history
</summary>
<p className="mt-1 whitespace-pre-wrap border-l-2 border-stone pl-2 text-[11px] text-muted-foreground/80">
{body.quoted}
</p>
</details>
)}
</>
);
})()}
{(comm.direction === "outbound" &&
(comm.deliveryState === "queued" ||
comm.deliveryState === "failed") &&
comm.subject &&
comm.textBody) ||
(comm.direction === "inbound" &&
detail.lead.email) ? (
<div className="mt-2 flex gap-1 border-t border-stone/60 pt-2">
{comm.direction === "outbound" &&
(comm.deliveryState === "queued" ||
comm.deliveryState === "failed") &&
comm.subject &&
comm.textBody && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
disabled={
saving === `retry-${comm.id}`
}
onClick={() => retryEmail(comm)}
>
{saving === `retry-${comm.id}`
? "Resending…"
: "Resend"}
</Button>
)}
{comm.direction === "inbound" &&
detail.lead.email && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() =>
openComposer({
subject: replySubject(
comm.subject
),
replyToCommunicationId: comm.id,
})
}
>
Reply
</Button>
)}
</div>
) : null}
</div>
)}
{!isNote && activity.body && (
Expand Down
46 changes: 46 additions & 0 deletions components/brand-mark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Image from "next/image";

// Deep Dive Brewing hoppy turtle — the primary brand mark. Monochrome
// only: the black file for light surfaces, the white file for dark
// surfaces. Never recolor; the two supplied assets are the whole palette.
// See THEME_AND_BRANDING.md.
const MARKS = {
black: "/brand/email-mark-black.png",
white: "/brand/email-mark-white.png",
} as const;

// Natural size of the supplied mark files — next/image keeps the 600:503
// aspect so any rendered width stays undistorted.
const MARK_WIDTH = 600;
const MARK_HEIGHT = 503;

export interface BrandMarkProps {
// Which artwork file: "black" on light backgrounds, "white" on dark.
tone: "black" | "white";
// Rendered width in CSS pixels; height follows the mark's aspect ratio.
size?: number;
// True when adjacent text already names the brand — renders alt="" so
// the mark is skipped by assistive tech instead of doubling the name.
decorative?: boolean;
className?: string;
}

export function BrandMark({
tone,
size = 32,
decorative = false,
className,
}: BrandMarkProps) {
return (
<Image
src={MARKS[tone]}
width={MARK_WIDTH}
height={MARK_HEIGHT}
sizes={`${size}px`}
alt={decorative ? "" : "Deep Dive Brewing Co"}
aria-hidden={decorative || undefined}
className={className}
style={{ width: size, height: "auto" }}
/>
);
}
6 changes: 5 additions & 1 deletion components/site-footer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import { BrandMark } from "@/components/brand-mark";
import { ConsentSettingsLink } from "@/components/consent-settings-link";
import { SOCIAL_URLS } from "@/lib/site";

Expand All @@ -8,7 +9,10 @@ export function SiteFooter() {
<div className="mx-auto max-w-300 px-6 py-12 md:py-16">
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="font-semibold text-ink">Deep Dive Brewing Co</p>
<p className="flex items-center gap-2.5 font-semibold text-ink">
<BrandMark tone="black" size={32} decorative />
Deep Dive Brewing Co
</p>
<p className="mt-2 text-sm text-muted-foreground">
Craft beer, brewed on Saba.
</p>
Expand Down
4 changes: 3 additions & 1 deletion components/site-header-default.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import { BrandMark } from "@/components/brand-mark";
import { MobileMenu } from "@/components/mobile-menu";

const navLinks = [
Expand All @@ -15,8 +16,9 @@ export function SiteHeaderDefault() {
<div className="mx-auto flex max-w-300 items-center justify-between px-6 py-4">
<Link
href="/"
className="rounded-sm text-xl font-semibold tracking-tight text-ink"
className="flex items-center gap-2.5 rounded-sm text-xl font-semibold tracking-tight text-ink"
>
<BrandMark tone="black" size={30} decorative />
Deep Dive Brewing Co
</Link>
<nav aria-label="Main" className="hidden items-center gap-8 md:flex">
Expand Down
4 changes: 3 additions & 1 deletion components/site-header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import { BrandMark } from "@/components/brand-mark";
import { MobileMenu } from "@/components/mobile-menu";

const navLinks = [
Expand All @@ -15,8 +16,9 @@ export function SiteHeader() {
<div className="mx-auto flex max-w-300 items-center justify-between px-6 py-4">
<Link
href="/"
className="rounded-sm text-xl font-semibold tracking-tight text-paper focus-visible:outline-paper"
className="flex items-center gap-2.5 rounded-sm text-xl font-semibold tracking-tight text-paper focus-visible:outline-paper"
>
<BrandMark tone="white" size={30} decorative />
Deep Dive Brewing Co
</Link>
<nav aria-label="Main" className="hidden items-center gap-8 md:flex">
Expand Down
11 changes: 10 additions & 1 deletion docs/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,16 @@ Email is a channel on the lead, not a separate inbox:
refused once the previous attempt is past the window (a `failed` send —
provider-rejected, never dispatched — stays resendable). A post-send
Firestore failure returns the communication id so the client retries the
same recorded send rather than composing a duplicate.
same recorded send rather than composing a duplicate. The HTML part is a
branded shell (`buildTradeLeadOutboundHtml` in
`lib/trade-leads-email-common.ts`): hoppy-turtle mark + white card around
the staff-written body + a compact footer — table layout and inline
styles only. The mark resolves to an absolute URL on `siteUrl`
(`/brand/email-mark-black.png`); the plain-text part is sent unchanged.
The rendered HTML is persisted on the communication (`htmlBody`) so a
resend replays the identical provider payload under the same idempotency
key; records written before branded mail fall back to the legacy
plain-text-derived HTML.
- **Inbound routing** — each lead owns `replyToken`, an 8-char token from an
unambiguous alphabet, forming `<token>@<TRADE_REPLY_DOMAIN>` (default
`reply.deepdivebrewing.com`). Replies to app-sent mail and staff-forwarded
Expand Down
Loading
Loading