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(),