Add saved sign text size controls with independent QR sizing

This commit is contained in:
2026-09-10 13:38:09 -04:00
parent 3ff4208c1b
commit 1c29fb2a8f
6 changed files with 69 additions and 9 deletions
@@ -4,14 +4,14 @@ import { brandPalette } from "@album/contracts";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { SignDesign } from "@/lib/guest-sign"; import type { SignDesign } from "@/lib/guest-sign";
import { signPaper } from "@/lib/guest-sign"; import { signPaper } from "@/lib/guest-sign";
import type { BannerCrop } from "@album/contracts"; import type { BannerCrop } from "@album/contracts";
import { BannerCropDialog } from "@/components/banner-crop-dialog"; import { BannerCropDialog } from "@/components/banner-crop-dialog";
import { Crop, Upload, Trash2 } from "lucide-react"; import { Crop, Upload, Trash2, RotateCcw } from "lucide-react";
const presets = { const presets = {
linenCard: { layout: "compact", paper: "card6x4", background: brandPalette.background, accent: brandPalette.ink, decoration: "minimal", font: "funnel" }, 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
<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-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> <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> <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>
<FieldGroup>
{([ ["headlineScale", "Headline"], ["messageScale", "Instructions"], ["titleScale", "Event name"] ] as const).map(([key, label]) => <Field key={key} data-invalid={(design[key] ?? 100) < 75 || (design[key] ?? 100) > 150}>
<FieldLabel htmlFor={`sign-${key}`}>{label} size (%)</FieldLabel>
<Input id={`sign-${key}`} type="number" min={75} max={150} step={5} value={design[key] ?? 100}
aria-describedby="sign-text-size-help"
aria-invalid={(design[key] ?? 100) < 75 || (design[key] ?? 100) > 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 }); }} />
</Field>)}
<FieldDescription id="sign-text-size-help">75150%; 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.</FieldDescription>
<Button type="button" variant="outline" onClick={() => onChange({ headlineScale: undefined, messageScale: undefined, titleScale: undefined })}><RotateCcw data-icon="inline-start" />Reset text sizes</Button>
</FieldGroup>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<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-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> <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>
@@ -135,7 +135,8 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
const svg = guestSignSvg(sizeError ? { ...design, paper: "letter" } : design, guestUrl, qr); const svg = guestSignSvg(sizeError ? { ...design, paper: "letter" } : design, guestUrl, qr);
const printMetrics = sizeError ? null : signPrintMetrics(design); const printMetrics = sizeError ? null : signPrintMetrics(design);
const fontReady = Boolean(design.fontData && design.interFontData && design.geologicaFontData); 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); const localUrl = ["localhost", "127.0.0.1", "[::1]"].includes(new URL(guestUrl).hostname);
function downloadSvg(content: string, filename: string) { function downloadSvg(content: string, filename: string) {
const url = URL.createObjectURL(new Blob([content], { type: "image/svg+xml;charset=utf-8" })); const url = URL.createObjectURL(new Blob([content], { type: "image/svg+xml;charset=utf-8" }));
+24
View File
@@ -14,6 +14,30 @@ test("standalone QR export scans to the guest link and keeps its quiet zone", as
}); });
const design: SignDesign = { title: "Sam & Alexs wedding", headline: "Share your favorite moments", message: "Add your photos to our shared album.", paper: "letter", ink: "indigo" }; const design: SignDesign = { title: "Sam & Alexs 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 [^>]*>${text}</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", () => { 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: "compact", paper: "card6x4" }).qrInches).toBeCloseTo(1.6);
expect(signPrintMetrics({ ...design, layout: "photo", paper: "card3x5" }).qrInches).toBeLessThan(1); expect(signPrintMetrics({ ...design, layout: "photo", paper: "card3x5" }).qrInches).toBeLessThan(1);
+16 -6
View File
@@ -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 ?? "")) .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(""); .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; 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) => `<text x="${x}" y="${y + index * size * 1.25}"${centered ? ' text-anchor="middle"' : ""} font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
};
if (design.layout) { if (design.layout) {
const compact = design.layout === "compact"; const compact = design.layout === "compact";
const photoLed = design.layout === "photo"; const photoLed = design.layout === "photo";
@@ -100,11 +110,11 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
<rect width="100%" height="100%" fill="${ink}"/> <rect width="100%" height="100%" fill="${ink}"/>
${photo ? `<image href="${photo}" width="100%" height="100%" preserveAspectRatio="xMidYMid slice"/>` : `<text x="50%" y="50%" text-anchor="middle" fill="white" font-size="22">Add your event photo</text>`} ${photo ? `<image href="${photo}" width="100%" height="100%" preserveAspectRatio="xMidYMid slice"/>` : `<text x="50%" y="50%" text-anchor="middle" fill="white" font-size="22">Add your event photo</text>`}
</svg>` : ""} </svg>` : ""}
${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)}
<g font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8"> <g font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
${headlineLines.map((line, index) => `<text x="${left}" y="${top + 80 + index * headlineSize * 1.1}" font-size="${headlineSize}" font-weight="650">${escapeXml(line)}</text>`).join("")} ${design.headlineScale === undefined ? headlineLines.map((line, index) => `<text x="${left}" y="${top + 80 + index * headlineSize * 1.1}" font-size="${headlineSize}" font-weight="650">${escapeXml(line)}</text>`).join("") : sizedText(design.headline, left, top + 80, fontSize, design.headlineScale, column, messageY - top - 95, 650)}
</g> </g>
${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)}
<svg x="${qrX}" y="${qrY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg> <svg x="${qrX}" y="${qrY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>
${lines("Scan to share", qrX, qrY - 22, compact ? 24 : 18, 30, 600)} ${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)))} ${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" ? `<rect x="35" y="35" width="${w - 70}" height="${h - 70}" rx="${design.decoration === "arch" ? 24 : 0}" fill="none" stroke="${headingInk}" stroke-opacity=".3" stroke-width="1"/>` : ""} ${design.decoration !== "minimal" ? `<rect x="35" y="35" width="${w - 70}" height="${h - 70}" rx="${design.decoration === "arch" ? 24 : 0}" fill="none" stroke="${headingInk}" stroke-opacity=".3" stroke-width="1"/>` : ""}
<path d="M${landscape ? 90 : 85} 158h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/> <path d="M${landscape ? 90 : 85} 158h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/>
<g font-family="${headingFont}"> <g font-family="${headingFont}">
${text(design.title, 108, 22, landscape ? 34 : 48, 500)} ${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)}
<g fill="${headingInk}" letter-spacing="-1.5">${text(design.headline, landscape ? 265 : 230, 52, landscape ? 18 : 24, 600)}</g> <g fill="${headingInk}" letter-spacing="-1.5">${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)}</g>
</g> </g>
${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)}
<svg x="${qx}" y="${qy}" width="370" height="370" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"> <svg x="${qx}" y="${qy}" width="370" height="370" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges">
<rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/> <rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/>
</svg> </svg>
+10
View File
@@ -1,6 +1,16 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { savedSignSchema } from "./sign"; 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", () => { 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" }; const design = { title: "Our event", headline: "Share your moments", message: "Photos welcome", paper: "card6x4", ink: "indigo" };
expect(savedSignSchema.safeParse(design).success).toBe(true); expect(savedSignSchema.safeParse(design).success).toBe(true);
+3
View File
@@ -29,6 +29,9 @@ const papers: ["custom", ...(keyof typeof SIGN_PAPERS)[]] = ["custom", ...Object
export const savedSignSchema = z.object({ export const savedSignSchema = z.object({
title: z.string().trim().min(1).max(120), headline: z.string().trim().min(1).max(44), title: z.string().trim().min(1).max(120), headline: z.string().trim().min(1).max(44),
message: z.string().trim().min(1).max(120), 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), paper: z.enum(papers),
ink: z.enum(["indigo", "black"]), layout: z.enum(["photo", "typography", "compact"]).optional(), ink: z.enum(["indigo", "black"]), layout: z.enum(["photo", "typography", "compact"]).optional(),
customWidth: z.number().finite().optional(), customHeight: z.number().finite().optional(), customWidth: z.number().finite().optional(), customHeight: z.number().finite().optional(),