Add saved sign text size controls with independent QR sizing
This commit is contained in:
@@ -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
|
||||
<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>
|
||||
<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">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.</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">
|
||||
<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>
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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 [^>]*>${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", () => {
|
||||
expect(signPrintMetrics({ ...design, layout: "compact", paper: "card6x4" }).qrInches).toBeCloseTo(1.6);
|
||||
expect(signPrintMetrics({ ...design, layout: "photo", paper: "card3x5" }).qrInches).toBeLessThan(1);
|
||||
|
||||
@@ -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) => `<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) {
|
||||
const compact = design.layout === "compact";
|
||||
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}"/>
|
||||
${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>` : ""}
|
||||
${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">
|
||||
${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>
|
||||
${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>
|
||||
${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" ? `<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"/>
|
||||
<g font-family="${headingFont}">
|
||||
${text(design.title, 108, 22, landscape ? 34 : 48, 500)}
|
||||
<g fill="${headingInk}" letter-spacing="-1.5">${text(design.headline, landscape ? 265 : 230, 52, landscape ? 18 : 24, 600)}</g>
|
||||
${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">${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>
|
||||
${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">
|
||||
<rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/>
|
||||
</svg>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user