-
-
+ |
+
|
- |
+ |
${bodyHtml}
|
-
- Deep Dive Brewing Co · Saba, Dutch Caribbean
+ |
+ ${brandLine}
+ Saba, Dutch Caribbean
${home}
|
diff --git a/lib/trade-leads-email.ts b/lib/trade-leads-email.ts
index bf8d9b7..d288a6e 100644
--- a/lib/trade-leads-email.ts
+++ b/lib/trade-leads-email.ts
@@ -151,6 +151,7 @@ async function prepareOutboundEmail(
bodyHtml: plainTextToHtml(message.body),
markUrl: `${siteUrl}${TRADE_EMAIL_MARK_PATH}`,
siteUrl,
+ senderName: actor.name,
});
// Reply threading: resolve the referenced communication and seed
diff --git a/lib/utils.ts b/lib/utils.ts
index a62ac88..de516fc 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -5,6 +5,25 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+/**
+ * Shared tactile feedback for interactive controls that are not `Button`
+ * instances — record rows, lead cards, filter chips, disclosure controls,
+ * icon buttons. `buttonVariants` carries the same press scale for shadcn
+ * Buttons; keep the two in sync (see THEME_AND_BRANDING.md — Interactive
+ * feedback).
+ *
+ * The transition list is explicit — the cheap paint properties the
+ * existing hovers already use (color, background, border, opacity,
+ * shadow) plus the press transform. `transition-all` is avoided on
+ * purpose: it would also animate layout-affecting properties, e.g. the
+ * width change when a submit label swaps to its loading text.
+ * The press scale is gated by `motion-safe:` and the transition duration
+ * drops to zero under `motion-reduce:`: reduced-motion sessions keep an
+ * instant color/border acknowledgement with no animation at all.
+ */
+export const pressableClasses =
+ "transition-[color,background-color,border-color,box-shadow,opacity,transform,translate,scale,rotate] duration-150 motion-reduce:duration-0 motion-safe:active:scale-[0.98]";
+
/** Build a Firebase Storage download URL from a bucket name and object path. */
export function storageDownloadUrl(bucket: string, objectPath: string): string {
const encoded = encodeURIComponent(objectPath);
diff --git a/tests/lib/interaction.test.ts b/tests/lib/interaction.test.ts
new file mode 100644
index 0000000..0c75900
--- /dev/null
+++ b/tests/lib/interaction.test.ts
@@ -0,0 +1,74 @@
+import { describe, it } from "node:test";
+import assert from "node:assert";
+import { pressableClasses } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+// UX polish — the shared pressed-state primitive. These tests pin the
+// contract of pressableClasses and buttonVariants: every interactive
+// control gets a visible press acknowledgement, reduced-motion sessions
+// are never transformed, and disabled controls lose interactive feedback
+// entirely. They assert class contracts, not animation timing.
+
+describe("pressableClasses", () => {
+ it("carries a transition so state changes are visible", () => {
+ assert.match(pressableClasses, /transition/);
+ assert.match(pressableClasses, /duration-/);
+ });
+
+ it("scales down on :active but only when motion is allowed", () => {
+ assert.match(pressableClasses, /motion-safe:active:scale-/);
+ // The transform must never leak into prefers-reduced-motion sessions —
+ // it is gated by motion-safe, not merely overridden.
+ assert.doesNotMatch(pressableClasses, /motion-reduce:active:scale/);
+ });
+});
+
+describe("buttonVariants press feedback", () => {
+ it("gives button-style variants a pressed scale", () => {
+ for (const variant of [
+ "default",
+ "destructive",
+ "outline",
+ "secondary",
+ "ghost",
+ ] as const) {
+ assert.match(
+ buttonVariants({ variant }),
+ /motion-safe:active:scale-/,
+ `expected press feedback on variant "${variant}"`
+ );
+ }
+ });
+
+ it("does not scale the text-only link variant", () => {
+ const classes = buttonVariants({ variant: "link" });
+ assert.doesNotMatch(classes, /scale-/);
+ });
+
+ it("disabled controls lose interactive feedback and read as muted", () => {
+ const classes = buttonVariants({});
+ // pointer-events-none strips hover/press visuals entirely, and the
+ // opacity + desaturation read clearly as "off" rather than merely a
+ // slightly different gray.
+ assert.match(classes, /disabled:pointer-events-none/);
+ assert.match(classes, /disabled:opacity-/);
+ assert.match(classes, /disabled:saturate-/);
+ });
+
+ it("keeps a visible focus indicator on every variant", () => {
+ for (const variant of [
+ "default",
+ "destructive",
+ "outline",
+ "secondary",
+ "ghost",
+ "link",
+ ] as const) {
+ assert.match(
+ buttonVariants({ variant }),
+ /focus-visible:ring-/,
+ `expected focus-visible styling on variant "${variant}"`
+ );
+ }
+ });
+});
diff --git a/tests/lib/trade-leads-email.test.ts b/tests/lib/trade-leads-email.test.ts
index 4190f1e..96dc8dc 100644
--- a/tests/lib/trade-leads-email.test.ts
+++ b/tests/lib/trade-leads-email.test.ts
@@ -476,14 +476,53 @@ describe("buildTradeLeadOutboundHtml", () => {
assert.ok(html.includes('alt="Deep Dive Brewing Co"'));
});
+ it("renders the mark centered at the enlarged brand size", () => {
+ assert.ok(html.includes('width="92"'));
+ // Centered presentation: the cell aligns center and the image
+ // carries an auto-margin fallback for clients that need it.
+ assert.ok(html.includes("margin:0 auto"));
+ });
+
it("carries the staff body inside the card unchanged", () => {
assert.ok(html.includes("Thanks for reaching out. "));
assert.ok(html.includes("Price list attached. "));
});
it("has a brand footer with the site link", () => {
- assert.ok(html.includes("Deep Dive Brewing Co · Saba"));
+ assert.ok(html.includes("Deep Dive Brewing Co"));
+ assert.ok(html.includes("Saba, Dutch Caribbean"));
assert.ok(html.includes('href="https://deepdivebrewing.com"'));
+ // No sender name passed — the footer falls back to the bare brand line.
+ assert.ok(!html.includes("· Deep Dive Brewing Co"));
+ });
+
+ it("credits the sending admin when a sender name is supplied", () => {
+ const withSender = buildTradeLeadOutboundHtml({
+ bodyHtml: "hi ",
+ markUrl: `https://deepdivebrewing.com${TRADE_EMAIL_MARK_PATH}`,
+ siteUrl: "https://deepdivebrewing.com",
+ senderName: "Chad",
+ });
+ assert.ok(withSender.includes("Chad · Deep Dive Brewing Co"));
+ // Blank or absent names fall back to the bare brand line.
+ const blank = buildTradeLeadOutboundHtml({
+ bodyHtml: "hi ",
+ markUrl: `https://deepdivebrewing.com${TRADE_EMAIL_MARK_PATH}`,
+ siteUrl: "https://deepdivebrewing.com",
+ senderName: " ",
+ });
+ assert.ok(!blank.includes("·"));
+ });
+
+ it("escapes the sender name — it cannot inject markup", () => {
+ const evil = buildTradeLeadOutboundHtml({
+ bodyHtml: "hi ",
+ markUrl: `https://deepdivebrewing.com${TRADE_EMAIL_MARK_PATH}`,
+ siteUrl: "https://deepdivebrewing.com",
+ senderName: 'Chad ',
+ });
+ assert.ok(!evil.includes(" {
|