diff --git a/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx b/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx
index 127d829..575c8ff 100644
--- a/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx
+++ b/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx
@@ -4,14 +4,14 @@ import { brandPalette } from "@album/contracts";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
-import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
+import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { SignDesign } from "@/lib/guest-sign";
import { signPaper } from "@/lib/guest-sign";
import type { BannerCrop } from "@album/contracts";
import { BannerCropDialog } from "@/components/banner-crop-dialog";
-import { Crop, Upload, Trash2 } from "lucide-react";
+import { Crop, Upload, Trash2, RotateCcw } from "lucide-react";
const presets = {
linenCard: { layout: "compact", paper: "card6x4", background: brandPalette.background, accent: brandPalette.ink, decoration: "minimal", font: "funnel" },
@@ -76,6 +76,18 @@ export function SignDesignControls({ design, onChange, onBusy }: { design: SignD
Design preset
Heading font
{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."}
+
+ {([ ["headlineScale", "Headline"], ["messageScale", "Instructions"], ["titleScale", "Event name"] ] as const).map(([key, label]) => 150}>
+ {label} size (%)
+ 150}
+ onChange={e => onChange({ [key]: e.target.value === "" || Number(e.target.value) === 100 ? undefined : Number(e.target.value) })}
+ onBlur={e => { const value = Math.min(150, Math.max(75, Number(e.target.value) || 100)); onChange({ [key]: value === 100 ? undefined : value }); }} />
+ )}
+ 75–150%; 100% uses the preset size. Preview updates live. Long text is fitted to its space; shorten it to make it larger. QR size stays unchanged. Save design to keep your changes.
+
+
Background onChange({ background: e.target.value })} />
Accent onChange({ accent: e.target.value })} />Text adapts for contrast against the background.
diff --git a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx
index f77d636..ae2a589 100644
--- a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx
+++ b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx
@@ -135,7 +135,8 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
const svg = guestSignSvg(sizeError ? { ...design, paper: "letter" } : design, guestUrl, qr);
const printMetrics = sizeError ? null : signPrintMetrics(design);
const fontReady = Boolean(design.fontData && design.interFontData && design.geologicaFontData);
- const valid = Boolean(!loadingDesign && !loadError && design.title.trim() && design.headline.trim() && design.message.trim() && !sizeError && fontReady && !assetBusy && (design.layout !== "photo" || design.backgroundImage));
+ const textSizesValid = [design.headlineScale, design.messageScale, design.titleScale].every(value => value === undefined || (Number.isFinite(value) && value >= 75 && value <= 150));
+ const valid = Boolean(!loadingDesign && !loadError && textSizesValid && design.title.trim() && design.headline.trim() && design.message.trim() && !sizeError && fontReady && !assetBusy && (design.layout !== "photo" || design.backgroundImage));
const localUrl = ["localhost", "127.0.0.1", "[::1]"].includes(new URL(guestUrl).hostname);
function downloadSvg(content: string, filename: string) {
const url = URL.createObjectURL(new Blob([content], { type: "image/svg+xml;charset=utf-8" }));
diff --git a/apps/web/src/lib/guest-sign.test.ts b/apps/web/src/lib/guest-sign.test.ts
index 8226969..e92fd12 100644
--- a/apps/web/src/lib/guest-sign.test.ts
+++ b/apps/web/src/lib/guest-sign.test.ts
@@ -14,6 +14,30 @@ test("standalone QR export scans to the guest link and keeps its quiet zone", as
});
const design: SignDesign = { title: "Sam & Alex’s wedding", headline: "Share your favorite moments", message: "Add your photos to our shared album.", paper: "letter", ink: "indigo" };
+test("text controls change each text independently without resizing the QR", () => {
+ const url = "https://ma.hadlock.tech/e/demo", qr = signQr(url);
+ const base: SignDesign = { ...design, layout: "compact", paper: "card6x4", title: "Event", headline: "Share", message: "Welcome" };
+ const normal = guestSignSvg(base, url, qr);
+ for (const [key, label] of [["titleScale", "Event"], ["headlineScale", "Share"], ["messageScale", "Welcome"]] as const) {
+ const sized = { ...base, [key]: 150 };
+ const svg = guestSignSvg(sized, url, qr);
+ const tag = (source: string, text: string) => source.match(new RegExp(`
]*>${text}`))?.[0];
+ expect(tag(svg, label)).not.toBe(tag(normal, label));
+ for (const other of ["Event", "Share", "Welcome"].filter(value => value !== label)) expect(tag(svg, other)).toBe(tag(normal, other));
+ expect(signPrintMetrics(sized)).toEqual(signPrintMetrics(base));
+ }
+});
+
+test("maximum text sizing keeps QR scannable across layouts and orientations", async () => {
+ const url = "https://ma.hadlock.tech/e/demo";
+ for (const layout of [undefined, "compact", "photo", "typography"] as const) {
+ for (const paper of ["card4x6", "card6x4"] as const) {
+ const svg = guestSignSvg({ ...design, layout, paper, title: "A wonderful wedding celebration with family and friends".repeat(2), message: "Please share your favorite photographs and leave a note for us to remember this special day together.", headlineScale: 150, messageScale: 150, titleScale: 150 }, url, signQr(url));
+ const { data, info } = await sharp(Buffer.from(svg), { density: 150 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
+ expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
+ }
+ }
+});
test("print guidance reflects physical QR size, orientation, and units", () => {
expect(signPrintMetrics({ ...design, layout: "compact", paper: "card6x4" }).qrInches).toBeCloseTo(1.6);
expect(signPrintMetrics({ ...design, layout: "photo", paper: "card3x5" }).qrInches).toBeLessThan(1);
diff --git a/apps/web/src/lib/guest-sign.ts b/apps/web/src/lib/guest-sign.ts
index 9088740..6af5baf 100644
--- a/apps/web/src/lib/guest-sign.ts
+++ b/apps/web/src/lib/guest-sign.ts
@@ -73,6 +73,16 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
.filter(([, data]) => /^data:font\/woff2;base64,[A-Za-z0-9+/=]+$/.test(data ?? ""))
.map(([family, data]) => `@font-face{font-family:${family};src:url('${data}') format('woff2');font-weight:100 900;font-style:normal}`).join("");
const qx = landscape ? 710 : 240, qy = landscape ? 235 : 420;
+ // Keep text inside its own region; changing type never moves or shrinks the QR.
+ const sizedText = (value: string, x: number, y: number, base: number, percent: number, width: number, height: number, weight = 400, centered = false) => {
+ let size = base * Math.min(150, Math.max(75, Number.isFinite(percent) ? percent : 100)) / 100;
+ let wrapped = signLines(value, Math.max(1, Math.floor(width / (size * .65))));
+ while (wrapped.length * size * 1.25 > height && size > 8) {
+ size -= .5;
+ wrapped = signLines(value, Math.max(1, Math.floor(width / (size * .65))));
+ }
+ return wrapped.map((line, index) => `
${escapeXml(line)}`).join("");
+ };
if (design.layout) {
const compact = design.layout === "compact";
const photoLed = design.layout === "photo";
@@ -100,11 +110,11 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
${photo ? `
` : `
Add your event photo`}
` : ""}
- ${lines(design.title, left, top, compact ? 25 : 19, Math.floor(column / (compact ? 15 : 11)), 500)}
+ ${design.titleScale === undefined ? lines(design.title, left, top, compact ? 25 : 19, Math.floor(column / (compact ? 15 : 11)), 500) : sizedText(design.title, left, top, compact ? 25 : 19, design.titleScale, column, 55, 500)}
- ${headlineLines.map((line, index) => `${escapeXml(line)}`).join("")}
+ ${design.headlineScale === undefined ? headlineLines.map((line, index) => `${escapeXml(line)}`).join("") : sizedText(design.headline, left, top + 80, fontSize, design.headlineScale, column, messageY - top - 95, 650)}
- ${lines(design.message, left, messageY, compact ? 26 : 18, compact ? (landscape ? 38 : 24) : photoLed ? (landscape ? 42 : 35) : (landscape ? 48 : 32))}
+ ${design.messageScale === undefined ? lines(design.message, left, messageY, compact ? 26 : 18, compact ? (landscape ? 38 : 24) : photoLed ? (landscape ? 42 : 35) : (landscape ? 48 : 32)) : sizedText(design.message, left, messageY, compact ? 26 : 18, design.messageScale, landscape && photoLed ? column : Math.min(column, qrX - left - 40), photoLed ? (landscape ? 65 : 85) : landscape ? 230 : 280)}
${lines("Scan to share", qrX, qrY - 22, compact ? 24 : 18, 30, 600)}
${lines(guestUrl.replace(/^https?:\/\//, ""), qrX, qrY + qrSize + 24, compact ? 15 : 11, Math.floor(qrSize / (compact ? 9 : 7)))}
@@ -124,10 +134,10 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
${design.decoration !== "minimal" ? `
` : ""}
- ${text(design.title, 108, 22, landscape ? 34 : 48, 500)}
- ${text(design.headline, landscape ? 265 : 230, 52, landscape ? 18 : 24, 600)}
+ ${design.titleScale === undefined ? text(design.title, 108, 22, landscape ? 34 : 48, 500) : sizedText(design.title, cx, 108, 22, design.titleScale, landscape ? 470 : 680, 45, 500, true)}
+ ${design.headlineScale === undefined ? text(design.headline, landscape ? 265 : 230, 52, landscape ? 18 : 24, 600) : sizedText(design.headline, cx, landscape ? 265 : 230, 52, design.headlineScale, landscape ? 470 : 680, landscape ? 190 : 115, 600, true)}
- ${text(design.message, landscape ? 485 : 359, 19, landscape ? 40 : 60)}
+ ${design.messageScale === undefined ? text(design.message, landscape ? 485 : 359, 19, landscape ? 40 : 60) : sizedText(design.message, cx, landscape ? 485 : 359, 19, design.messageScale, landscape ? 470 : 680, landscape ? 85 : 55, 400, true)}
diff --git a/packages/contracts/src/sign.test.ts b/packages/contracts/src/sign.test.ts
index fdaed42..1445372 100644
--- a/packages/contracts/src/sign.test.ts
+++ b/packages/contracts/src/sign.test.ts
@@ -1,6 +1,16 @@
import { expect, test } from "bun:test";
import { savedSignSchema } from "./sign";
+test("text sizing survives saved design round trips and rejects invalid scales", () => {
+ const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" };
+ expect(savedSignSchema.parse(base).headlineScale).toBeUndefined();
+ const sized = { ...base, headlineScale: 150, messageScale: 125, titleScale: 75 };
+ expect(savedSignSchema.parse(JSON.parse(JSON.stringify(sized)))).toEqual(sized);
+ for (const key of ["headlineScale", "messageScale", "titleScale"]) {
+ for (const value of [0, 74, 151, Infinity, NaN, "125"]) expect(savedSignSchema.safeParse({ ...base, [key]: value }).success).toBe(false);
+ }
+});
+
test("saved designs reject unsafe assets, invalid settings, and embedded runtime fonts", () => {
const design = { title: "Our event", headline: "Share your moments", message: "Photos welcome", paper: "card6x4", ink: "indigo" };
expect(savedSignSchema.safeParse(design).success).toBe(true);
diff --git a/packages/contracts/src/sign.ts b/packages/contracts/src/sign.ts
index 46946c0..04b49a4 100644
--- a/packages/contracts/src/sign.ts
+++ b/packages/contracts/src/sign.ts
@@ -29,6 +29,9 @@ const papers: ["custom", ...(keyof typeof SIGN_PAPERS)[]] = ["custom", ...Object
export const savedSignSchema = z.object({
title: z.string().trim().min(1).max(120), headline: z.string().trim().min(1).max(44),
message: z.string().trim().min(1).max(120),
+ headlineScale: z.number().finite().min(75).max(150).optional(),
+ messageScale: z.number().finite().min(75).max(150).optional(),
+ titleScale: z.number().finite().min(75).max(150).optional(),
paper: z.enum(papers),
ink: z.enum(["indigo", "black"]), layout: z.enum(["photo", "typography", "compact"]).optional(),
customWidth: z.number().finite().optional(), customHeight: z.number().finite().optional(),