From d517b16151dd4c88dcce258572eae7305fc2c804 Mon Sep 17 00:00:00 2001 From: pablostanley Date: Thu, 30 Jul 2026 14:09:04 -0700 Subject: [PATCH 1/2] Give the components the knobs they were pretending to have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stat card's Trend was the tell: pick "down" and you got a down arrow next to "+12.5%" and a sparkline still climbing. The control wasn't dead exactly — it moved a 12px glyph — it just didn't touch either of the two things that carry the meaning. An audit of all 150 defs found the same shape of problem everywhere. Four kinds of broken, now fixed: Controls that did nothing. Ranges whose top half was inert — profile-screen offered 2-9 cards and drew 3, gallery dropped any tile that overflowed, file-upload pinned Files to 0 unless a second control was set just so. Each one either reflows to honour the range or admits the honest maximum. Components arguing with themselves. A cart header reading "3 things" beside an Items control set to 5, kanban badges fixed at 6/3/9/1, an order summary whose total ignored its line items, a usage meter forever "14 of 20 used". These now derive from the prop they used to contradict. Copy with no way in. Every navbar CTA said "Sign up", every footer said "squig", both hero buttons and the chart title were literals. 71 new controls, mostly text fields over strings that were previously unreachable. Options too thin to matter. contact-form-block's "Split layout" was a yes/no select defaulting to boolean true, so it rendered blank. feature-grid and pricing-block each marked four controls quick when the context row hard-slices at three, so the fourth silently vanished. On Trend specifically: it now drives the arrow, the sparkline's slope, and the delta's sign. An unsigned delta picks up the trend's sign — the default is "12.5%" now — but a sign you typed yourself is left alone, because arguing with someone's own input is worse than the contradiction. Saved documents keep rendering the same. Nodes freeze {...def.defaults} into props at drop time, so an old node never gains a new key; every added prop falls back to the literal it replaced. Verified by rendering both versions of each def across the old prop set and diffing prims. Adds scripts/test-defs.ts: every def at 60/100/150%, asserting it doesn't throw, draws something, has no NaN, stays inside its own box, keeps every control key in defaults, keeps select defaults among their options, and respects the three-quick-control cap. 1200 checks over 150 components. It caught five defs that had been spilling content out of the box when scaled down — login by 130px — which are also fixed here. Co-Authored-By: Claude Opus 5 --- lib/library/defs-basic.ts | 87 +++++-- lib/library/defs-blocks-app.ts | 236 ++++++++++++------- lib/library/defs-blocks-marketing.ts | 227 ++++++++++++------- lib/library/defs-display.ts | 234 ++++++++++++++----- lib/library/defs-extra.ts | 73 +++++- lib/library/defs-more.ts | 326 ++++++++++++++++++++------- lib/library/defs-nav.ts | 148 ++++++++++-- lib/library/defs-templates.ts | 234 +++++++++++++------ package.json | 2 +- scripts/test-defs.ts | 198 ++++++++++++++++ 10 files changed, 1347 insertions(+), 418 deletions(-) create mode 100644 scripts/test-defs.ts diff --git a/lib/library/defs-basic.ts b/lib/library/defs-basic.ts index b70dbf3..ee54ae8 100644 --- a/lib/library/defs-basic.ts +++ b/lib/library/defs-basic.ts @@ -19,17 +19,25 @@ export const buttonDef: ComponentDef = { group: "Buttons", keywords: ["btn", "cta", "action"], size: { w: 132, h: 40 }, - defaults: { label: "Click me", variant: "filled", size: "md", icon: "none" }, + defaults: { label: "Click me", variant: "filled", size: "md", icon: "none", glyph: "plus" }, controls: [ { key: "label", label: "Label", type: "text" }, { key: "variant", label: "Variant", type: "select", options: ["filled", "outline", "ghost"], quick: true }, { key: "size", label: "Size", type: "select", options: ["sm", "md", "lg"], quick: true }, - { key: "icon", label: "Icon", type: "select", options: ["none", "left", "right"], quick: true }, + { key: "icon", label: "Icon side", type: "select", options: ["none", "left", "right"], quick: true }, + { + key: "glyph", + label: "Glyph", + type: "select", + options: ["plus", "arrow-right", "arrow-left", "download-simple", "paper-plane-tilt", "sparkle"], + }, ], render(p, w, h) { const variant = str(p, "variant", "filled") const size = str(p, "size", "md") const iconPos = str(p, "icon", "none") + // Old nodes have no glyph key: fall back to what each side used to hardcode. + const glyph = str(p, "glyph", iconPos === "right" ? "arrow-right" : "plus") const fontSize = size === "sm" ? 13 : size === "lg" ? 18 : 15 const prims: Prim[] = [] if (variant === "filled") { @@ -46,11 +54,11 @@ export const buttonDef: ComponentDef = { const startX = (w - total) / 2 const cy = h / 2 if (iconPos === "left") { - prims.push(...icon("plus", startX + iconSize / 2, cy, iconSize)) + prims.push(...icon(glyph, startX + iconSize / 2, cy, iconSize)) prims.push(text(startX + iconSize + 8, cy + fontSize * 0.35, label, fontSize)) } else if (iconPos === "right") { prims.push(text(startX, cy + fontSize * 0.35, label, fontSize)) - prims.push(...icon("arrow-right", startX + tw + 8 + iconSize / 2, cy, iconSize)) + prims.push(...icon(glyph, startX + tw + 8 + iconSize / 2, cy, iconSize)) } else { prims.push(text(w / 2, cy + fontSize * 0.35, label, fontSize, { align: "center" })) } @@ -65,12 +73,13 @@ export const badgeDef: ComponentDef = { name: "Badge", category: "components", group: "Display", - keywords: ["tag", "chip", "pill", "label"], + keywords: ["tag", "chip", "pill", "label", "status", "dot"], size: { w: 64, h: 24 }, - defaults: { label: "New", variant: "outline" }, + defaults: { label: "New", variant: "outline", dot: false }, controls: [ { key: "label", label: "Label", type: "text" }, { key: "variant", label: "Variant", type: "select", options: ["filled", "outline"], quick: true }, + { key: "dot", label: "Status dot", type: "toggle", quick: true }, ], render(p, w, h) { const prims: Prim[] = [ @@ -78,7 +87,17 @@ export const badgeDef: ComponentDef = { ? pill(0, 0, w, h, { fill: "shade", fillColor: "ink" }) : pill(0, 0, w, h), ] - prims.push(text(w / 2, h / 2 + 4, truncate(str(p, "label", "New"), 12, w - 12), 12, { align: "center" })) + // Too narrow for a dot and a word, so the word wins. + const dot = bool(p, "dot") && w > 34 + const d = Math.max(4, Math.min(7, h * 0.26)) + const label = truncate(str(p, "label", "New"), 12, w - 12 - (dot ? d + 6 : 0)) + if (dot) { + const startX = Math.max(6, (w - (d + 6 + textWidth(label, 12))) / 2) + prims.push(ellipse(startX, h / 2 - d / 2, d, d, { fill: "solid", fillColor: "ink" })) + prims.push(text(startX + d + 6, h / 2 + 4, label, 12)) + } else { + prims.push(text(w / 2, h / 2 + 4, label, 12, { align: "center" })) + } return prims }, } @@ -277,7 +296,7 @@ export const switchDef: ComponentDef = { defaults: { label: "Turn me on", on: true, showLabel: true }, controls: [ { key: "on", label: "On", type: "toggle", quick: true }, - { key: "showLabel", label: "Show label", type: "toggle" }, + { key: "showLabel", label: "Show label", type: "toggle", quick: true }, { key: "label", label: "Label", type: "text" }, ], render(p, w, h) { @@ -309,24 +328,38 @@ export const sliderDef: ComponentDef = { group: "Forms", keywords: ["range", "volume", "form"], size: { w: 200, h: 28 }, - defaults: { value: 60, showValue: false }, + defaults: { label: "Volume", showLabel: false, value: 60, showValue: false }, controls: [ + { key: "label", label: "Label", type: "text" }, + { key: "showLabel", label: "Show label", type: "toggle", quick: true }, { key: "value", label: "Value", type: "number", min: 0, max: 100, quick: true }, { key: "showValue", label: "Show value", type: "toggle" }, ], render(p, w, h) { const v = Math.max(0, Math.min(100, num(p, "value", 60))) / 100 - const cy = h / 2 - const knob = 16 - const trackW = w - (bool(p, "showValue") ? 36 : 0) - const kx = v * (trackW - knob) + knob / 2 + // Below 26px tall the label would sit on top of the track, so it steps aside. + const showLabel = bool(p, "showLabel") && h >= 26 + const top = showLabel ? Math.min(22, h * 0.5) : 0 + const cy = top + (h - top) / 2 + // Without a label the knob is the 16 it has always been; with one it + // shrinks into whatever band the label left behind. + const knob = showLabel ? Math.max(8, Math.min(16, h - top)) : 16 + const trackW = Math.max(2, w - (bool(p, "showValue") ? 36 : 0)) + // On a track narrower than the knob, park the knob instead of pushing it + // off the left edge. + const kx = Math.max(knob / 2, v * (trackW - knob) + knob / 2) const prims: Prim[] = [ line(0, cy, trackW, cy, { stroke: "muted", strokeWidth: 2 }), line(0, cy, kx, cy, { strokeWidth: 2.5 }), ellipse(kx - knob / 2, cy - knob / 2, knob, knob, { fill: "solid", fillColor: "paper" }), ellipse(kx - knob / 2, cy - knob / 2, knob, knob), ] - if (bool(p, "showValue")) prims.push(text(trackW + 8, cy + 5, String(num(p, "value", 60)), 13, { color: "muted" })) + if (showLabel) prims.push(text(2, 13, truncate(str(p, "label", "Volume"), 13, w - 40), 13)) + if (bool(p, "showValue")) { + // The label pushes the track down; keep the number off the bottom edge. + const vy = showLabel ? Math.min(cy + 5, h - 4) : cy + 5 + prims.push(text(trackW + 8, vy, String(num(p, "value", 60)), 13, { color: "muted" })) + } return prims }, } @@ -340,12 +373,28 @@ export const progressDef: ComponentDef = { group: "Feedback", keywords: ["bar", "loading", "meter"], size: { w: 200, h: 16 }, - defaults: { value: 40 }, - controls: [{ key: "value", label: "Value", type: "number", min: 0, max: 100, quick: true }], + defaults: { value: 40, showValue: false }, + controls: [ + { key: "value", label: "Value", type: "number", min: 0, max: 100, quick: true }, + { key: "showValue", label: "Show value", type: "toggle", quick: true }, + ], render(p, w, h) { - const v = Math.max(0, Math.min(100, num(p, "value", 40))) / 100 - const prims: Prim[] = [rect(0, 0, w, h)] - if (v > 0.02) prims.push(rect(2, 2, (w - 4) * v, h - 4, { fill: "shade", fillColor: "ink", strokeWidth: 1 })) + const raw = Math.max(0, Math.min(100, num(p, "value", 40))) + const v = raw / 100 + const showValue = bool(p, "showValue") + // The number needs 36px; on a bar too short to spare them, the track keeps + // a sliver rather than going inside out. + const trackW = Math.max(2, w - (showValue ? 36 : 0)) + const fillW = Math.max(0, (trackW - 4) * v) + const prims: Prim[] = [rect(0, 0, trackW, h)] + if (v > 0.02 && fillW > 0) { + prims.push(rect(2, 2, fillW, h - 4, { fill: "shade", fillColor: "ink", strokeWidth: 1 })) + } + if (showValue) { + // A progress bar is short — shrink the number rather than let it hang out. + const fs = Math.max(8, Math.min(13, h - 3)) + prims.push(text(trackW + 8, h / 2 + fs * 0.35, `${Math.round(raw)}%`, fs, { color: "muted" })) + } return prims }, } diff --git a/lib/library/defs-blocks-app.ts b/lib/library/defs-blocks-app.ts index 1e252ed..cf3d19e 100644 --- a/lib/library/defs-blocks-app.ts +++ b/lib/library/defs-blocks-app.ts @@ -24,6 +24,9 @@ import { navbarDef, sidebarDef } from "./defs-nav" const str = (p: Props, k: string, fallback = ""): string => String(p[k] ?? fallback) const bool = (p: Props, k: string): boolean => Boolean(p[k]) +/** For toggles added after the fact — on a node that predates the key, + * `undefined` has to mean "the way it always looked", not "off". */ +const boolOn = (p: Props, k: string): boolean => p[k] === undefined || Boolean(p[k]) const num = (p: Props, k: string, fallback = 0): number => Number(p[k] ?? fallback) const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v)) @@ -83,6 +86,9 @@ function stepper(x: number, y: number, w: number, h: number, value = "1"): Prim[ ] } +/** Wireframe money — two decimals, one currency, no opinions. */ +const money = (v: number): string => `$${v.toFixed(2)}` + /** Label left, value right — the totals pattern. */ function ledgerRow(x: number, y: number, w: number, label: string, value: string, strong = false): Prim[] { return [ @@ -121,9 +127,9 @@ export const usageMeterDef: ComponentDef = { ], render(p, w, h) { const labels = [str(p, "label", "Seats"), "Storage", "API calls"] - const caps = ["14 of 20 used", "3.5 of 10 GB used", "8,800 of 10,000"] const vals = [clamp(num(p, "value", 70), 0, 100), 35, 88] - const n = clamp(Math.min(num(p, "meters", 2), Math.floor(h / 38)), 1, 3) + const caps = [`${Math.round((vals[0] / 100) * 20)} of 20 used`, "3.5 of 10 GB used", "8,800 of 10,000"] + const n = clamp(Math.min(num(p, "meters", 2), Math.floor(h / 34)), 1, 3) const slot = h / n const prims: Prim[] = [] for (let i = 0; i < n; i++) { @@ -158,9 +164,11 @@ export const invoiceListDef: ComponentDef = { const pad = 14 const headerH = bool(p, "header") ? 26 : 4 const avail = h - pad * 2 - headerH - const n = clamp(Math.min(num(p, "rows", 4), Math.floor(avail / 32)), 1, 8) + const n = clamp(Math.min(num(p, "rows", 4), Math.floor(avail / 24)), 1, 8) if (n < 1) return prims const rowH = Math.min(46, avail / n) + // Tight rows get a shorter badge so it never crowds the hairlines. + const badgeH = clamp(rowH - 6, 16, 22) const iconCx = w - pad - 9 const showIcon = w > 320 @@ -202,7 +210,7 @@ export const invoiceListDef: ComponentDef = { prims.push(text(dateX, cy + 4, dates[i], 12, { color: "muted" })) if (descW > 40) prims.push(text(descX, cy + 4, truncate(descs[i], 13, descW), 13)) prims.push(text(amountRight, cy + 4, amounts[i], 13, { align: "right", bold: true })) - if (showStatus) prims.push(...sub(badgeDef, { label: states[i], variant: "outline" }, statusX, cy - 11, statusW, 22)) + if (showStatus) prims.push(...sub(badgeDef, { label: states[i], variant: "outline" }, statusX, cy - badgeH / 2, statusW, badgeH)) if (showIcon) prims.push(...icon("download-simple", iconCx, cy, 15, { stroke: "muted" })) y += rowH } @@ -246,8 +254,8 @@ export const accountBlockDef: ComponentDef = { const labels = ["Display name", "Email", "Where you are", "Short bio"] const values = ["Pablo Scribbles", "pablo@squig.sh", "Somewhere warm", "Draws boxes for money"] const n = clamp(num(p, "fields", 3), 1, 4) - const fieldH = 58 const footTop = h - 56 + const fieldH = clamp((footTop - 6 - y - 12 * (n - 1)) / n, 46, 58) for (let i = 0; i < n; i++) { if (y + fieldH > footTop - 6) break prims.push(...sub(inputDef, { label: labels[i], placeholder: values[i], icon: i === 1 ? "mail" : "none" }, pad, y, cw, fieldH)) @@ -335,7 +343,7 @@ export const notificationListDef: ComponentDef = { category: "blocks", group: "App", keywords: ["alerts", "bell", "updates", "unread", "inbox"], - size: { w: 420, h: 360 }, + size: { w: 420, h: 400 }, defaults: { rows: 4, header: true, avatars: true }, controls: [ { key: "rows", label: "Rows", type: "number", min: 2, max: 6, quick: true }, @@ -463,7 +471,7 @@ export const commentsDef: ComponentDef = { const composer = bool(p, "composer") const composerH = composer ? 62 : 0 const avail = h - pad * 2 - composerH - const n = clamp(Math.min(num(p, "rows", 3), Math.floor(avail / 82)), 1, 4) + const n = clamp(Math.min(num(p, "rows", 3), Math.floor(avail / 72)), 1, 4) const rowH = Math.min(104, avail / Math.max(1, n)) const names = ["Maya", "Luis", "Ana", "Kai"] const times = ["2h ago", "1h ago", "34m ago", "just now"] @@ -601,7 +609,6 @@ export const kanbanBoardDef: ComponentDef = { const cols = clamp(num(p, "columns", 3), 2, 4) const colW = (w - gap * (cols - 1)) / cols const names = ["To do", "Doing", "Done", "Nope"] - const counts = ["6", "3", "9", "1"] const tags = ["bug", "copy", "design", "chore", "spike", "ugh"] const headH = 30 const bodyY = headH + 4 @@ -613,9 +620,9 @@ export const kanbanBoardDef: ComponentDef = { for (let c = 0; c < cols; c++) { const x = c * (colW + gap) prims.push(text(x + 2, 18, truncate(names[c], 14, colW - 40), 14, { bold: true })) - prims.push(...sub(badgeDef, { label: counts[c], variant: "outline" }, x + colW - 30, 4, 28, 20)) prims.push(rect(x, bodyY, colW, bodyH, { stroke: "faint", dashed: true })) let y = bodyY + 10 + let drawn = 0 const cardW = colW - 20 for (let i = 0; i < nCards; i++) { if (y + cardH > bodyY + bodyH - 8) break @@ -626,8 +633,10 @@ export const kanbanBoardDef: ComponentDef = { if (bool(p, "avatars") && cardW > 120) { prims.push(...sub(avatarDef, { content: "icon" }, x + 10 + cardW - 32, fy - 10, 20, 20)) } + drawn++ y += cardH + 10 } + prims.push(...sub(badgeDef, { label: String(drawn), variant: "outline" }, x + colW - 30, 4, 28, 20)) if (y + 20 < bodyY + bodyH && colW > 110) { prims.push(...icon("plus", x + 22, y + 8, 11, { stroke: "faint" })) prims.push(text(x + 34, y + 12, truncate("Add a card", 12, colW - 44), 12, { color: "faint" })) @@ -646,16 +655,20 @@ export const calendarBlockDef: ComponentDef = { group: "App", keywords: ["schedule", "week", "month", "events", "agenda", "dates"], size: { w: 640, h: 400 }, - defaults: { view: "week", events: true }, + defaults: { view: "week", events: true, month: "July" }, controls: [ + { key: "month", label: "Month", type: "text" }, { key: "view", label: "View", type: "select", options: ["week", "month"], quick: true }, { key: "events", label: "Events", type: "toggle", quick: true }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] const headH = Math.min(44, h * 0.14) - prims.push(text(16, headH / 2 + 6, "July", 17, { bold: true })) - if (w > 260) prims.push(text(16 + textWidth("July", 17) + 10, headH / 2 + 6, "2026", 14, { color: "muted" })) + const rightEdge = w > 320 ? w - 150 : w - 60 + const yearW = w > 260 ? textWidth("2026", 14) + 10 : 0 + const monthLabel = truncate(str(p, "month", "July"), 17, Math.max(24, rightEdge - 16 - yearW)) + prims.push(text(16, headH / 2 + 6, monthLabel, 17, { bold: true })) + if (w > 260) prims.push(text(16 + textWidth(monthLabel, 17) + 10, headH / 2 + 6, "2026", 14, { color: "muted" })) prims.push(...icon("caret-left", w - 46, headH / 2, 13, { stroke: "muted" })) prims.push(...icon("caret-right", w - 20, headH / 2, 13, { stroke: "muted" })) if (w > 320) { @@ -754,6 +767,7 @@ export const fileBrowserDef: ComponentDef = { const prims: Prim[] = [rect(0, 0, w, h)] const pad = 16 const cw = w - pad * 2 + const isGrid = str(p, "layout", "grid") === "grid" let y = pad if (bool(p, "toolbar")) { prims.push(...sub(breadcrumbDef, { items: "Home, Projects, Doodles" }, pad, y, Math.min(cw * 0.55, 240), 24)) @@ -763,8 +777,8 @@ export const fileBrowserDef: ComponentDef = { rx -= 96 } if (cw > 300) { - prims.push(...icon("squares-four", rx - 12, y + 12, 15, { stroke: "ink" })) - prims.push(...icon("list-bullets", rx - 38, y + 12, 15, { stroke: "faint" })) + prims.push(...icon("squares-four", rx - 12, y + 12, 15, { stroke: isGrid ? "ink" : "faint" })) + prims.push(...icon("list-bullets", rx - 38, y + 12, 15, { stroke: isGrid ? "faint" : "ink" })) } y += 34 prims.push(hair(pad, y, cw)) @@ -787,14 +801,18 @@ export const fileBrowserDef: ComponentDef = { ] const n = clamp(num(p, "items", 8), 1, 12) const areaH = h - y - pad + // Below this the tiles/rows would have to be shorter than their own + // contents; the toolbar alone is the honest answer. + if (areaH < 24) return prims - if (str(p, "layout", "grid") === "grid") { + if (isGrid) { const gap = 14 const cols = clamp(Math.floor((cw + gap) / (110 + gap)), 2, 6) const tileW = (cw - gap * (cols - 1)) / cols - const tileH = Math.min(104, tileW * 0.9) - const rowsFit = Math.max(1, Math.floor((areaH + gap) / (tileH + gap))) - const shown = Math.min(n, cols * rowsFit) + const rowsFit = Math.max(1, Math.floor((areaH + gap) / (56 + gap))) + const rows = Math.min(Math.ceil(n / cols), rowsFit) + const tileH = Math.min(104, tileW * 0.9, (areaH - gap * (rows - 1)) / rows) + const shown = Math.min(n, cols * rows) for (let i = 0; i < shown; i++) { const c = i % cols const r = Math.floor(i / cols) @@ -805,7 +823,7 @@ export const fileBrowserDef: ComponentDef = { prims.push(text(x + tileW / 2, ty + tileH - 14, truncate(names[i], 11, tileW - 12), 11, { align: "center" })) } } else { - const rowH = Math.min(40, areaH / Math.max(1, Math.min(n, Math.floor(areaH / 30)))) + const rowH = Math.min(40, areaH / Math.max(1, Math.min(n, Math.floor(areaH / 26)))) const shown = Math.min(n, Math.max(1, Math.floor(areaH / rowH))) const sizes = ["—", "—", "—", "4.2 MB", "812 KB", "3 KB", "88 KB", "1.1 MB", "2.4 MB", "1 KB", "18 MB", "9 MB"] const when = ["today", "today", "yesterday", "Tuesday", "Jul 3", "Jun 28", "Jun 21", "Jun 2", "May 30", "May 4", "Apr 9", "Mar 1"] @@ -959,11 +977,19 @@ export const emptyBlockDef: ComponentDef = { group: "App", keywords: ["empty", "blank", "nothing", "zero state", "placeholder"], size: { w: 360, h: 220 }, - defaults: { title: "Nothing here yet", icon: "folder", cta: true }, + defaults: { + title: "Nothing here yet", + subtitle: "Make one and it shows up right here.", + icon: "folder", + cta: true, + ctaLabel: "Make one", + }, controls: [ { key: "title", label: "Title", type: "text" }, + { key: "subtitle", label: "Subtitle", type: "text" }, { key: "icon", label: "Icon", type: "select", options: ["folder", "file", "magnifying-glass", "star", "sparkle", "bell"], quick: true }, { key: "cta", label: "Button", type: "toggle", quick: true }, + { key: "ctaLabel", label: "Button label", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h, { stroke: "faint", dashed: true })] @@ -977,11 +1003,14 @@ export const emptyBlockDef: ComponentDef = { prims.push(text(w / 2, y, truncate(str(p, "title", "Nothing here yet"), 16, w - 32), 16, { align: "center", bold: true })) y += 20 if (y < h - 6) { - prims.push(text(w / 2, y, truncate("Make one and it shows up right here.", 12, w - 32), 12, { align: "center", color: "muted" })) + const subtitle = str(p, "subtitle", "Make one and it shows up right here.") + prims.push(text(w / 2, y, truncate(subtitle, 12, w - 32), 12, { align: "center", color: "muted" })) } y += 18 if (cta && y + 40 < h) { - prims.push(...sub(buttonDef, { label: "Make one", variant: "filled", size: "sm" }, w / 2 - 62, y, 124, 34)) + const label = str(p, "ctaLabel", "Make one") + const bw = clamp(textWidth(label, 13) + 40, 124, w - 32) + prims.push(...sub(buttonDef, { label, variant: "filled", size: "sm" }, w / 2 - bw / 2, y, bw, 34)) } return prims }, @@ -996,10 +1025,12 @@ export const settingsBlockDef: ComponentDef = { group: "App", keywords: ["preferences", "toggles", "options", "switches", "config"], size: { w: 520, h: 330 }, - defaults: { rows: 4, header: true }, + defaults: { rows: 4, header: true, title: "Preferences", subcopy: true }, controls: [ { key: "rows", label: "Rows", type: "number", min: 2, max: 5, quick: true }, { key: "header", label: "Header", type: "toggle", quick: true }, + { key: "title", label: "Title", type: "text" }, + { key: "subcopy", label: "Sub-copy", type: "toggle", quick: true }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] @@ -1007,7 +1038,7 @@ export const settingsBlockDef: ComponentDef = { const cw = w - pad * 2 let y = pad if (bool(p, "header")) { - prims.push(text(pad, y + 16, "Preferences", 16, { bold: true })) + prims.push(text(pad, y + 16, truncate(str(p, "title", "Preferences"), 16, cw), 16, { bold: true })) prims.push(hair(pad, y + 28, cw)) y += 38 } @@ -1023,14 +1054,15 @@ export const settingsBlockDef: ComponentDef = { const avail = h - y - pad const n = clamp(Math.min(num(p, "rows", 4), Math.floor(avail / 44)), 1, 5) const rowH = Math.min(72, avail / Math.max(1, n)) + const subcopy = boolOn(p, "subcopy") for (let i = 0; i < n; i++) { const ry = y + i * rowH const cy = ry + rowH / 2 if (i > 0) prims.push(hair(pad, ry, cw)) const ctrlW = kinds[i] === "select" ? Math.min(118, cw * 0.3) : 44 const tw = cw - ctrlW - 24 - prims.push(text(pad, cy - 4, truncate(titles[i], 14, tw), 14, { bold: true })) - if (rowH > 44) prims.push(text(pad, cy + 14, truncate(descs[i], 12, tw), 12, { color: "muted" })) + prims.push(text(pad, subcopy ? cy - 4 : cy + 5, truncate(titles[i], 14, tw), 14, { bold: true })) + if (subcopy && rowH > 44) prims.push(text(pad, cy + 14, truncate(descs[i], 12, tw), 12, { color: "muted" })) if (kinds[i] === "select") { prims.push(...sub(selectDef, { showLabel: false, value: "GMT−6" }, w - pad - ctrlW, cy - 16, ctrlW, 32)) } else { @@ -1050,8 +1082,9 @@ export const profileHeaderDef: ComponentDef = { group: "App", keywords: ["profile", "cover", "banner", "bio", "stats", "follow"], size: { w: 560, h: 250 }, - defaults: { cover: true, stats: true, cta: true }, + defaults: { name: "Pablo Scribbles", cover: true, stats: true, cta: true }, controls: [ + { key: "name", label: "Name", type: "text" }, { key: "cover", label: "Cover", type: "toggle", quick: true }, { key: "stats", label: "Stats", type: "toggle", quick: true }, { key: "cta", label: "Button", type: "toggle" }, @@ -1067,12 +1100,22 @@ export const profileHeaderDef: ComponentDef = { const d = clamp(h * 0.31, 48, 78) const ax = pad const ay = coverH ? coverH - d * 0.48 : pad + const name = str(p, "name", "Pablo Scribbles") + const initials = ( + name + .trim() + .split(/\s+/) + .map((s) => s[0] ?? "") + .join("") + .slice(0, 2) || "PS" + ).toUpperCase() prims.push(ellipse(ax - 5, ay - 5, d + 10, d + 10, { fill: "solid", fillColor: "paper", stroke: "faint" })) - prims.push(...sub(avatarDef, { content: "initials", initials: "PS", status: true }, ax, ay, d, d)) + prims.push(...sub(avatarDef, { content: "initials", initials, status: true }, ax, ay, d, d)) let y = ay + d + 26 - prims.push(text(pad, y, truncate("Pablo Scribbles", 20, w - pad * 2 - 110), 20, { bold: true })) - prims.push(text(pad + textWidth("Pablo Scribbles", 20) + 18, y - 2, "@squiggle", 13, { color: "muted" })) + const shownName = truncate(name, 20, w - pad * 2 - 110) + prims.push(text(pad, y, shownName, 20, { bold: true })) + prims.push(text(pad + textWidth(shownName, 20) + 18, y - 2, "@squiggle", 13, { color: "muted" })) y += 20 if (y < h - 6) { prims.push(text(pad, y, truncate("Draws boxes. Occasionally circles. Rarely on time.", 13, w - pad * 2 - 20), 13, { color: "muted" })) @@ -1397,7 +1440,7 @@ export const aiAgentCardDef: ComponentDef = { defaults: { name: "Bug Squisher", status: "running", cta: true }, controls: [ { key: "name", label: "Name", type: "text" }, - { key: "status", label: "Status", type: "select", options: ["running", "idle", "asleep"], quick: true }, + { key: "status", label: "Status", type: "select", options: ["running", "idle", "failed"], quick: true }, { key: "cta", label: "Run button", type: "toggle", quick: true }, ], render(p, w, h) { @@ -1409,8 +1452,12 @@ export const aiAgentCardDef: ComponentDef = { const tx = pad + d + 14 const tw = w - tx - pad prims.push(text(tx, pad + 18, truncate(str(p, "name", "Bug Squisher"), 16, tw), 16, { bold: true })) + // Three marks at one anchor: solid dot, hollow dot, warning. Anything else + // — an older node saved with a status this def no longer offers — reads as + // the hollow dot, which is what it drew before. const status = str(p, "status", "running") - prims.push(ellipse(tx, pad + 28, 8, 8, status === "running" ? { fill: "solid", fillColor: "ink" } : { stroke: "muted" })) + if (status === "failed") prims.push(...icon("warning", tx + 4, pad + 32, 11, { stroke: "muted" })) + else prims.push(ellipse(tx, pad + 28, 8, 8, status === "running" ? { fill: "solid", fillColor: "ink" } : { stroke: "muted" })) prims.push(text(tx + 14, pad + 36, status, 12, { color: "muted" })) const by = h - 52 @@ -1433,7 +1480,7 @@ export const aiThinkingDef: ComponentDef = { category: "blocks", group: "AI", keywords: ["reasoning", "steps", "spinner", "loading", "chain"], - size: { w: 400, h: 180 }, + size: { w: 400, h: 200 }, defaults: { state: "steps", steps: 3 }, controls: [ { key: "state", label: "State", type: "select", options: ["steps", "collapsed"], quick: true }, @@ -1511,34 +1558,40 @@ export const orderSummaryDef: ComponentDef = { prims.push(hair(pad, y + 26, cw)) y += 38 } - const items: [string, string][] = [ - ["1 × Doodle Pad", "$24.00"], - ["2 × Fine-tip pens", "$18.00"], - ["1 × Sticker chaos", "$6.00"], - ["1 × Eraser, deluxe", "$4.00"], - ] - const totals: [string, string][] = [ - ["Subtotal", "$48.00"], - ["Shipping", "$6.00"], - ["Tax, sadly", "$4.32"], + const items: [string, number][] = [ + ["1 × Doodle Pad", 24], + ["2 × Fine-tip pens", 18], + ["1 × Sticker chaos", 6], + ["1 × Eraser, deluxe", 4], ] const n = clamp(num(p, "items", 3), 1, 4) - const bottomBlock = 3 * 20 + 34 + const bottomBlock = 3 * 20 + 38 + let subtotal = 0 for (let i = 0; i < n; i++) { - if (y + 22 > h - pad - bottomBlock) break - prims.push(...ledgerRow(pad, y + 12, cw, items[i][0], items[i][1])) + // The first line always draws: totals summing to nothing read as broken, + // not as "small". + if (i > 0 && y + 22 > h - pad - bottomBlock) break + subtotal += items[i][1] + prims.push(...ledgerRow(pad, y + 12, cw, items[i][0], money(items[i][1]))) y += 24 } + const totals: [string, number][] = [ + ["Subtotal", subtotal], + ["Shipping", 6], + ["Tax, sadly", subtotal * 0.09], + ] y = Math.max(y, h - pad - bottomBlock) prims.push(hair(pad, y, cw)) y += 18 - for (const [label, value] of totals) { + let extras = 0 + for (let i = 0; i < totals.length; i++) { if (y + 14 > h - pad - 26) break - prims.push(...ledgerRow(pad, y, cw, label, value)) + if (i > 0) extras += totals[i][1] + prims.push(...ledgerRow(pad, y, cw, totals[i][0], money(totals[i][1]))) y += 20 } prims.push(hair(pad, y - 4, cw)) - prims.push(...ledgerRow(pad, Math.min(y + 18, h - pad - 2), cw, "Total", "$58.32", true)) + prims.push(...ledgerRow(pad, Math.min(y + 18, h - pad - 2), cw, "Total", money(subtotal + extras), true)) return prims }, } @@ -1562,23 +1615,25 @@ export const cartDef: ComponentDef = { const prims: Prim[] = [rect(0, 0, w, h)] const pad = 16 const cw = w - pad * 2 - let y = pad - prims.push(text(pad, y + 16, "Your cart", 18, { bold: true })) - prims.push(text(w - pad, y + 16, "3 things", 12, { align: "right", color: "muted" })) - prims.push(hair(pad, y + 28, cw)) - y += 38 - const cta = bool(p, "cta") const totals = bool(p, "totals") const footH = (totals ? 74 : 0) + (cta ? 52 : 0) const names = ["Doodle Pad", "Fine-tip pens", "Sticker chaos pack", "Eraser, deluxe"] const variants = ["A5 · dotted", "Pack of 4 · black", "38 stickers", "Pink, obviously"] - const prices = ["$24.00", "$18.00", "$6.00", "$4.00"] - const avail = h - y - pad - footH - const n = clamp(Math.min(num(p, "items", 3), Math.floor(avail / 66)), 0, 4) + const prices = [24, 18, 6, 4] + const avail = h - (pad + 38) - pad - footH + // 58 is the shortest row that still fits the thumbnail and both lines of + // text — at 66 the fourth item had nowhere to go at the default height. + const n = clamp(Math.min(num(p, "items", 3), Math.floor(avail / 58)), 0, 4) const rowH = n > 0 ? Math.min(84, avail / n) : 0 const showStepper = w > 420 + let y = pad + prims.push(text(pad, y + 16, "Your cart", 18, { bold: true })) + prims.push(text(w - pad, y + 16, `${n} thing${n === 1 ? "" : "s"}`, 12, { align: "right", color: "muted" })) + prims.push(hair(pad, y + 28, cw)) + y += 38 + for (let i = 0; i < n; i++) { const ry = y + i * rowH const cy = ry + rowH / 2 @@ -1594,16 +1649,18 @@ export const cartDef: ComponentDef = { prims.push(text(tx, cy - 4, truncate(names[i], 14, tw), 14, { bold: true })) prims.push(text(tx, cy + 14, truncate(variants[i], 12, tw), 12, { color: "muted" })) if (showStepper) prims.push(...stepper(stepX, cy - 14, 84, 28, String(i === 1 ? 2 : 1))) - prims.push(text(priceX, cy + 4, prices[i], 14, { align: "right", bold: true })) + prims.push(text(priceX, cy + 4, money(prices[i]), 14, { align: "right", bold: true })) prims.push(...icon("x", rightEdge + 12, cy, 12, { stroke: "faint" })) } const fy = h - pad - footH + 10 if (totals) { + const subtotal = prices.slice(0, n).reduce((s, v) => s + v, 0) + const shipping = n > 0 ? 6 : 0 prims.push(hair(pad, fy - 12, cw)) - prims.push(...ledgerRow(pad, fy + 6, cw, "Subtotal", "$48.00")) - prims.push(...ledgerRow(pad, fy + 26, cw, "Shipping", "$6.00")) - prims.push(...ledgerRow(pad, fy + 50, cw, "Total", "$54.00", true)) + prims.push(...ledgerRow(pad, fy + 6, cw, "Subtotal", money(subtotal))) + prims.push(...ledgerRow(pad, fy + 26, cw, "Shipping", money(shipping))) + prims.push(...ledgerRow(pad, fy + 50, cw, "Total", money(subtotal + shipping), true)) } if (cta) { prims.push(...sub(buttonDef, { label: "Check out", variant: "filled" }, pad, h - pad - 42, cw, 42)) @@ -1621,14 +1678,17 @@ export const checkoutDef: ComponentDef = { group: "Commerce", keywords: ["payment", "pay", "card", "billing", "order"], size: { w: 720, h: 520 }, - defaults: { summary: true, express: true }, + defaults: { summary: true, express: true, items: 3 }, controls: [ { key: "summary", label: "Order summary", type: "toggle", quick: true }, + { key: "items", label: "Summary items", type: "number", min: 1, max: 4, quick: true }, { key: "express", label: "Express pay", type: "toggle", quick: true }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] const pad = clamp(w * 0.035, 16, 26) + const items = clamp(num(p, "items", 3), 1, 4) + const due = [24, 18, 6, 4].slice(0, items).reduce((s, v) => s + v, 0) const withSummary = bool(p, "summary") && w > 480 const sumW = withSummary ? clamp(w * 0.36, 220, 280) : 0 const cw = w - pad * 2 - (sumW ? sumW + 24 : 0) @@ -1669,12 +1729,12 @@ export const checkoutDef: ComponentDef = { prims.push(...sub(inputDef, { label: "Name on card", placeholder: "Pablo Scribbles" }, pad, y, cw, fieldH)) y += fieldH + 12 } - prims.push(...sub(buttonDef, { label: "Pay $58.32", variant: "filled" }, pad, h - pad - payH - 20, cw, payH)) + prims.push(...sub(buttonDef, { label: `Pay ${money(due + 6 + due * 0.09)}`, variant: "filled" }, pad, h - pad - payH - 20, cw, payH)) prims.push(text(pad + cw / 2, h - pad - 4, "Cancel any time. We won't be weird about it.", 11, { align: "center", color: "muted" })) if (withSummary) { const sx = w - pad - sumW - prims.push(...sub(orderSummaryDef, { items: 3, title: true, frame: true }, sx, pad, sumW, h - pad * 2)) + prims.push(...sub(orderSummaryDef, { items, title: true, frame: true }, sx, pad, sumW, h - pad * 2)) } return prims }, @@ -1689,8 +1749,10 @@ export const productDetailDef: ComponentDef = { group: "Commerce", keywords: ["product", "pdp", "gallery", "buy", "add to cart"], size: { w: 720, h: 460 }, - defaults: { thumbs: true, options: true, rating: true }, + defaults: { title: "The Doodle Pad", price: "$24.00", thumbs: true, options: true, rating: true }, controls: [ + { key: "title", label: "Title", type: "text" }, + { key: "price", label: "Price", type: "text" }, { key: "thumbs", label: "Thumbnails", type: "toggle", quick: true }, { key: "options", label: "Options", type: "toggle", quick: true }, { key: "rating", label: "Rating", type: "toggle" }, @@ -1719,7 +1781,7 @@ export const productDetailDef: ComponentDef = { let y = pad + 16 prims.push(text(x, y, "STATIONERY", 10, { color: "muted", bold: true })) y += 24 - for (const l of wrap("The Doodle Pad", 24, rw, 2)) { + for (const l of wrap(str(p, "title", "The Doodle Pad"), 24, rw, 2)) { prims.push(text(x, y, l, 24, { bold: true })) y += 28 } @@ -1728,8 +1790,9 @@ export const productDetailDef: ComponentDef = { prims.push(text(x + 5 * 15 + 8, y, "128 opinions", 11, { color: "muted" })) y += 22 } - prims.push(text(x, y + 12, "$24.00", 22, { bold: true })) - const oldX = x + textWidth("$24.00", 22) + 22 + const price = truncate(str(p, "price", "$24.00"), 22, rw - 90) + prims.push(text(x, y + 12, price, 22, { bold: true })) + const oldX = x + textWidth(price, 22) + 22 prims.push(text(oldX, y + 12, "$32.00", 14, { color: "muted" })) prims.push(line(oldX - 2, y + 7, oldX + textWidth("$32.00", 14) + 2, y + 7, { stroke: "muted", strokeWidth: 1.2 })) y += 32 @@ -1849,18 +1912,34 @@ export const paymentMethodsDef: ComponentDef = { const addRow = bool(p, "addRow") const addH = addRow ? 56 : 0 const avail = h - y - pad - addH - const n = clamp(Math.min(num(p, "cards", 2), Math.floor(avail / 58)), 1, 4) + // Rows keep their roomy two-line height while they fit. Past that they + // compress — expiry moves onto the name line — so 3 and 4 cards still show + // up instead of being quietly dropped. + const want = clamp(num(p, "cards", 2), 1, 4) + const roomy = Math.floor(avail / 58) + const n = want <= roomy ? want : clamp(Math.min(want, Math.floor(avail / 38)), 1, 4) const rowH = Math.min(70, avail / Math.max(1, n)) for (let i = 0; i < n; i++) { const ry = y + i * rowH const bh = Math.min(58, rowH - 8) const cy = ry + bh / 2 + // Below this the expiry baseline would sit on the card's bottom edge. + const stacked = bh >= 44 prims.push(rect(pad, ry, cw, bh, i === 0 ? { strokeWidth: 1.6 } : { stroke: "muted" })) prims.push(...icon("credit-card", pad + 28, cy, 19, { stroke: "muted" })) const tx = pad + 52 const tw = cw - 52 - (w > 380 ? 110 : 30) - prims.push(text(tx, cy - 3, truncate(brands[i], 14, tw), 14, { bold: true })) - prims.push(text(tx, cy + 15, expiries[i], 11, { color: "muted" })) + if (stacked) { + prims.push(text(tx, cy - 3, truncate(brands[i], 14, tw), 14, { bold: true })) + prims.push(text(tx, cy + 15, expiries[i], 11, { color: "muted" })) + } else { + const brand = truncate(brands[i], 14, tw) + prims.push(text(tx, cy + 5, brand, 14, { bold: true })) + const bw = textWidth(brand, 14) + if (bw + 12 + textWidth(expiries[i], 11) <= tw) { + prims.push(text(tx + bw + 12, cy + 4, expiries[i], 11, { color: "muted" })) + } + } if (i === 0 && w > 380) prims.push(...sub(badgeDef, { label: "Default", variant: "outline" }, pad + cw - 96, cy - 11, 66, 22)) prims.push(...icon("dots-three", pad + cw - 18, cy, 15, { stroke: "muted" })) } @@ -1939,8 +2018,9 @@ export const landingPageDef: ComponentDef = { group: "Screens", keywords: ["marketing", "hero", "features", "website", "home", "site"], size: { w: 1000, h: 830 }, - defaults: { hero: "split", features: 3, footer: true }, + defaults: { navbar: true, hero: "split", features: 3, footer: true }, controls: [ + { key: "navbar", label: "Navbar", type: "toggle" }, { key: "hero", label: "Hero", type: "select", options: ["split", "center"], quick: true }, { key: "features", label: "Features", type: "number", min: 2, max: 4, quick: true }, { key: "footer", label: "Footer", type: "toggle", quick: true }, @@ -1949,8 +2029,11 @@ export const landingPageDef: ComponentDef = { const prims: Prim[] = [rect(0, 0, w, h)] const pad = clamp(w * 0.06, 24, 72) const cw = w - pad * 2 - const navH = Math.min(64, h * 0.09) - prims.push(...sub(navbarDef, { links: "Product, Pricing, Docs, Blog", search: false, avatar: false, cta: true }, 0, 0, w, navH)) + // Old nodes predate the toggle and have no `navbar` key — they keep their navbar. + const navH = p.navbar === false ? 0 : Math.min(64, h * 0.09) + if (navH) { + prims.push(...sub(navbarDef, { links: "Product, Pricing, Docs, Blog", search: false, avatar: false, cta: true }, 0, 0, w, navH)) + } const footerH = bool(p, "footer") ? Math.min(150, h * 0.19) : 0 const ctaH = Math.min(130, h * 0.16) @@ -2252,7 +2335,8 @@ export const profileScreenDef: ComponentDef = { if (availH < 90) return prims const cols = clamp(Math.floor((w - pad * 2 + gap) / (240 + gap)), 1, 4) const cardW = (w - pad * 2 - gap * (cols - 1)) / cols - const rowsFit = Math.max(1, Math.floor((availH + gap) / (170 + gap))) + const rowsNeeded = Math.ceil(n / cols) + const rowsFit = Math.max(1, Math.min(rowsNeeded, Math.floor((availH + gap) / (110 + gap)))) const cardH = Math.min(210, (availH - gap * (rowsFit - 1)) / rowsFit) const shown = Math.min(n, cols * rowsFit) for (let i = 0; i < shown; i++) { diff --git a/lib/library/defs-blocks-marketing.ts b/lib/library/defs-blocks-marketing.ts index 2ff54d2..377189d 100644 --- a/lib/library/defs-blocks-marketing.ts +++ b/lib/library/defs-blocks-marketing.ts @@ -188,6 +188,8 @@ export const heroDef: ComponentDef = { secondCta: true, headline: "Draw it badly, ship it anyway", subline: "A wireframe tool that never pretends to be the final design.", + cta: "Start scribbling", + cta2: "See examples", }, controls: [ { key: "layout", label: "Layout", type: "select", options: ["centered", "split-image", "split-form"], quick: true }, @@ -195,6 +197,8 @@ export const heroDef: ComponentDef = { { key: "secondCta", label: "Second button", type: "toggle", quick: true }, { key: "headline", label: "Headline", type: "text" }, { key: "subline", label: "Subhead", type: "text" }, + { key: "cta", label: "Button label", type: "text" }, + { key: "cta2", label: "Second button label", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -204,8 +208,8 @@ export const heroDef: ComponentDef = { const heading = str(p, "headline", "Draw it badly, ship it anyway") const subline = str(p, "subline", "A wireframe tool that never pretends to be the final design.") const second = bool(p, "secondCta") - const l1 = "Start scribbling" - const l2 = "See examples" + const l1 = str(p, "cta", "Start scribbling") + const l2 = str(p, "cta2", "See examples") if (layout === "centered") { const colW = clamp(w - pad * 2, 160, Math.max(200, w * 0.7)) @@ -313,14 +317,18 @@ export const heroMinimalDef: ComponentDef = { keywords: ["landing", "simple", "headline", "intro", "above the fold"], size: { w: 720, h: 240 }, defaults: { + align: "center", + action: true, headline: "Wireframes that look like wireframes", subline: "Sketch the idea. Argue about it later.", cta: "Try it free", }, controls: [ + { key: "align", label: "Align", type: "select", options: ["center", "left"], quick: true }, + { key: "action", label: "Button", type: "toggle", quick: true }, { key: "headline", label: "Headline", type: "text" }, { key: "subline", label: "Subhead", type: "text" }, - { key: "cta", label: "Button", type: "text" }, + { key: "cta", label: "Button label", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -330,19 +338,24 @@ export const heroMinimalDef: ComponentDef = { const bh = clamp(h * 0.17, 30, 44) const label = str(p, "cta", "Try it free") const bw = Math.min(btnW(label), colW) + const centered = str(p, "align", "center") !== "left" + const action = bool(p, "action") const stack: Prim[] = [] - const head = fitHead(0, 0, colW, h - bh - 26, { + const head = fitHead(0, 0, colW, h - (action ? bh : 0) - 26, { heading: str(p, "headline", "Wireframes that look like wireframes"), subline: str(p, "subline", "Sketch the idea. Argue about it later."), - align: "center", + align: centered ? "center" : "left", size: hs, subLines: 1, }) stack.push(...head.prims) - let sy = head.bottom + 18 - stack.push(...sub(buttonDef, { label, variant: "filled" }, colW / 2 - bw / 2, sy, bw, bh)) - sy += bh + let sy = head.bottom + if (action) { + sy += 18 + stack.push(...sub(buttonDef, { label, variant: "filled" }, centered ? colW / 2 - bw / 2 : 0, sy, bw, bh)) + sy += bh + } prims.push(...place(stack, pad, Math.max(6, (h - sy) / 2))) return prims }, @@ -362,12 +375,16 @@ export const ctaBlockDef: ComponentDef = { headline: "Got an idea?", subline: "Ten seconds from blank canvas to first draft.", buttons: 2, + cta: "Start free", + cta2: "Talk to a human", }, controls: [ { key: "variant", label: "Variant", type: "select", options: ["centered", "split", "boxed"], quick: true }, { key: "buttons", label: "Buttons", type: "number", min: 1, max: 2, quick: true }, { key: "headline", label: "Headline", type: "text" }, { key: "subline", label: "Subhead", type: "text" }, + { key: "cta", label: "Button label", type: "text" }, + { key: "cta2", label: "Second button label", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -375,8 +392,8 @@ export const ctaBlockDef: ComponentDef = { const nBtn = int(p, "buttons", 2, 1, 2) const heading = str(p, "headline", "Got an idea?") const subline = str(p, "subline", "Ten seconds from blank canvas to first draft.") - const l1 = "Start free" - const l2 = "Talk to a human" + const l1 = str(p, "cta", "Start free") + const l2 = str(p, "cta2", "Talk to a human") const boxed = variant === "boxed" const inset = boxed ? clamp(Math.min(w, h) * 0.03, 6, 14) : 0 if (boxed) { @@ -389,17 +406,21 @@ export const ctaBlockDef: ComponentDef = { const gap = 24 const w1 = btnW(l1) const w2 = btnW(l2) - const btnCol = Math.min(w * 0.42, nBtn > 1 ? w1 + 12 + w2 : w1) + const want = nBtn > 1 ? w1 + 12 + w2 : w1 + const btnCol = Math.min(w * 0.42, want) + // a long label asks for more than the column has — shrink both to fit it + const room = Math.max(24, nBtn > 1 ? btnCol - 12 : btnCol) + const k = Math.min(1, room / (nBtn > 1 ? w1 + w2 : w1)) const textW = Math.max(120, w - pad * 2 - gap - btnCol) const head = fitHead(0, 0, textW, h - 20, { heading, subline, size: clamp(w * 0.035, 17, 26) }) prims.push(...place(head.prims, pad, Math.max(8, (h - head.bottom) / 2))) let bx = w - pad - btnCol const by = (h - bh) / 2 if (nBtn > 1) { - prims.push(...sub(buttonDef, { label: l2, variant: "outline" }, bx, by, w2, bh)) - bx += w2 + 12 + prims.push(...sub(buttonDef, { label: l2, variant: "outline" }, bx, by, w2 * k, bh)) + bx += w2 * k + 12 } - prims.push(...sub(buttonDef, { label: l1, variant: "filled" }, bx, by, Math.min(w1, w - bx - pad), bh)) + prims.push(...sub(buttonDef, { label: l1, variant: "filled" }, bx, by, Math.min(w1 * k, w - bx - pad), bh)) return prims } @@ -438,12 +459,13 @@ export const featureGridDef: ComponentDef = { group: "Marketing", keywords: ["features", "benefits", "columns", "icons", "grid"], size: { w: 820, h: 380 }, - defaults: { cols: 3, rows: 2, heading: true, boxed: false }, + defaults: { cols: 3, rows: 2, heading: true, boxed: false, headline: "Everything you sort of need" }, controls: [ { key: "cols", label: "Columns", type: "number", min: 2, max: 4, quick: true }, { key: "rows", label: "Rows", type: "number", min: 1, max: 3, quick: true }, - { key: "heading", label: "Heading", type: "toggle", quick: true }, { key: "boxed", label: "Cards", type: "toggle", quick: true }, + { key: "heading", label: "Heading", type: "toggle" }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -455,7 +477,7 @@ export const featureGridDef: ComponentDef = { if (bool(p, "heading")) { const head = headBlock(pad, pad, w - pad * 2, { eyebrow: "the good bits", - heading: "Everything you sort of need", + heading: str(p, "headline", "Everything you sort of need"), align: "center", size: clamp(w * 0.035, 17, 26), }) @@ -479,14 +501,21 @@ export const featureGridDef: ComponentDef = { let inW = cw if (boxed) { prims.push(rect(x, y, cw, ch, { stroke: "faint" })) - ix = x + 14 - iy = y + 14 - inW = cw - 28 + // a squat card takes a tighter inset so its contents still fit inside + const ins = clamp(ch * 0.13, 6, 14) + ix = x + ins + iy = y + ins + inW = cw - ins * 2 } const box = clamp(Math.min(ch * 0.28, 34), 20, 34) - prims.push(rect(ix, iy, box, box, { stroke: "faint" })) - prims.push(...icon(FEATURE_ICONS[i % FEATURE_ICONS.length], ix + box / 2, iy + box / 2, box * 0.56)) - let ty = iy + box + 20 + // a squat cell drops the icon rather than pushing the title out the bottom + const iconFits = iy - y + box + 24 <= ch + if (iconFits) { + prims.push(rect(ix, iy, box, box, { stroke: "faint" })) + prims.push(...icon(FEATURE_ICONS[i % FEATURE_ICONS.length], ix + box / 2, iy + box / 2, box * 0.56)) + } + let ty = iy + (iconFits ? box + 20 : 12) + if (ty + 4 > y + ch) continue prims.push(text(ix, ty, truncate(FEATURE_TITLES[i % FEATURE_TITLES.length], 15, inW), 15, { bold: true })) ty += 12 const room = y + ch - ty - 4 @@ -748,14 +777,16 @@ export const pricingBlockDef: ComponentDef = { defaults: { tiers: 3, highlight: 2, heading: true, billing: true }, controls: [ { key: "tiers", label: "Tiers", type: "number", min: 2, max: 4, quick: true }, - { key: "highlight", label: "Highlighted", type: "number", min: 0, max: 4, quick: true }, + { key: "highlight", label: "Highlight tier", type: "number", min: 0, max: 4, quick: true }, { key: "heading", label: "Heading", type: "toggle", quick: true }, - { key: "billing", label: "Billing toggle", type: "toggle", quick: true }, + { key: "billing", label: "Billing toggle", type: "toggle" }, ], render(p, w, h) { const prims: Prim[] = [] const n = int(p, "tiers", 3, 2, 4) - const hi = int(p, "highlight", 2, 0, 4) + // 0 means "none"; anything past the last tier highlights the last one + const hiRaw = int(p, "highlight", 2, 0, 4) + const hi = hiRaw === 0 ? 0 : clamp(hiRaw, 1, n) const pad = clamp(w * 0.035, 10, 30) let top = pad if (bool(p, "heading")) { @@ -839,16 +870,19 @@ export const faqDef: ComponentDef = { group: "Marketing", keywords: ["questions", "accordion", "help", "answers", "support"], size: { w: 740, h: 400 }, - defaults: { count: 5, columns: 1, heading: true }, + defaults: { count: 5, columns: 1, heading: true, expanded: 1 }, controls: [ { key: "count", label: "Questions", type: "number", min: 2, max: 6, quick: true }, { key: "columns", label: "Columns", type: "number", min: 1, max: 2, quick: true }, { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "expanded", label: "Expanded", type: "number", min: 0, max: 6 }, ], render(p, w, h) { const prims: Prim[] = [] const n = int(p, "count", 5, 2, 6) const cols = int(p, "columns", 1, 1, 2) + // which question is open; 0 = all shut + const exp = clamp(int(p, "expanded", 1, 0, 6), 0, n) const pad = clamp(w * 0.04, 12, 32) let top = pad if (bool(p, "heading")) { @@ -871,7 +905,7 @@ export const faqDef: ComponentDef = { const r = i % perCol const x = pad + c * (colW + gap) const y = top + r * rowH - const open = i === 0 + const open = i + 1 === exp prims.push(text(x, y + 16, truncate(FAQS[i % FAQS.length], 15, colW - 28), 15, { bold: open })) prims.push(...icon(open ? "caret-up" : "caret-down", x + colW - 10, y + 12, 12, { stroke: "muted" })) if (open) { @@ -931,11 +965,17 @@ export const newsletterDef: ComponentDef = { group: "Marketing", keywords: ["email", "subscribe", "signup", "list", "inline form"], size: { w: 700, h: 210 }, - defaults: { align: "center", finePrint: true, headline: "One email a month. Maybe." }, + defaults: { + align: "center", + finePrint: true, + headline: "One email a month. Maybe.", + subline: "Drawing tips, product notes, zero growth hacks.", + }, controls: [ { key: "align", label: "Align", type: "select", options: ["center", "left"], quick: true }, { key: "finePrint", label: "Fine print", type: "toggle", quick: true }, { key: "headline", label: "Headline", type: "text" }, + { key: "subline", label: "Subhead", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -946,7 +986,7 @@ export const newsletterDef: ComponentDef = { const stack: Prim[] = [] const head = fitHead(0, 0, colW, h - fh - (bool(p, "finePrint") ? 52 : 30), { heading: str(p, "headline", "One email a month. Maybe."), - subline: "Drawing tips, product notes, zero growth hacks.", + subline: str(p, "subline", "Drawing tips, product notes, zero growth hacks."), align: centered ? "center" : "left", size: clamp(w * 0.036, 16, 26), subLines: 1, @@ -984,7 +1024,7 @@ export const bannerDef: ComponentDef = { group: "Marketing", keywords: ["strip", "notice", "cookie", "promo", "alert bar"], size: { w: 820, h: 60 }, - defaults: { variant: "info", dismiss: true, message: "We use three cookies. They are delicious." }, + defaults: { variant: "info", dismiss: true, message: "Heads up: we reboot at noon. Bring snacks." }, controls: [ { key: "variant", label: "Variant", type: "select", options: ["info", "promo", "cookie"], quick: true }, { key: "dismiss", label: "Dismiss", type: "toggle", quick: true }, @@ -1004,7 +1044,7 @@ export const bannerDef: ComponentDef = { const ic = variant === "promo" ? "megaphone" : variant === "cookie" ? "info" : "bell" prims.push(...icon(ic, pad + 10, cy, clamp(h * 0.34, 14, 20))) let right = w - pad - const dismiss = bool(p, "dismiss") && variant !== "cookie" + const dismiss = bool(p, "dismiss") if (dismiss) { prims.push(...icon("x", right - 7, cy, 12, { stroke: "muted" })) right -= 26 @@ -1038,7 +1078,7 @@ export const bannerDef: ComponentDef = { } const tx = pad + 26 const tw = Math.max(20, right - tx) - const msg = str(p, "message", "We use three cookies. They are delicious.") + const msg = str(p, "message", "Heads up: we reboot at noon. Bring snacks.") prims.push(text(tx, cy + 5, truncate(msg, 14, tw), 14)) return prims }, @@ -1097,17 +1137,19 @@ export const footerDef: ComponentDef = { group: "Marketing", keywords: ["bottom", "sitemap", "links", "copyright", "social"], size: { w: 860, h: 280 }, - defaults: { variant: "columns", columns: 3, social: true }, + defaults: { variant: "columns", columns: 3, social: true, brand: "squig" }, controls: [ { key: "variant", label: "Variant", type: "select", options: ["simple", "columns"], quick: true }, { key: "columns", label: "Link columns", type: "number", min: 2, max: 4, quick: true }, { key: "social", label: "Social row", type: "toggle", quick: true }, + { key: "brand", label: "Brand", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] const simple = str(p, "variant", "columns") === "simple" const pad = clamp(w * 0.05, 14, 44) const social = bool(p, "social") + const brand = str(p, "brand", "squig") prims.push(line(0, 0, w, 0, { stroke: "faint" })) const botH = 44 const baseY = h - botH @@ -1115,9 +1157,10 @@ export const footerDef: ComponentDef = { if (simple) { const cy = Math.max(30, (h - botH) / 2) prims.push(...icon("logo", pad + 12, cy, 24)) - prims.push(text(pad + 32, cy + 6, "squig", 18, { bold: true })) + const mark = truncate(brand, 18, w * 0.3) + prims.push(text(pad + 32, cy + 6, mark, 18, { bold: true })) const links = ["Features", "Pricing", "Docs", "Blog", "Contact"] - let lx = pad + 120 + let lx = Math.max(pad + 120, pad + 32 + textWidth(mark, 18) + 24) for (const l of links) { const lw = textWidth(l, 13) if (lx + lw > w - pad) break @@ -1128,7 +1171,7 @@ export const footerDef: ComponentDef = { const top = clamp(h * 0.12, 18, 40) const brandW = clamp(w * 0.26, 120, 230) prims.push(...icon("logo", pad + 12, top + 12, 24)) - prims.push(text(pad + 32, top + 18, "squig", 18, { bold: true })) + prims.push(text(pad + 32, top + 18, truncate(brand, 18, Math.max(20, brandW - 52)), 18, { bold: true })) prims.push(...loremLines(pad, top + 44, brandW - 20, 2, 15)) const n = int(p, "columns", 3, 2, 4) const colsX = pad + brandW @@ -1146,7 +1189,7 @@ export const footerDef: ComponentDef = { prims.push(line(pad, baseY, w - pad, baseY, { stroke: "faint" })) const cy2 = baseY + botH / 2 - prims.push(text(pad, cy2 + 4, truncate("© 2026 squig — drawn by hand, mostly", 12, w * 0.5), 12, { color: "muted" })) + prims.push(text(pad, cy2 + 4, truncate(`© 2026 ${brand} — drawn by hand, mostly`, 12, w * 0.5), 12, { color: "muted" })) if (social) { let sx = w - pad - 10 for (let i = SOCIAL_ICONS.length - 1; i >= 0; i--) { @@ -1276,31 +1319,30 @@ export const galleryDef: ComponentDef = { { key: "style", label: "Tile style", type: "select", options: ["plain", "crossed"], quick: true }, ], render(p, w, h) { - const cols = int(p, "cols", 3, 2, 4) + const prims: Prim[] = [] const n = int(p, "count", 6, 3, 9) + // never leave an empty column + const cols = Math.min(int(p, "cols", 3, 2, 4), n) const gap = 12 const colW = (w - gap * (cols - 1)) / cols - const cursors = new Array(cols).fill(0) + if (colW < 20 || h < 40) return prims const factors = [0.44, 0.3, 0.36, 0.26, 0.4, 0.32, 0.38, 0.28, 0.34] - const tiles: { c: number; y: number; hgt: number }[] = [] - for (let i = 0; i < n; i++) { - let c = 0 - for (let k = 1; k < cols; k++) if (cursors[k] < cursors[c]) c = k - const th = h * factors[i % factors.length] - if (cursors[c] + th > h + 20) continue - tiles.push({ c, y: cursors[c], hgt: th }) - cursors[c] += th + gap - } - // stretch the last tile of each column so the block reads full-bleed - for (let c = 0; c < cols; c++) { - let last = -1 - for (let i = 0; i < tiles.length; i++) if (tiles[i].c === c) last = i - if (last >= 0) tiles[last].hgt = Math.max(40, h - tiles[last].y) - } const style = str(p, "style", "plain") - const prims: Prim[] = [] - for (const t of tiles) { - prims.push(...sub(imageDef, { style, caption: false }, t.c * (colW + gap), t.y, colW, t.hgt)) + // how many tiles a column can stack before they stop reading as pictures + const perCol = Math.max(1, Math.floor((h + gap) / (24 + gap))) + for (let c = 0; c < cols; c++) { + const weights: number[] = [] + for (let i = c; i < n && weights.length < perCol; i += cols) weights.push(factors[i % factors.length]) + const sumW = weights.reduce((a, v) => a + v, 0) + if (sumW <= 0) continue + // normalise the masonry weights so every column ends flush at h + const avail = Math.max(0, h - gap * (weights.length - 1)) + let y = 0 + for (const wt of weights) { + const hgt = (wt / sumW) * avail + prims.push(...sub(imageDef, { style, caption: false }, c * (colW + gap), y, colW, hgt)) + y += hgt + gap + } } return prims }, @@ -1319,9 +1361,10 @@ export const aboutBlockDef: ComponentDef = { group: "Content", keywords: ["story", "company", "bio", "two column", "text"], size: { w: 800, h: 320 }, - defaults: { portrait: true, heading: "We started with one bad rectangle" }, + defaults: { portrait: true, side: "right", heading: "We started with one bad rectangle" }, controls: [ { key: "portrait", label: "Portrait", type: "toggle", quick: true }, + { key: "side", label: "Portrait side", type: "select", options: ["left", "right"], quick: true }, { key: "heading", label: "Heading", type: "text" }, ], render(p, w, h) { @@ -1332,8 +1375,10 @@ export const aboutBlockDef: ComponentDef = { const gap = clamp(w * 0.04, 14, 36) const imgW = portrait ? clamp(w * 0.26, 100, 240) : 0 const textW = w - pad * 2 - (portrait ? imgW + gap : 0) + const imgLeft = portrait && str(p, "side", "right") === "left" + const textX = imgLeft ? pad + imgW + gap : pad let y = vpad - const head = headBlock(pad, y, textW, { + const head = headBlock(textX, y, textW, { eyebrow: "about us", heading: str(p, "heading", "We started with one bad rectangle"), size: clamp(w * 0.036, 16, 27), @@ -1344,11 +1389,12 @@ export const aboutBlockDef: ComponentDef = { const colW = (textW - colGap) / 2 const lines = clamp(Math.floor((h - y - vpad) / 17), 1, 8) if (lines > 0) { - prims.push(...loremLines(pad, y + 6, colW, lines, 17)) - prims.push(...loremLines(pad + colW + colGap, y + 6, colW, Math.max(1, lines - 1), 17)) + prims.push(...loremLines(textX, y + 6, colW, lines, 17)) + prims.push(...loremLines(textX + colW + colGap, y + 6, colW, Math.max(1, lines - 1), 17)) } if (portrait) { - prims.push(...sub(imageDef, { style: "plain", caption: false }, w - pad - imgW, vpad, imgW, h - vpad * 2)) + const imgX = imgLeft ? pad : w - pad - imgW + prims.push(...sub(imageDef, { style: "plain", caption: false }, imgX, vpad, imgW, h - vpad * 2)) } return prims }, @@ -1428,12 +1474,13 @@ export const blogPostHeaderDef: ComponentDef = { group: "Content", keywords: ["article", "title", "byline", "author", "cover"], size: { w: 720, h: 380 }, - defaults: { cover: true, tag: true, title: "Why your wireframe should look unfinished", align: "left" }, + defaults: { cover: true, tag: true, tagText: "Craft", title: "Why your wireframe should look unfinished", align: "left" }, controls: [ { key: "align", label: "Align", type: "select", options: ["left", "center"], quick: true }, { key: "cover", label: "Cover image", type: "toggle", quick: true }, { key: "tag", label: "Tag", type: "toggle", quick: true }, { key: "title", label: "Title", type: "text" }, + { key: "tagText", label: "Tag text", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1441,9 +1488,10 @@ export const blogPostHeaderDef: ComponentDef = { const pad = clamp(w * 0.06, 14, 48) const colW = Math.max(140, w - pad * 2) let y = clamp(h * 0.07, 10, 34) - if (bool(p, "tag")) { - if (centered) prims.push(...pillCentered("Craft", w / 2, y, 22)) - else prims.push(...pill("Craft", pad, y, 22)) + const tagLabel = str(p, "tagText", "Craft").trim() + if (bool(p, "tag") && tagLabel) { + if (centered) prims.push(...pillCentered(tagLabel, w / 2, y, 22)) + else prims.push(...pill(tagLabel, pad, y, 22)) y += 32 } const ts = clamp(w * 0.045, 18, 34) @@ -1481,11 +1529,12 @@ export const articleBodyDef: ComponentDef = { group: "Content", keywords: ["prose", "reading column", "text", "pull quote", "post"], size: { w: 640, h: 480 }, - defaults: { quote: true, bullets: true, heading: true }, + defaults: { quote: true, bullets: true, heading: true, title: "The rectangle problem" }, controls: [ - { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "heading", label: "Show heading", type: "toggle", quick: true }, { key: "quote", label: "Pull quote", type: "toggle", quick: true }, { key: "bullets", label: "Bullet list", type: "toggle", quick: true }, + { key: "title", label: "Heading", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1494,7 +1543,7 @@ export const articleBodyDef: ComponentDef = { const gap = 17 let y = pad if (bool(p, "heading")) { - prims.push(text(pad, y + 20, truncate("The rectangle problem", 22, colW), 22, { bold: true })) + prims.push(text(pad, y + 20, truncate(str(p, "title", "The rectangle problem"), 22, colW), 22, { bold: true })) y += 34 } const wantsQuote = bool(p, "quote") @@ -1584,6 +1633,12 @@ export const changelogDef: ComponentDef = { // -- contact form ----------------------------------------------------------- +const CONTACT_ROWS: [string, string][] = [ + ["envelope", "hi@squig.sh"], + ["phone", "+1 (555) 010-0101"], + ["map-pin", "Somewhere with good light"], +] + export const contactFormBlockDef: ComponentDef = { kind: "contact-form-block", name: "Contact form", @@ -1593,13 +1648,14 @@ export const contactFormBlockDef: ComponentDef = { size: { w: 780, h: 420 }, defaults: { split: true, heading: "Say hello", details: true }, controls: [ - { key: "split", label: "Split layout", type: "select", options: ["yes", "no"], quick: true }, + { key: "split", label: "Split layout", type: "toggle", quick: true }, { key: "details", label: "Contact details", type: "toggle", quick: true }, { key: "heading", label: "Heading", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] - const split = str(p, "split", "yes") !== "no" + // tolerant read: the control used to be a yes/no select, so old docs hold strings + const split = p.split !== false && p.split !== "no" const pad = clamp(w * 0.05, 14, 40) const vpad = clamp(h * 0.07, 12, 30) const gap = clamp(w * 0.05, 16, 44) @@ -1607,6 +1663,7 @@ export const contactFormBlockDef: ComponentDef = { let formX = pad let formW = w - pad * 2 + let detailsH = 0 if (split) { const leftW = clamp(w * 0.36, 140, 300) formX = pad + leftW + gap @@ -1620,12 +1677,7 @@ export const contactFormBlockDef: ComponentDef = { prims.push(...head.prims) let dy = head.bottom + 22 if (bool(p, "details")) { - const rows: [string, string][] = [ - ["envelope", "hi@squig.sh"], - ["phone", "+1 (555) 010-0101"], - ["map-pin", "Somewhere with good light"], - ] - for (const [ic, label] of rows) { + for (const [ic, label] of CONTACT_ROWS) { if (dy + 18 > h - vpad) break prims.push(...icon(ic, pad + 9, dy + 6, 15, { stroke: "muted" })) prims.push(text(pad + 28, dy + 11, truncate(label, 13, leftW - 32), 13)) @@ -1641,9 +1693,22 @@ export const contactFormBlockDef: ComponentDef = { subLines: 1, }) prims.push(...head.prims) + // same three details, laid out as one centred strip — only when there is + // room for three pairs side by side AND the textarea survives the shift + if (bool(p, "details") && formW >= 420 && h - vpad * 2 - 212 >= 60) { + const cellW = formW / CONTACT_ROWS.length + CONTACT_ROWS.forEach(([ic, label], i) => { + const maxLabel = cellW - 40 + const lw = Math.min(textWidth(label, 13), maxLabel) + const sx = pad + cellW * i + cellW / 2 - (lw + 22) / 2 + prims.push(...icon(ic, sx + 8, vpad + 90, 15, { stroke: "muted" })) + prims.push(text(sx + 22, vpad + 95, truncate(label, 13, maxLabel), 13)) + }) + detailsH = 28 + } } - let y = split ? vpad : vpad + 82 + let y = split ? vpad : vpad + 82 + detailsH const fieldH = 38 const half = (formW - 14) / 2 if (formW < 200) { @@ -1731,6 +1796,7 @@ export const sectionHeaderDef: ComponentDef = { defaults: { align: "center", eyebrow: true, + eyebrowText: "how it works", heading: "Small parts, fewer meetings", subline: "The bit of copy nobody reads but everybody argues about.", }, @@ -1739,13 +1805,14 @@ export const sectionHeaderDef: ComponentDef = { { key: "eyebrow", label: "Eyebrow", type: "toggle", quick: true }, { key: "heading", label: "Heading", type: "text" }, { key: "subline", label: "Subhead", type: "text" }, + { key: "eyebrowText", label: "Eyebrow text", type: "text" }, ], render(p, w, h) { const align = str(p, "align", "center") === "left" ? "left" : "center" const pad = clamp(w * 0.05, 10, 44) const colW = Math.max(120, w - pad * 2) const head = fitHead(0, 0, colW, h - 10, { - eyebrow: bool(p, "eyebrow") ? "section" : undefined, + eyebrow: bool(p, "eyebrow") ? str(p, "eyebrowText", "how it works") : undefined, heading: str(p, "heading", "Small parts, fewer meetings"), subline: str(p, "subline", "The bit of copy nobody reads but everybody argues about."), align, diff --git a/lib/library/defs-display.ts b/lib/library/defs-display.ts index 2e682f8..4d3e673 100644 --- a/lib/library/defs-display.ts +++ b/lib/library/defs-display.ts @@ -21,13 +21,15 @@ export const cardDef: ComponentDef = { group: "Display", keywords: ["panel", "box", "container"], size: { w: 260, h: 240 }, - defaults: { title: "Card title", image: true, header: true, footer: true, actions: false }, + defaults: { title: "Card title", image: true, header: true, footer: true, actions: false, cta: "Go", secondary: true }, controls: [ { key: "title", label: "Title", type: "text" }, + { key: "cta", label: "Button", type: "text" }, { key: "image", label: "Image", type: "toggle", quick: true }, { key: "header", label: "Header", type: "toggle", quick: true }, { key: "footer", label: "Footer", type: "toggle", quick: true }, { key: "actions", label: "Actions menu", type: "toggle" }, + { key: "secondary", label: "Second action", type: "toggle" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] @@ -45,14 +47,24 @@ export const cardDef: ComponentDef = { y += 26 } const footerH = bool(p, "footer") ? 52 : 14 - const bodyLines = Math.max(1, Math.floor((h - y - footerH) / 16)) - prims.push(...loremLines(16, y + 10, w - 32, Math.min(bodyLines, 5), 16)) + // a short card has no room for body copy — better none than lines through the footer + const bodyLines = Math.floor((h - y - footerH) / 16) + if (bodyLines > 0) prims.push(...loremLines(16, y + 10, w - 32, Math.min(bodyLines, 5), 16)) if (bool(p, "footer")) { const fy = h - 46 prims.push(line(0, fy, w, fy, { stroke: "faint" })) - prims.push(rect(w - 96, fy + 9, 80, 28, { fill: "shade", fillColor: "ink" })) - prims.push(text(w - 56, fy + 27, "Go", 13, { align: "center" })) - prims.push(text(16, fy + 27, "Nope", 13, { color: "muted" })) + const secondary = bool(p, "secondary") + const label = str(p, "cta", "Go") + // leave room for "Nope" when it's there, and never let the button eat the card + const cap = Math.max(40, w - 16 - (secondary ? 52 : 20)) + const bw = Math.min(Math.max(80, textWidth(label, 13) + 28), cap) + const bx = w - 16 - bw + prims.push(rect(bx, fy + 9, bw, 28, { fill: "shade", fillColor: "ink" })) + prims.push(text(bx + bw / 2, fy + 27, truncate(label, 13, bw - 20), 13, { align: "center" })) + // on a very narrow card the button wins and the quiet link steps aside + if (secondary && bx > 16 + textWidth("Nope", 13) + 8) { + prims.push(text(16, fy + 27, "Nope", 13, { color: "muted" })) + } } return prims }, @@ -95,20 +107,26 @@ export const dividerDef: ComponentDef = { group: "Display", keywords: ["separator", "line", "hr"], size: { w: 220, h: 20 }, - defaults: { label: "", showLabel: false }, + // `showLabel` is legacy: it has no control any more, but old documents that + // toggled it on with no text still get their "or" instead of a bare line. + defaults: { label: "", showLabel: false, direction: "horizontal" }, controls: [ - { key: "showLabel", label: "Label", type: "toggle", quick: true }, + { key: "direction", label: "Direction", type: "select", options: ["horizontal", "vertical"], quick: true }, { key: "label", label: "Text", type: "text" }, ], render(p, w, h) { + if (str(p, "direction", "horizontal") === "vertical") return [line(w / 2, 0, w / 2, h, { stroke: "muted" })] const cy = h / 2 - if (bool(p, "showLabel")) { - const t = str(p, "label") || "or" + const typed = str(p, "label").trim() + const raw = typed || (bool(p, "showLabel") ? "or" : "") + if (raw) { + const t = truncate(raw, 13, Math.max(8, w - 24)) const tw = textWidth(t, 13) + 16 + const arm = Math.max(0, (w - tw) / 2) return [ - line(0, cy, (w - tw) / 2, cy, { stroke: "muted" }), + line(0, cy, arm, cy, { stroke: "muted" }), text(w / 2, cy + 4, t, 13, { align: "center", color: "muted" }), - line((w + tw) / 2, cy, w, cy, { stroke: "muted" }), + line(w - arm, cy, w, cy, { stroke: "muted" }), ] } return [line(0, cy, w, cy, { stroke: "muted" })] @@ -136,7 +154,9 @@ export const paragraphDef: ComponentDef = { prims.push(rect(0, 0, w * 0.55, 16, { fill: "shade", fillColor: "muted", stroke: "faint", strokeWidth: 1 })) y = 34 } - const n = Math.max(1, Math.min(12, num(p, "lines", 4))) + // only as many lines as actually fit — past that they'd spill out the bottom + const fit = Math.max(1, Math.floor((h - y - 6) / 12)) + const n = Math.max(1, Math.min(fit, num(p, "lines", 4))) const gap = Math.max(12, (h - y - 6) / n) prims.push(...loremLines(0, y + 4, w, n, gap)) return prims @@ -156,7 +176,7 @@ export const tableDef: ComponentDef = { controls: [ { key: "cols", label: "Columns", type: "number", min: 2, max: 6, quick: true }, { key: "rows", label: "Rows", type: "number", min: 1, max: 10, quick: true }, - { key: "header", label: "Header", type: "toggle" }, + { key: "header", label: "Header", type: "toggle", quick: true }, ], render(p, w, h) { const cols = Math.max(2, Math.min(6, num(p, "cols", 4))) @@ -241,12 +261,22 @@ export const dialogDef: ComponentDef = { ] prims.push(text(20, 36, truncate(str(p, "title", "Are you sure?"), 18, w - 60), 18, { bold: true })) if (bool(p, "close")) prims.push(...icon("x", w - 22, 24, 14, { stroke: "muted" })) - prims.push(...loremLines(20, 62, w - 40, 2, 16)) + // body grows with the box, and gets out of the way entirely when it's short + const bodyLines = Math.min(4, Math.floor((h - 62 - 76) / 16)) + if (bodyLines > 0) prims.push(...loremLines(20, 62, w - 40, bodyLines, 16)) const by = h - 48 - prims.push(rect(w - 110, by, 90, 32, { fill: "shade", fillColor: "ink" })) - prims.push(text(w - 65, by + 21, bool(p, "danger") ? "Delete it" : "Confirm", 13, { align: "center" })) - prims.push(rect(w - 210, by, 90, 32)) - prims.push(text(w - 165, by + 21, "Cancel", 13, { align: "center" })) + // two buttons that shrink together, so the pair never walks off the left edge + const bw = Math.min(90, (w - 50) / 2) + const confirmX = w - 20 - bw + const cancelX = confirmX - 10 - bw + prims.push(rect(confirmX, by, bw, 32, { fill: "shade", fillColor: "ink" })) + prims.push( + text(confirmX + bw / 2, by + 21, truncate(bool(p, "danger") ? "Delete it" : "Confirm", 13, bw - 12), 13, { + align: "center", + }) + ) + prims.push(rect(cancelX, by, bw, 32)) + prims.push(text(cancelX + bw / 2, by + 21, truncate("Cancel", 13, bw - 12), 13, { align: "center" })) if (bool(p, "danger")) prims.push(...icon("warning", 30, by - 20, 18)) return prims }, @@ -261,14 +291,17 @@ export const dropdownDef: ComponentDef = { group: "Navigation", keywords: ["menu", "context", "options", "popover"], size: { w: 180, h: 150 }, - defaults: { items: "Profile, Settings, Invite team, Log out", icons: true }, + defaults: { items: "Profile, Settings, Invite team, Log out", icons: true, divider: true }, controls: [ { key: "items", label: "Items (comma-sep)", type: "text" }, { key: "icons", label: "Icons", type: "toggle", quick: true }, + { key: "divider", label: "Last item apart", type: "toggle", quick: true }, ], render(p, w, h) { const items = list(p, "items", "Profile, Settings, Log out") const icons = bool(p, "icons") + // the sign-out treatment: dimmed row plus a rule above it, or neither + const apart = bool(p, "divider") && items.length > 1 const iconNames = ["user", "gear", "mail", "arrow-right", "star", "trash"] as const const rowH = h / Math.max(1, items.length) const prims: Prim[] = [rect(0, 0, w, h, { fill: "solid", fillColor: "paper", shadow: true }), rect(0, 0, w, h)] @@ -279,8 +312,10 @@ export const dropdownDef: ComponentDef = { prims.push(...icon(iconNames[i % iconNames.length], 20, cy, 14, { stroke: "muted" })) tx = 36 } - prims.push(text(tx, cy + 5, truncate(item, 14, w - tx - 10), 14, { color: i === items.length - 1 ? "muted" : "ink" })) - if (i === items.length - 1 && items.length > 2) prims.push(line(8, i * rowH, w - 8, i * rowH, { stroke: "faint" })) + const lastApart = apart && i === items.length - 1 + prims.push(text(tx, cy + 5, truncate(item, 14, w - tx - 10), 14, { color: lastApart ? "muted" : "ink" })) + // the rule only earns its keep once there's a group above it to separate + if (lastApart && items.length > 2) prims.push(line(8, i * rowH, w - 8, i * rowH, { stroke: "faint" })) }) return prims }, @@ -295,15 +330,17 @@ export const toastDef: ComponentDef = { group: "Feedback", keywords: ["notification", "snackbar", "message"], size: { w: 280, h: 64 }, - defaults: { title: "Saved!", description: true, action: false }, + defaults: { title: "Saved!", variant: "success", description: true, action: false }, controls: [ { key: "title", label: "Title", type: "text" }, + { key: "variant", label: "Variant", type: "select", options: ["success", "error", "info", "loading"], quick: true }, { key: "description", label: "Description", type: "toggle", quick: true }, { key: "action", label: "Action", type: "toggle", quick: true }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h, { fill: "solid", fillColor: "paper", shadow: true }), rect(0, 0, w, h)] - prims.push(...icon("check", 22, h / 2, 16)) + const glyphs: Record = { success: "check", error: "x-circle", info: "info", loading: "hourglass" } + prims.push(...icon(glyphs[str(p, "variant", "success")] ?? "check", 22, h / 2, 16)) const hasDesc = bool(p, "description") const ty = hasDesc ? h / 2 - 6 : h / 2 + 5 prims.push(text(42, ty, truncate(str(p, "title", "Saved!"), 15, w - 100), 15, { bold: true })) @@ -316,23 +353,34 @@ export const toastDef: ComponentDef = { // -- alert ------------------------------------------------------------------ +// one glyph per state, so the variant reads without colour to lean on. +// Anything unrecognised falls back to the calm one. +const ALERT_ICONS: Record = { + info: "info", + success: "check-circle", + warning: "warning", + error: "x-circle", +} + export const alertDef: ComponentDef = { kind: "alert", name: "Alert", category: "components", group: "Feedback", - keywords: ["banner", "warning", "info", "callout"], + keywords: ["banner", "warning", "info", "success", "error", "callout"], size: { w: 320, h: 68 }, - defaults: { title: "Heads up!", variant: "info" }, + defaults: { title: "Heads up!", variant: "info", description: true }, controls: [ { key: "title", label: "Title", type: "text" }, - { key: "variant", label: "Variant", type: "select", options: ["info", "warning"], quick: true }, + { key: "variant", label: "Variant", type: "select", options: ["info", "success", "warning", "error"], quick: true }, + { key: "description", label: "Description", type: "toggle", quick: true }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] - prims.push(...icon(str(p, "variant") === "warning" ? "warning" : "info", 26, h / 2, 20)) - prims.push(text(48, h / 2 - 4, truncate(str(p, "title", "Heads up!"), 15, w - 60), 15, { bold: true })) - prims.push(line(48, h / 2 + 12, w - 20, h / 2 + 12, { stroke: "muted", strokeWidth: 1.2 })) + prims.push(...icon(ALERT_ICONS[str(p, "variant")] ?? "info", 26, h / 2, 20)) + const hasDesc = bool(p, "description") + prims.push(text(48, hasDesc ? h / 2 - 4 : h / 2 + 5, truncate(str(p, "title", "Heads up!"), 15, w - 60), 15, { bold: true })) + if (hasDesc) prims.push(line(48, h / 2 + 12, w - 20, h / 2 + 12, { stroke: "muted", strokeWidth: 1.2 })) return prims }, } @@ -346,14 +394,37 @@ export const tooltipDef: ComponentDef = { group: "Feedback", keywords: ["hint", "hover", "popover"], size: { w: 120, h: 44 }, - defaults: { label: "Helpful hint" }, - controls: [{ key: "label", label: "Text", type: "text" }], + defaults: { label: "Helpful hint", arrow: "bottom" }, + controls: [ + { key: "label", label: "Text", type: "text" }, + { key: "arrow", label: "Arrow", type: "select", options: ["bottom", "top", "left", "right"], quick: true }, + ], render(p, w, h) { - const bh = h - 10 + // the arrow eats 10px off whichever edge it hangs from, so the bubble + // shrinks on that axis and the whole thing still fits the box + const side = str(p, "arrow", "bottom") + const nub = 10 + const half = 7 + const sideways = side === "left" || side === "right" + const bw = sideways ? w - nub : w + const bh = sideways ? h : h - nub + const bx = side === "left" ? nub : 0 + const by = side === "top" ? nub : 0 + const cx = bx + bw / 2 + const cy = by + bh / 2 + // always on the middle of the edge it points out of + const nose: [number, number][] = + side === "top" + ? [[cx - half, by], [cx, 0], [cx + half, by]] + : side === "left" + ? [[bx, cy - half], [0, cy], [bx, cy + half]] + : side === "right" + ? [[bx + bw, cy - half], [w, cy], [bx + bw, cy + half]] + : [[cx - half, by + bh], [cx, h], [cx + half, by + bh]] return [ - rect(0, 0, w, bh, { fill: "shade", fillColor: "ink" }), - poly([[w / 2 - 7, bh], [w / 2, h], [w / 2 + 7, bh]], false), - text(w / 2, bh / 2 + 5, truncate(str(p, "label", "Helpful hint"), 13, w - 12), 13, { align: "center" }), + rect(bx, by, bw, bh, { fill: "shade", fillColor: "ink" }), + poly(nose, false), + text(cx, cy + 5, truncate(str(p, "label", "Helpful hint"), 13, bw - 12), 13, { align: "center" }), ] }, } @@ -367,19 +438,33 @@ export const breadcrumbDef: ComponentDef = { group: "Navigation", keywords: ["path", "nav", "trail"], size: { w: 260, h: 24 }, - defaults: { items: "Home, Library, Data" }, - controls: [{ key: "items", label: "Items (comma-sep)", type: "text" }], + defaults: { items: "Home, Library, Data", separator: "chevron", homeIcon: false }, + controls: [ + { key: "items", label: "Items (comma-sep)", type: "text" }, + { key: "separator", label: "Separator", type: "select", options: ["chevron", "slash"], quick: true }, + { key: "homeIcon", label: "Home icon", type: "toggle", quick: true }, + ], render(p, w, h) { const items = list(p, "items", "Home, Library, Data") + const slash = str(p, "separator", "chevron") === "slash" const prims: Prim[] = [] let x = 0 const cy = h / 2 + if (bool(p, "homeIcon")) { + prims.push(...icon("house", x + 7, cy, 14, { stroke: "muted" })) + x += 22 + } items.forEach((item, i) => { const last = i === items.length - 1 - prims.push(text(x, cy + 5, item, 14, { color: last ? "ink" : "muted", bold: last })) - x += textWidth(item, 14) + 10 + // whatever is left of the row, minus the separator the next crumb needs + const room = w - x - (last ? 2 : 20) + if (room < 10) return + const t = truncate(item, 14, room) + prims.push(text(x, cy + 5, t, 14, { color: last ? "ink" : "muted", bold: last })) + x += textWidth(t, 14) + 10 if (!last) { - prims.push(...icon("chevron-right", x + 4, cy, 10, { stroke: "faint" })) + if (slash) prims.push(line(x + 2, cy + 5, x + 8, cy - 5, { stroke: "faint" })) + else prims.push(...icon("chevron-right", x + 4, cy, 10, { stroke: "faint" })) x += 18 } }) @@ -404,25 +489,37 @@ export const paginationDef: ComponentDef = { render(p, w, h) { const pages = Math.max(2, Math.min(7, num(p, "pages", 5))) const current = Math.max(1, Math.min(pages, num(p, "current", 2))) - const cell = Math.min(h, 30) - const gap = 8 + // the row is cells + gaps + two chevrons; if that's wider than the box, + // everything shrinks by the same factor rather than hanging off both ends + const wanted = (pages + 2) * (Math.min(h, 30) + 8) - 8 + const k = Math.min(1, w / wanted) + const cell = Math.min(h, 30) * k + const gap = 8 * k + const arrow = Math.min(6, cell * 0.2) + const size = Math.min(13, cell * 0.55) const total = (pages + 2) * (cell + gap) - gap let x = (w - total) / 2 const cy = h / 2 const prims: Prim[] = [] - prims.push(...icon("chevron-right", x + cell / 2, cy, 12, { stroke: "muted" }).map((pr) => pr)) - // flip the first chevron to point left by drawing manually - prims.length = 0 - prims.push(poly([[x + cell * 0.6, cy - 6], [x + cell * 0.35, cy], [x + cell * 0.6, cy + 6]], false, { stroke: "muted" })) + prims.push( + poly([[x + cell * 0.6, cy - arrow], [x + cell * 0.35, cy], [x + cell * 0.6, cy + arrow]], false, { stroke: "muted" }) + ) x += cell + gap for (let i = 1; i <= pages; i++) { if (i === current) { prims.push(rect(x, cy - cell / 2, cell, cell, { fill: "shade", fillColor: "ink" })) } - prims.push(text(x + cell / 2, cy + 5, String(i), 13, { align: "center", color: i === current ? "ink" : "muted" })) + prims.push( + text(x + cell / 2, cy + Math.min(5, size * 0.4), String(i), size, { + align: "center", + color: i === current ? "ink" : "muted", + }) + ) x += cell + gap } - prims.push(poly([[x + cell * 0.4, cy - 6], [x + cell * 0.65, cy], [x + cell * 0.4, cy + 6]], false, { stroke: "muted" })) + prims.push( + poly([[x + cell * 0.4, cy - arrow], [x + cell * 0.65, cy], [x + cell * 0.4, cy + arrow]], false, { stroke: "muted" }) + ) return prims }, } @@ -436,16 +533,20 @@ export const chartDef: ComponentDef = { group: "Data", keywords: ["graph", "analytics", "data", "viz", "stats"], size: { w: 280, h: 180 }, - defaults: { style: "line", title: true }, + defaults: { style: "line", title: true, titleText: "Revenue", points: 6, trend: "up" }, controls: [ { key: "style", label: "Style", type: "select", options: ["line", "bars", "pie"], quick: true }, + { key: "points", label: "Points", type: "number", min: 3, max: 8, quick: true }, + { key: "trend", label: "Trend", type: "select", options: ["up", "down", "flat"], quick: true }, { key: "title", label: "Title", type: "toggle" }, + { key: "titleText", label: "Text", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] let top = 0 if (bool(p, "title")) { - prims.push(rect(0, 0, w * 0.4, 12, { fill: "shade", fillColor: "muted", stroke: "faint", strokeWidth: 0.8 })) + const t = str(p, "titleText") || "Revenue" + prims.push(text(0, 12, truncate(t, 14, Math.max(8, w - 8)), 14, { bold: true })) top = 24 } const ch = h - top @@ -464,18 +565,33 @@ export const chartDef: ComponentDef = { // axes prims.push(line(8, top + 4, 8, top + ch - 14)) prims.push(line(8, top + ch - 14, w - 4, top + ch - 14)) + const n = Math.max(3, Math.min(8, Math.round(num(p, "points", 6)))) + const trend = str(p, "trend", "up") + const plot = Math.max(10, ch - 30) + // one series per style, cut to the point count and then bent to the trend + const shape = (base: number[]): number[] => { + const vals = base.slice(0, n) + if (trend === "down") return [...vals].reverse() + if (trend === "flat") return vals.map((_, i) => (i % 2 ? 0.52 : 0.48)) + return vals + } if (style === "bars") { - const n = 5 - const bw = (w - 30) / n - 10 - const heights = [0.5, 0.8, 0.35, 0.65, 0.95] + // bars climb by count, so any number of them still reads as the trend + const wobble = [0, 0.07, -0.05, 0.04, -0.06, 0.05, -0.03, 0] + const ramp = Array.from({ length: n }, (_, i) => + Math.min(0.96, Math.max(0.18, 0.32 + 0.6 * (i / (n - 1)) + wobble[i])) + ) + const heights = shape(ramp) + const slot = (w - 24) / n + const bw = Math.max(3, slot * 0.68) for (let i = 0; i < n; i++) { - const bh = (ch - 30) * heights[i] - prims.push(rect(18 + i * (bw + 10), top + ch - 14 - bh, bw, bh, { fill: "shade", fillColor: "ink" })) + const bh = plot * heights[i] + prims.push(rect(12 + i * slot + (slot - bw) / 2, top + ch - 14 - bh, bw, bh, { fill: "shade", fillColor: "ink" })) } } else { - const pts: [number, number][] = [0.7, 0.45, 0.6, 0.3, 0.5, 0.15].map((v, i, arr) => [ - 12 + ((w - 24) / (arr.length - 1)) * i, - top + 8 + (ch - 30) * v, + const pts: [number, number][] = shape([0.7, 0.45, 0.6, 0.3, 0.5, 0.15, 0.35, 0.2]).map((v, i, arr) => [ + 12 + ((w - 24) / Math.max(1, arr.length - 1)) * i, + top + 8 + plot * v, ]) prims.push(poly(pts, false, { strokeWidth: 2, roughness: 1.8 })) pts.forEach(([px, py]) => prims.push(ellipse(px - 3, py - 3, 6, 6, { fill: "solid", fillColor: "ink" }))) diff --git a/lib/library/defs-extra.ts b/lib/library/defs-extra.ts index 1369d16..8418790 100644 --- a/lib/library/defs-extra.ts +++ b/lib/library/defs-extra.ts @@ -14,6 +14,40 @@ const num = (p: Props, k: string, fallback = 0): number => Number(p[k] ?? fallba // -- icon ------------------------------------------------------------------- +/** + * A short shelf of the glyphs people actually reach for. The full catalogue is + * 174 names, which is a dropdown nobody wants to scroll — "custom" hands the + * job back to the Custom name field, and is the default so that older icons + * (which only ever stored a name) keep drawing the glyph they were saved with. + */ +const ICON_PICKS = [ + "star", + "heart", + "user", + "users", + "house", + "gear", + "bell", + "envelope", + "magnifying-glass", + "check", + "x", + "plus", + "calendar-blank", + "clock", + "lock", + "trash", + "image", + "file", + "folder", + "chat-circle", + "shopping-cart", + "sparkle", + "lightning", + "globe", + "custom", +] + export const iconDef: ComponentDef = { kind: "icon", name: "Icon", @@ -21,9 +55,10 @@ export const iconDef: ComponentDef = { group: "Media", keywords: ["glyph", "symbol", "phosphor", "pictogram"], size: { w: 40, h: 40 }, - defaults: { name: "star", shape: "none" }, + defaults: { pick: "custom", name: "star", shape: "none" }, controls: [ - { key: "name", label: "Icon name", type: "text" }, + { key: "pick", label: "Icon", type: "select", options: ICON_PICKS, quick: true }, + { key: "name", label: "Custom name", type: "text" }, { key: "shape", label: "Container", type: "select", options: ["none", "circle", "square"], quick: true }, ], render(p, w, h) { @@ -32,7 +67,10 @@ export const iconDef: ComponentDef = { const d = Math.min(w, h) if (shape === "circle") prims.push(ellipse((w - d) / 2, (h - d) / 2, d, d)) else if (shape === "square") prims.push(rect((w - d) / 2, (h - d) / 2, d, d, { r: 6 })) - const name = str(p, "name", "star") + // "custom" — which is also what a legacy node or a broken-apart icon + // resolves to — reads the name field instead + const pick = str(p, "pick", "custom") + const name = pick && pick !== "custom" ? pick : str(p, "name", "star") const inner = shape === "none" ? d : d * 0.55 if (resolveIconName(name) || name === "logo") { prims.push(...icon(name, w / 2, h / 2, inner)) @@ -54,9 +92,10 @@ export const stickyDef: ComponentDef = { group: "Display", keywords: ["postit", "post-it", "note", "comment", "annotation"], size: { w: 160, h: 140 }, - defaults: { label: "why is this here?", fold: true }, + defaults: { label: "why is this here?", fold: true, size: "md" }, controls: [ { key: "label", label: "Note", type: "text" }, + { key: "size", label: "Text size", type: "select", options: ["sm", "md", "lg"], quick: true }, { key: "fold", label: "Folded corner", type: "toggle", quick: true }, ], render(p, w, h) { @@ -82,7 +121,8 @@ export const stickyDef: ComponentDef = { prims.push(rect(0, 0, w, h, { fill: "shade", fillColor: "muted", r: 2 })) } // wrap the note text by hand — sticky notes are always a bit cramped - const size = 14 + const sizeName = str(p, "size", "md") + const size = sizeName === "sm" ? 12 : sizeName === "lg" ? 20 : 14 const maxChars = Math.max(4, Math.floor((w - 24) / (size * 0.46))) const words = str(p, "label", "").split(/\s+/).filter(Boolean) const lines: string[] = [] @@ -97,7 +137,7 @@ export const stickyDef: ComponentDef = { if (cur) lines.push(cur) const maxLines = Math.max(1, Math.floor((h - 24) / (size * 1.35))) lines.slice(0, maxLines).forEach((l, i) => { - prims.push(text(12, 26 + i * size * 1.35, l, size)) + prims.push(text(12, 12 + size + i * size * 1.35, l, size)) }) return prims }, @@ -116,7 +156,7 @@ export const frameDef: ComponentDef = { controls: [ { key: "label", label: "Name", type: "text" }, { key: "preset", label: "Preset", type: "select", options: ["phone", "tablet", "desktop", "free"], quick: true }, - { key: "statusBar", label: "Status bar", type: "toggle", quick: true }, + { key: "statusBar", label: "Top bar", type: "toggle", quick: true }, ], render(p, w, h) { const preset = str(p, "preset", "phone") @@ -124,7 +164,8 @@ export const frameDef: ComponentDef = { prims.push(text(0, -8, truncate(str(p, "label", "Screen"), 13, w), 13, { color: "muted" })) const r = preset === "phone" ? 22 : preset === "tablet" ? 14 : 6 prims.push(rect(0, 0, w, h, { r })) - if (bool(p, "statusBar") && preset !== "free") { + // every preset gets a bar — "free" used to swallow the toggle whole + if (bool(p, "statusBar")) { if (preset === "desktop") { prims.push(line(0, 28, w, 28, { stroke: "faint" })) prims.push(ellipse(12, 10, 8, 8, { stroke: "muted" })) @@ -196,9 +237,21 @@ export const specDef: ComponentDef = { const prims: Prim[] = [rect(0, 0, w, h, { stroke: "faint", dashed: true, r: 6 })] prims.push(...icon(glyph, 20, 20, 15, { stroke: "muted" })) prims.push(text(36, 25, truncate(str(p, "title", "Note"), 14, w - 46), 14, { bold: true })) + // The title block is fixed; the copy under it is elastic. Under ~52 tall + // there is nowhere for a line to sit that isn't already the title, so the + // note becomes a labelled box. Above that, the gap is the leftover room — + // it works out to the usual 18 at the shipped height — and once the lines + // can't hold a legible distance apart the spare ones are dropped rather + // than stacked on each other or run off the floor. const n = Math.max(1, Math.min(8, num(p, "lines", 3))) - const avail = h - 42 - prims.push(...loremLines(14, 44, w - 28, n, Math.max(11, avail / n))) + const top = 44 + const room = h - 8 - top + if (room >= 0) { + const minGap = 6 + const count = Math.max(1, Math.min(n, Math.floor(room / minGap) + 1)) + const gap = count > 1 ? Math.min((h - 42) / n, room / (count - 1)) : (h - 42) / n + prims.push(...loremLines(14, top, w - 28, count, gap)) + } return prims }, } diff --git a/lib/library/defs-more.ts b/lib/library/defs-more.ts index 93b8bca..90aec92 100644 --- a/lib/library/defs-more.ts +++ b/lib/library/defs-more.ts @@ -29,6 +29,37 @@ const mid = (top: number, bh: number, size: number): number => top + bh / 2 + si /** safe indexed pick from a cycling pool */ const pick = (pool: string[], i: number): string => pool[((i % pool.length) + pool.length) % pool.length] +/** seconds as m:ss, the way a player writes them */ +const mmss = (s: number): string => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}` + +/** the glyph a trend select points at */ +const trendIcon = (trend: string): string => + trend === "up" ? "arrow-up" : trend === "down" ? "arrow-down" : "minus" + +/** + * Sign a delta to agree with the trend beside it. + * + * A card reading "↓ +12.5%" is the kind of thing nobody notices in a review and + * everybody notices in a screenshot. So an unsigned delta picks up the trend's + * sign — but a sign the user typed themselves is left exactly as typed, because + * arguing with someone's own input is worse than the contradiction. + */ +const signDelta = (delta: string, trend: string): string => { + const d = delta.trim() + if (!d || /^[+-]/.test(d)) return delta + return trend === "up" ? `+${d}` : trend === "down" ? `-${d}` : d +} + +/** + * A sparkline that agrees with the trend. One wobble, read three ways: as drawn + * it falls to the right, mirrored it climbs, flattened it just twitches along + * the middle. Reusing the one shape keeps all three looking hand-drawn by the + * same hand. + */ +const SPARK = [0.7, 0.5, 0.62, 0.34, 0.46, 0.2, 0.1] +const sparkVals = (trend: string): number[] => + trend === "up" ? SPARK : trend === "down" ? SPARK.map((v) => 1 - v) : SPARK.map((v) => 0.5 + (v - 0.5) * 0.22) + const FAINT_FILL: PrimOpts = { fill: "solid", fillColor: "faint", stroke: "faint", strokeWidth: 0.8 } const PAPER_FILL: PrimOpts = { fill: "solid", fillColor: "paper" } const INK_FILL: PrimOpts = { fill: "shade", fillColor: "ink" } @@ -80,14 +111,14 @@ export const buttonGroupDef: ComponentDef = { group: "Buttons", keywords: ["segmented", "control", "toggle", "switcher"], size: { w: 240, h: 36 }, - defaults: { labels: "Day, Week, Month", count: 3, active: 2 }, + defaults: { labels: "Day, Week, Month, Year, All, Custom", count: 3, active: 2 }, controls: [ { key: "labels", label: "Labels (comma-sep)", type: "text" }, { key: "count", label: "Segments", type: "number", min: 2, max: 6, quick: true }, { key: "active", label: "Active", type: "number", min: 1, max: 6, quick: true }, ], render(p, w, h) { - const labels = list(p, "labels", "Day, Week, Month") + const labels = list(p, "labels", "Day, Week, Month, Year, All, Custom") const n = clamp(Math.round(num(p, "count", labels.length)), 2, 6) const active = clamp(Math.round(num(p, "active", 1)), 1, n) - 1 const segW = w / n @@ -279,10 +310,18 @@ export const formFieldDef: ComponentDef = { group: "Forms", keywords: ["label", "helper", "error", "validation", "input"], size: { w: 260, h: 86 }, - defaults: { label: "Email", placeholder: "you@company.com", helper: true, error: false, required: true }, + defaults: { + label: "Email", + placeholder: "you@company.com", + message: "That doesn't look like an email", + helper: true, + error: false, + required: true, + }, controls: [ { key: "label", label: "Label", type: "text" }, { key: "placeholder", label: "Placeholder", type: "text" }, + { key: "message", label: "Error text", type: "text" }, { key: "helper", label: "Helper text", type: "toggle", quick: true }, { key: "error", label: "Error", type: "toggle", quick: true }, { key: "required", label: "Required", type: "toggle" }, @@ -313,7 +352,8 @@ export const formFieldDef: ComponentDef = { if (helper) { const hs = clamp(helperH * 0.6, 9, 12) const hy = labelH + fieldH + helperH * 0.72 - if (err) prims.push(text(1, hy, truncate("That doesn't look like an email", hs, w - 4), hs)) + if (err) + prims.push(text(1, hy, truncate(str(p, "message", "That doesn't look like an email"), hs, w - 4), hs)) else prims.push(line(1, hy - hs * 0.35, Math.min(w, w * 0.72), hy - hs * 0.35, { stroke: "muted", strokeWidth: 1.2 })) } return prims @@ -412,15 +452,16 @@ export const fileUploadDef: ComponentDef = { group: "Forms", keywords: ["dropzone", "drag", "drop", "attach", "files"], size: { w: 280, h: 150 }, - defaults: { variant: "dropzone", hint: "PNG, JPG or PDF up to 10 MB", files: 2 }, + defaults: { hint: "PNG, JPG or PDF up to 10 MB", files: 0 }, controls: [ - { key: "variant", label: "Variant", type: "select", options: ["dropzone", "with files"], quick: true }, - { key: "files", label: "Files", type: "number", min: 1, max: 4, quick: true }, + { key: "files", label: "Files", type: "number", min: 0, max: 4, quick: true }, { key: "hint", label: "Hint", type: "text" }, ], render(p, w, h) { - const withFiles = str(p, "variant", "dropzone") === "with files" - const files = withFiles ? clamp(Math.round(num(p, "files", 2)), 1, 4) : 0 + // Nodes dropped before Files went live carry `variant: "dropzone"` and an inert + // files: 2 — honour the saved bare dropzone until someone moves the control. + const legacyZone = str(p, "variant", "") === "dropzone" && num(p, "files", 0) === 2 + const files = legacyZone ? 0 : clamp(Math.round(num(p, "files", 0)), 0, 4) const zoneH = files ? Math.max(30, h - Math.min(files * 32 + 8, h * 0.56)) : h const listH = h - zoneH const rowH = files ? Math.max(12, (listH - 8) / files) : 0 @@ -463,11 +504,12 @@ export const datePickerDef: ComponentDef = { group: "Forms", keywords: ["calendar", "day", "when", "schedule", "input"], size: { w: 220, h: 62 }, - defaults: { label: "Starts on", showLabel: true, value: "Mar 4, 2026", range: false }, + defaults: { label: "Starts on", showLabel: true, value: "Mar 4, 2026", endValue: "Mar 18", range: false }, controls: [ { key: "label", label: "Label", type: "text" }, { key: "showLabel", label: "Show label", type: "toggle", quick: true }, { key: "value", label: "Value", type: "text" }, + { key: "endValue", label: "End date", type: "text" }, { key: "range", label: "Range", type: "toggle", quick: true }, ], render(p, w, h) { @@ -482,7 +524,8 @@ export const datePickerDef: ComponentDef = { prims.push(rect(0, top, w, fieldH)) const isz = clamp(fieldH * 0.42, 11, 17) prims.push(...icon("calendar-blank", 14 + isz / 2, top + fieldH / 2, isz, { stroke: "muted" })) - const value = bool(p, "range") ? `${str(p, "value", "Mar 4")} – Mar 18` : str(p, "value", "Mar 4, 2026") + const start = str(p, "value", "Mar 4, 2026") + const value = bool(p, "range") ? `${start} – ${str(p, "endValue", "Mar 18")}` : start const fs = clamp(fieldH * 0.38, 10, 14) const tx = 22 + isz prims.push(text(tx, mid(top, fieldH, fs), truncate(value, fs, Math.max(10, w - tx - 24)), fs)) @@ -640,6 +683,14 @@ export const colorSwatchRowDef: ComponentDef = { const step = ramp[Math.round((i / (n - 1)) * (ramp.length - 1))] ?? ramp[0] const o: PrimOpts = { ...step, strokeWidth: i === sel ? 2.4 : 1 } prims.push(round ? ellipse(x, y, d, d, o) : rect(x, y, d, d, o)) + if (i !== sel) continue + // A heavier border is all the mark the pale swatches need, but on the + // dark end of the ramp it disappears into the fill. So the selected one + // also gets a paper disc punched into it — inside the swatch, so nothing + // spills past the row — with a tick on top once there's room to draw one. + const md = Math.min(d * 0.56, 20) + prims.push(ellipse(x + (d - md) / 2, y + (d - md) / 2, md, md, { fill: "solid", fillColor: "paper" })) + if (md > 9) prims.push(...icon("check", x + d / 2, y + d / 2, md * 0.68)) } return prims }, @@ -911,15 +962,15 @@ export const accordionDef: ComponentDef = { group: "Display", keywords: ["faq", "collapse", "expand", "disclosure", "details"], size: { w: 300, h: 220 }, - defaults: { labels: "What is squig?, How much is it?, Can I export?, Refund policy", count: 4, expanded: 1 }, + defaults: { labels: "What is squig?, How much is it?, Can I export?, Refund policy", expanded: 1 }, controls: [ { key: "labels", label: "Rows (comma-sep)", type: "text" }, - { key: "count", label: "Rows", type: "number", min: 2, max: 6, quick: true }, { key: "expanded", label: "Expanded", type: "number", min: 0, max: 6, quick: true }, ], render(p, w, h) { const labels = list(p, "labels", "What is squig?, How much is it?, Can I export?, Refund policy") - const n = clamp(Math.round(num(p, "count", 4)), 2, 6) + // Rows follow the labels now; nodes saved with the old Rows control keep their count. + const n = clamp(Math.round(num(p, "count", labels.length)), 2, 6) const expanded = clamp(Math.round(num(p, "expanded", 1)), 0, n) const wantBody = expanded >= 1 let headerH = wantBody ? Math.min(46, h / (n + 1)) : h / n @@ -1108,6 +1159,8 @@ const TREE_ROWS: { d: number; folder: boolean; open: boolean; name: string }[] = { d: 0, folder: false, open: false, name: "package.json" }, ] +const TREE_NAMES = TREE_ROWS.map((r) => r.name).join(", ") + export const treeViewDef: ComponentDef = { kind: "tree-view", name: "Tree view", @@ -1115,12 +1168,14 @@ export const treeViewDef: ComponentDef = { group: "Display", keywords: ["file", "folder", "explorer", "nested", "sidebar", "directory"], size: { w: 220, h: 190 }, - defaults: { count: 8, selected: 3 }, + defaults: { names: TREE_NAMES, count: 8, selected: 3 }, controls: [ + { key: "names", label: "Rows (comma-sep)", type: "text" }, { key: "count", label: "Rows", type: "number", min: 3, max: 10, quick: true }, { key: "selected", label: "Selected", type: "number", min: 0, max: 10, quick: true }, ], render(p, w, h) { + const names = list(p, "names", TREE_NAMES) const n = clamp(Math.round(num(p, "count", 8)), 3, 10) const selected = clamp(Math.round(num(p, "selected", 3)), 0, n) - 1 const rowH = clamp(h / n, 16, 30) @@ -1129,17 +1184,19 @@ export const treeViewDef: ComponentDef = { const prims: Prim[] = [] for (let i = 0; i < n; i++) { const r = TREE_ROWS[i % TREE_ROWS.length] + const name = names[i] ?? r.name + const folder = !name.includes(".") const y = i * rowH if (y + rowH > h + 1) break if (i === selected) prims.push(rect(0, y + 1, w, rowH - 2, FAINT_FILL)) let ix = 2 + r.d * indent - if (r.folder) prims.push(...icon(r.open ? "caret-down" : "caret-right", ix + 5, y + rowH / 2, 9, { stroke: "muted" })) + if (folder) prims.push(...icon(r.open ? "caret-down" : "caret-right", ix + 5, y + rowH / 2, 9, { stroke: "muted" })) ix += 13 - prims.push(...icon(r.folder ? "folder" : "file-text", ix + 7, y + rowH / 2, clamp(rowH * 0.55, 10, 14), { - stroke: r.folder ? "ink" : "muted", + prims.push(...icon(folder ? "folder" : "file-text", ix + 7, y + rowH / 2, clamp(rowH * 0.55, 10, 14), { + stroke: folder ? "ink" : "muted", })) const tx = ix + 17 - prims.push(text(tx, mid(y, rowH, fs), truncate(r.name, fs, Math.max(8, w - tx - 6)), fs, { bold: r.folder })) + prims.push(text(tx, mid(y, rowH, fs), truncate(name, fs, Math.max(8, w - tx - 6)), fs, { bold: folder })) } return prims }, @@ -1161,6 +1218,7 @@ export const listItemDef: ComponentDef = { leading: "avatar", icon: "folder", trailing: "chevron", + trailingText: "", divider: true, }, controls: [ @@ -1169,12 +1227,14 @@ export const listItemDef: ComponentDef = { { key: "showSubtitle", label: "Subtitle", type: "toggle", quick: true }, { key: "leading", label: "Leading", type: "select", options: ["avatar", "icon", "none"], quick: true }, { key: "trailing", label: "Trailing", type: "select", options: ["chevron", "switch", "badge", "button", "meta", "none"], quick: true }, + { key: "trailingText", label: "Trailing text", type: "text" }, { key: "icon", label: "Icon", type: "select", options: ["folder", "file-text", "bell", "lock", "star", "clock"] }, { key: "divider", label: "Divider", type: "toggle" }, ], render(p, w, h) { const leading = str(p, "leading", "avatar") const trailing = str(p, "trailing", "chevron") + const tl = str(p, "trailingText", "") const showSub = bool(p, "showSubtitle") && h > 44 const padX = 12 const prims: Prim[] = [] @@ -1204,20 +1264,23 @@ export const listItemDef: ComponentDef = { prims.push(ellipse(right - 3 - (th - 6), ty + 3, th - 6, th - 6)) right -= tw + 10 } else if (trailing === "badge") { - const bw = 44 + const label = tl || "New" + const bw = clamp(advance(label, 11) + 18, 44, Math.max(44, right - x - 40)) const bh = 20 prims.push(pill(right - bw, (h - bh) / 2, bw, bh)) - prims.push(text(right - bw / 2, h / 2 + 4, "New", 11, { align: "center" })) + prims.push(text(right - bw / 2, h / 2 + 4, truncate(label, 11, bw - 10), 11, { align: "center" })) right -= bw + 10 } else if (trailing === "button") { - const bw = 64 + const label = tl || "Open" + const bw = clamp(advance(label, 12) + 22, 64, Math.max(64, right - x - 40)) const bh = Math.min(30, h - 12) prims.push(rect(right - bw, (h - bh) / 2, bw, bh)) - prims.push(text(right - bw / 2, h / 2 + 4, "Open", 12, { align: "center" })) + prims.push(text(right - bw / 2, h / 2 + 4, truncate(label, 12, bw - 10), 12, { align: "center" })) right -= bw + 10 } else if (trailing === "meta") { - prims.push(text(right, h / 2 + 4, "2h", 11, { align: "right", color: "muted" })) - right -= 30 + const label = tl || "2h" + prims.push(text(right, h / 2 + 4, truncate(label, 11, Math.max(20, right - x - 30)), 11, { align: "right", color: "muted" })) + right -= Math.max(30, advance(label, 11) + 10) } const tw = Math.max(24, right - x - 6) const ts = clamp(h * 0.24, 12, 16) @@ -1368,15 +1431,18 @@ export const skeletonDef: ComponentDef = { const bar = (x: number, y: number, bw: number, bh: number): Prim => rect(x, y, Math.max(4, bw), Math.max(4, bh), FAINT_FILL) const prims: Prim[] = [] if (variant === "card") { - const imgH = clamp(h * 0.56, 30, h - 46) + const imgH = clamp(h * 0.56, 30, Math.max(30, h - 14 - n * 9)) prims.push(bar(0, 0, w, imgH)) - const rest = h - imgH - 14 - const bh = clamp(rest / 3 - 8, 7, 14) - const widths = [0.92, 0.76, 0.5] - for (let i = 0; i < 3; i++) { - const y = imgH + 14 + i * (bh + 8) + const rest = Math.max(9, h - imgH - 14) + // never more bars than the leftover strip can space out, or they'd stack on each other + const rows = clamp(Math.floor(rest / 5), 1, n) + const step = rest / rows + const bh = clamp(step - Math.min(8, step * 0.35), 4, 14) + const widths = [0.92, 0.76, 0.5, 0.84, 0.66, 0.9, 0.58, 0.8] + for (let i = 0; i < rows; i++) { + const y = imgH + 14 + i * step if (y + bh > h + 1) break - prims.push(bar(0, y, w * widths[i], bh)) + prims.push(bar(0, y, w * widths[i % widths.length], bh)) } return prims } @@ -1472,13 +1538,31 @@ export const cardStatDef: ComponentDef = { group: "Display", keywords: ["kpi", "metric", "number", "dashboard", "card"], size: { w: 230, h: 124 }, - defaults: { label: "Monthly revenue", value: "$12,480", delta: "+12.5%", trend: "up", showIcon: true, spark: true }, + defaults: { + label: "Monthly revenue", + value: "$12,480", + // unsigned on purpose — the Trend control signs it, so the arrow and the + // number can't disagree. Type your own "+" or "-" and that wins. + delta: "12.5%", + period: "vs last month", + trend: "up", + showIcon: true, + icon: "currency-dollar", + spark: true, + }, controls: [ { key: "label", label: "Label", type: "text" }, { key: "value", label: "Value", type: "text" }, { key: "delta", label: "Delta", type: "text" }, + { key: "period", label: "Period", type: "text" }, { key: "trend", label: "Trend", type: "select", options: ["up", "down", "flat"], quick: true }, { key: "showIcon", label: "Icon", type: "toggle", quick: true }, + { + key: "icon", + label: "Glyph", + type: "select", + options: ["currency-dollar", "users", "chart-line", "shopping-cart", "clock", "eye"], + }, { key: "spark", label: "Sparkline", type: "toggle", quick: true }, ], render(p, w, h) { @@ -1489,7 +1573,7 @@ export const cardStatDef: ComponentDef = { if (showIcon) { const d = 26 prims.push(rect(w - pad - d, pad, d, d, { stroke: "faint" })) - prims.push(...icon("currency-dollar", w - pad - d / 2, pad + d / 2, 14, { stroke: "muted" })) + prims.push(...icon(str(p, "icon", "currency-dollar"), w - pad - d / 2, pad + d / 2, 14, { stroke: "muted" })) } const vs = clamp(h * 0.26, 18, 34) const vy = pad + 18 + vs @@ -1498,17 +1582,18 @@ export const cardStatDef: ComponentDef = { const dy = Math.min(h - pad - 2, vy + 22) if (dy > vy + 12) { const trend = str(p, "trend", "up") - const delta = str(p, "delta", "+12.5%") - prims.push(...icon(trend === "up" ? "arrow-up" : trend === "down" ? "arrow-down" : "minus", pad + 6, dy - 4, 12)) + const delta = signDelta(str(p, "delta", "12.5%"), trend) + prims.push(...icon(trendIcon(trend), pad + 6, dy - 4, 12)) prims.push(text(pad + 18, dy, truncate(delta, 12, w * 0.4), 12, { bold: true })) const px = pad + 18 + advance(delta, 12) + 10 const periodW = w - pad - px - (spark ? w * 0.3 : 0) - if (periodW > 26) prims.push(text(px, dy, truncate("vs last month", 12, periodW), 12, { color: "muted" })) + if (periodW > 26) + prims.push(text(px, dy, truncate(str(p, "period", "vs last month"), 12, periodW), 12, { color: "muted" })) if (spark) { const sw = w * 0.26 const sx = w - pad - sw const sh = 20 - const vals = [0.7, 0.5, 0.62, 0.34, 0.46, 0.2, 0.1] + const vals = sparkVals(trend) const pts: [number, number][] = vals.map((v, i) => [sx + (sw / (vals.length - 1)) * i, dy - 12 + sh * v]) prims.push(poly(pts, false, { strokeWidth: 1.6, stroke: "muted", roughness: 1.4 })) } @@ -1588,10 +1673,11 @@ export const cardMediaDef: ComponentDef = { group: "Display", keywords: ["image", "thumbnail", "cover", "card", "preview"], size: { w: 250, h: 240 }, - defaults: { title: "A weekend in the fog", badge: true, meta: true }, + defaults: { title: "A weekend in the fog", badge: true, badgeLabel: "Travel", meta: true }, controls: [ { key: "title", label: "Title", type: "text" }, { key: "badge", label: "Badge", type: "toggle", quick: true }, + { key: "badgeLabel", label: "Badge text", type: "text" }, { key: "meta", label: "Meta row", type: "toggle", quick: true }, ], render(p, w, h) { @@ -1601,10 +1687,11 @@ export const cardMediaDef: ComponentDef = { prims.push(rect(0, 0, w, imgH, { fill: "shade", fillColor: "faint" })) prims.push(...icon("image", w / 2, imgH / 2, clamp(Math.min(w, imgH) * 0.24, 18, 46), { stroke: "muted" })) if (bool(p, "badge") && imgH > 40) { - const bw = 56 + const label = str(p, "badgeLabel", "Travel") const bh = 20 + const bw = clamp(advance(label, 10) + 16, 34, Math.min(Math.max(34, w - pad * 2), 120)) prims.push(pill(pad, pad, bw, bh, PAPER_FILL), pill(pad, pad, bw, bh)) - prims.push(text(pad + bw / 2, pad + bh / 2 + 4, "Travel", 10, { align: "center" })) + prims.push(text(pad + bw / 2, pad + bh / 2 + 4, truncate(label, 10, bw - 10), 10, { align: "center" })) } const meta = bool(p, "meta") const metaY = h - pad - 2 @@ -1641,6 +1728,7 @@ export const cardPricingDef: ComponentDef = { controls: [ { key: "tier", label: "Tier", type: "text" }, { key: "price", label: "Price", type: "text" }, + { key: "period", label: "Period", type: "text" }, { key: "features", label: "Features", type: "number", min: 1, max: 6, quick: true }, { key: "popular", label: "Popular", type: "toggle", quick: true }, { key: "cta", label: "Button", type: "text" }, @@ -1739,13 +1827,14 @@ export const cardProductDef: ComponentDef = { group: "Display", keywords: ["shop", "commerce", "buy", "price", "cart", "card"], size: { w: 220, h: 270 }, - defaults: { title: "Squig Tote", price: "$28", badge: true, rating: true, cta: "icon" }, + defaults: { title: "Squig Tote", price: "$28", badge: true, rating: true, cta: "icon", ctaLabel: "Add to cart" }, controls: [ { key: "title", label: "Title", type: "text" }, { key: "price", label: "Price", type: "text" }, { key: "badge", label: "Badge", type: "toggle", quick: true }, { key: "rating", label: "Rating", type: "toggle", quick: true }, { key: "cta", label: "Action", type: "select", options: ["icon", "button"], quick: true }, + { key: "ctaLabel", label: "Button", type: "text" }, ], render(p, w, h) { const pad = 14 @@ -1780,7 +1869,11 @@ export const cardProductDef: ComponentDef = { } else { const bw = Math.min(w - pad * 2 - priceW - 8, 116) prims.push(rect(w - pad - bw, by, bw, btnD, INK_FILL)) - prims.push(text(w - pad - bw / 2, by + btnD / 2 + 4, truncate("Add to cart", 12, bw - 10), 12, { align: "center" })) + prims.push( + text(w - pad - bw / 2, by + btnD / 2 + 4, truncate(str(p, "ctaLabel", "Add to cart"), 12, bw - 10), 12, { + align: "center", + }) + ) } return prims }, @@ -1795,37 +1888,49 @@ export const cardBlogDef: ComponentDef = { group: "Display", keywords: ["article", "post", "author", "tag", "card", "read"], size: { w: 260, h: 280 }, - defaults: { title: "Why wireframes should look unfinished", tag: "Design", author: "Pablo S." }, + defaults: { title: "Why wireframes should look unfinished", tag: "Design", author: "Pablo S.", image: true, footer: true }, controls: [ { key: "title", label: "Title", type: "text" }, { key: "tag", label: "Tag", type: "text" }, { key: "author", label: "Author", type: "text" }, + { key: "image", label: "Image", type: "toggle", quick: true }, + { key: "footer", label: "Byline", type: "toggle", quick: true }, ], render(p, w, h) { const pad = clamp(w * 0.06, 14, 18) const prims: Prim[] = [rect(0, 0, w, h)] - const imgH = clamp(h * 0.38, 40, Math.max(40, h - 130)) - prims.push(rect(0, 0, w, imgH, { fill: "shade", fillColor: "faint" })) - prims.push(...icon("image", w / 2, imgH / 2, clamp(Math.min(w, imgH) * 0.26, 18, 44), { stroke: "muted" })) - let y = imgH + pad + 8 - const tw = Math.min(w - pad * 2, advance(str(p, "tag", "Design"), 10) + 18) - const tag = truncate(str(p, "tag", "Design"), 10, Math.max(6, tw - 14)) - prims.push(pill(pad, y - 13, tw, 20, { stroke: "muted" })) - prims.push(text(pad + tw / 2, y + 1, tag, 10, { align: "center", color: "muted" })) - y += 24 + const showImage = bool(p, "image") + const showFooter = bool(p, "footer") + let y = pad + 8 + if (showImage) { + const imgH = clamp(h * 0.38, 40, Math.max(40, h - 130)) + prims.push(rect(0, 0, w, imgH, { fill: "shade", fillColor: "faint" })) + prims.push(...icon("image", w / 2, imgH / 2, clamp(Math.min(w, imgH) * 0.26, 18, 44), { stroke: "muted" })) + y = imgH + pad + 8 + } + const tagText = str(p, "tag", "Design").trim() + if (tagText) { + const tw = Math.min(w - pad * 2, advance(tagText, 10) + 18) + prims.push(pill(pad, y - 13, tw, 20, { stroke: "muted" })) + prims.push(text(pad + tw / 2, y + 1, truncate(tagText, 10, Math.max(6, tw - 14)), 10, { align: "center", color: "muted" })) + y += 24 + } const ts = clamp(w * 0.07, 13, 18) prims.push(text(pad, y, truncate(str(p, "title", ""), ts, w - pad * 2), ts, { bold: true })) y += 8 const d = 26 const footY = h - pad - d - const lines = clamp(Math.floor((footY - y - 12) / 14), 0, 3) + const bodyBottom = showFooter ? footY : h - pad + const lines = clamp(Math.floor((bodyBottom - y - 12) / 14), 0, 5) if (lines > 0) prims.push(...loremLines(pad, y + 12, w - pad * 2, lines, 14)) - prims.push(ellipse(pad, footY, d, d)) - prims.push(...icon("user", pad + d / 2, footY + d / 2, d * 0.5, { stroke: "muted" })) - const ax = pad + d + 10 - const aw = Math.max(20, w - ax - pad) - prims.push(text(ax, footY + 11, truncate(str(p, "author", ""), 12, aw), 12, { bold: true })) - prims.push(text(ax, footY + 25, truncate("Mar 4 · 6 min read", 10, aw), 10, { color: "muted" })) + if (showFooter) { + prims.push(ellipse(pad, footY, d, d)) + prims.push(...icon("user", pad + d / 2, footY + d / 2, d * 0.5, { stroke: "muted" })) + const ax = pad + d + 10 + const aw = Math.max(20, w - ax - pad) + prims.push(text(ax, footY + 11, truncate(str(p, "author", ""), 12, aw), 12, { bold: true })) + prims.push(text(ax, footY + 25, truncate("Mar 4 · 6 min read", 10, aw), 10, { color: "muted" })) + } return prims }, } @@ -1929,6 +2034,25 @@ export const menubarDef: ComponentDef = { // -- context menu ----------------------------------------------------------- +/** The shortcuts everyone already knows, keyed by the verb the row starts with. */ +const SHORTCUT_WORDS = new Map([ + ["cut", "⌘X"], + ["copy", "⌘C"], + ["paste", "⌘V"], + ["duplicate", "⌘D"], + ["delete", "⌫"], + ["remove", "⌫"], + ["share", ""], +]) + +/** ⌘ + first letter for anything else that starts with a letter, nothing otherwise. */ +const shortcutFor = (label: string): string => { + const first = label.trim().split(/\s+/)[0] ?? "" + const known = SHORTCUT_WORDS.get(first.toLowerCase()) + if (known !== undefined) return known + return /^[a-z]/i.test(first) ? `⌘${first.charAt(0).toUpperCase()}` : "" +} + export const contextMenuDef: ComponentDef = { kind: "context-menu", name: "Context menu", @@ -1947,7 +2071,6 @@ export const contextMenuDef: ComponentDef = { const n = items.length const shortcuts = bool(p, "shortcuts") const hover = clamp(Math.round(num(p, "hover", 2)), 0, n) - 1 - const SC = ["⌘X", "⌘C", "⌘V", "⌘D", "", "⌫"] const pad = 6 const submenu = n >= 3 ? n - 2 : -1 let seps = [1, n - 2].filter((s, i, arr) => s > 0 && s < n - 1 && arr.indexOf(s) === i) @@ -1970,8 +2093,9 @@ export const contextMenuDef: ComponentDef = { ) if (i === submenu) { prims.push(...icon("caret-right", w - 14, y + rowH / 2, 10, { stroke: "muted" })) - } else if (shortcuts && SC[i]) { - prims.push(text(w - 12, mid(y, rowH, 11), SC[i], 11, { align: "right", color: "muted" })) + } else if (shortcuts) { + const sc = shortcutFor(pick(items, i)) + if (sc) prims.push(text(w - 12, mid(y, rowH, 11), sc, 11, { align: "right", color: "muted" })) } y += rowH if (seps.indexOf(i) !== -1) { @@ -1992,9 +2116,15 @@ export const commandMenuDef: ComponentDef = { group: "Navigation", keywords: ["palette", "cmdk", "spotlight", "search", "quick actions"], size: { w: 360, h: 280 }, - defaults: { placeholder: "Type a command or search…", count: 5, footer: true }, + defaults: { + placeholder: "Type a command or search…", + items: "New project, Open folder, Invite teammate, Settings, New doc, Deploy site, Ask the agent, Recent files", + count: 5, + footer: true, + }, controls: [ { key: "placeholder", label: "Placeholder", type: "text" }, + { key: "items", label: "Results (comma-sep)", type: "text" }, { key: "count", label: "Results", type: "number", min: 2, max: 8, quick: true }, { key: "footer", label: "Footer", type: "toggle", quick: true }, ], @@ -2017,7 +2147,7 @@ export const commandMenuDef: ComponentDef = { const headerH = 18 const rowH = clamp((bodyBottom - bodyTop - headerH * 2) / n, 20, 34) const ICONS = ["plus", "folder", "user-plus", "gear", "file-text", "rocket-launch", "magic-wand", "clock"] - const LABELS = ["New project", "Open folder", "Invite teammate", "Settings", "New doc", "Deploy site", "Ask the agent", "Recent files"] + const items = list(p, "items", "New project, Open folder, Invite teammate, Settings, New doc, Deploy site, Ask the agent, Recent files") const KB = ["⌘N", "⌘O", "⌘I", "⌘,", "⌘D", "", "", ""] const secondAt = Math.ceil(n / 2) let y = bodyTop @@ -2029,7 +2159,7 @@ export const commandMenuDef: ComponentDef = { if (y + rowH > bodyBottom) break if (i === 0) prims.push(rect(6, y + 1, w - 12, rowH - 2, FAINT_FILL)) prims.push(...icon(pick(ICONS, i), 22, y + rowH / 2, 14, { stroke: "muted" })) - prims.push(text(40, mid(y, rowH, 13), truncate(pick(LABELS, i), 13, Math.max(10, w - 100)), 13)) + prims.push(text(40, mid(y, rowH, 13), truncate(pick(items, i), 13, Math.max(10, w - 100)), 13)) const kb = KB[i % KB.length] if (kb) prims.push(text(w - 14, mid(y, rowH, 11), kb, 11, { align: "right", color: "muted" })) y += rowH @@ -2207,7 +2337,8 @@ export const statDef: ComponentDef = { group: "Data", keywords: ["metric", "kpi", "number", "delta", "figure"], size: { w: 180, h: 86 }, - defaults: { label: "Active users", value: "8,240", delta: "+4.2%", trend: "up", period: "vs last week" }, + // delta unsigned — Trend signs it, so the arrow and the number always agree + defaults: { label: "Active users", value: "8,240", delta: "4.2%", trend: "up", period: "vs last week" }, controls: [ { key: "label", label: "Label", type: "text" }, { key: "value", label: "Value", type: "text" }, @@ -2223,8 +2354,8 @@ export const statDef: ComponentDef = { const dy = vy + 20 if (dy <= h) { const trend = str(p, "trend", "up") - const delta = str(p, "delta", "") - prims.push(...icon(trend === "up" ? "arrow-up" : trend === "down" ? "arrow-down" : "minus", 6, dy - 4, 12)) + const delta = signDelta(str(p, "delta", ""), trend) + prims.push(...icon(trendIcon(trend), 6, dy - 4, 12)) const dw = advance(delta, 12) prims.push(text(18, dy, truncate(delta, 12, w * 0.45), 12, { bold: true })) const px = 18 + dw + 10 @@ -2243,8 +2374,9 @@ export const dataTableDef: ComponentDef = { group: "Data", keywords: ["grid", "rows", "sort", "filter", "select", "pagination", "crud"], size: { w: 540, h: 320 }, - defaults: { rows: 5, toolbar: true, footer: true, selected: 2 }, + defaults: { cols: "Name, Status, Amount", rows: 5, toolbar: true, footer: true, selected: 2 }, controls: [ + { key: "cols", label: "Columns (comma-sep)", type: "text" }, { key: "rows", label: "Rows", type: "number", min: 1, max: 10, quick: true }, { key: "toolbar", label: "Toolbar", type: "toggle", quick: true }, { key: "footer", label: "Footer", type: "toggle", quick: true }, @@ -2288,10 +2420,14 @@ export const dataTableDef: ComponentDef = { prims.push(line(0, tTop + headerH, w, tTop + headerH, { stroke: "faint" })) prims.push(rect(cbW / 2 - 7, tTop + headerH / 2 - 7, 14, 14, { stroke: "muted" })) prims.push(...icon("minus", cbW / 2, tTop + headerH / 2, 10)) - prims.push(text(colX[0], mid(tTop, headerH, 12), "Name", 12, { bold: true })) - prims.push(...icon("caret-up-down", colX[0] + advance("Name", 12) + 13, tTop + headerH / 2, 10, { stroke: "muted" })) - prims.push(text(colX[1], mid(tTop, headerH, 12), "Status", 12, { bold: true })) - prims.push(text(cbW + contentW - 6, mid(tTop, headerH, 12), "Amount", 12, { align: "right", bold: true })) + const cols = list(p, "cols", "Name, Status, Amount") + const hName = truncate(cols[0] ?? "Name", 12, Math.max(16, contentW * 0.46 - 30)) + const hStatus = truncate(cols[1] ?? "Status", 12, Math.max(16, contentW * 0.28 - 8)) + const hAmount = truncate(cols[2] ?? "Amount", 12, Math.max(16, cbW + contentW - 6 - colX[2])) + prims.push(text(colX[0], mid(tTop, headerH, 12), hName, 12, { bold: true })) + prims.push(...icon("caret-up-down", colX[0] + advance(hName, 12) + 13, tTop + headerH / 2, 10, { stroke: "muted" })) + prims.push(text(colX[1], mid(tTop, headerH, 12), hStatus, 12, { bold: true })) + prims.push(text(cbW + contentW - 6, mid(tTop, headerH, 12), hAmount, 12, { align: "right", bold: true })) const bodyTop = tTop + headerH const bodyH = Math.max(16, h - footerH - bodyTop) const rowH = bodyH / rows @@ -2340,6 +2476,9 @@ export const dataTableDef: ComponentDef = { // -- key/value list --------------------------------------------------------- +const KV_KEYS = "Plan, Seats, Renews, Owner, Region, Status, Created, Support" +const KV_VALS = "Pro, 12, Mar 4 2026, Pablo S., us-east-1, Active, Jan 2024, Priority" + export const kvListDef: ComponentDef = { kind: "kv-list", name: "Key/value list", @@ -2347,8 +2486,10 @@ export const kvListDef: ComponentDef = { group: "Data", keywords: ["details", "definition", "properties", "summary", "meta"], size: { w: 260, h: 170 }, - defaults: { count: 5, dividers: true, layout: "row" }, + defaults: { keys: KV_KEYS, values: KV_VALS, count: 5, dividers: true, layout: "row" }, controls: [ + { key: "keys", label: "Keys (comma-sep)", type: "text" }, + { key: "values", label: "Values (comma-sep)", type: "text" }, { key: "count", label: "Rows", type: "number", min: 2, max: 8, quick: true }, { key: "layout", label: "Layout", type: "select", options: ["row", "stacked"], quick: true }, { key: "dividers", label: "Dividers", type: "toggle", quick: true }, @@ -2357,8 +2498,8 @@ export const kvListDef: ComponentDef = { const n = clamp(Math.round(num(p, "count", 5)), 2, 8) const dividers = bool(p, "dividers") const stacked = str(p, "layout", "row") === "stacked" - const KEYS = ["Plan", "Seats", "Renews", "Owner", "Region", "Status", "Created", "Support"] - const VALS = ["Pro", "12", "Mar 4, 2026", "Pablo S.", "us-east-1", "Active", "Jan 2024", "Priority"] + const KEYS = list(p, "keys", KV_KEYS) + const VALS = list(p, "values", KV_VALS) const rowH = h / n const prims: Prim[] = [] for (let i = 0; i < n; i++) { @@ -2383,6 +2524,9 @@ export const kvListDef: ComponentDef = { // -- video player ----------------------------------------------------------- +/** the clip this player has always been playing: 4:03 */ +const TOTAL_S = 243 + export const videoPlayerDef: ComponentDef = { kind: "video-player", name: "Video player", @@ -2390,11 +2534,12 @@ export const videoPlayerDef: ComponentDef = { group: "Media", keywords: ["play", "movie", "scrubber", "youtube", "embed"], size: { w: 320, h: 200 }, - defaults: { controls: true, progress: 35, title: false }, + defaults: { controls: true, progress: 35, title: false, titleText: "How we draw squiggles" }, controls: [ { key: "controls", label: "Controls", type: "toggle", quick: true }, { key: "progress", label: "Progress", type: "number", min: 0, max: 100, quick: true }, { key: "title", label: "Title overlay", type: "toggle", quick: true }, + { key: "titleText", label: "Title", type: "text" }, ], render(p, w, h) { const controls = bool(p, "controls") && h > 90 @@ -2406,20 +2551,25 @@ export const videoPlayerDef: ComponentDef = { prims.push(ellipse(w / 2 - d / 2, stageH / 2 - d / 2, d, d)) prims.push(...icon("play", w / 2 + d * 0.04, stageH / 2, d * 0.4)) if (bool(p, "title")) { - prims.push(text(14, 24, truncate("How we draw squiggles", 13, w - 28), 13, { bold: true })) + prims.push(text(14, 24, truncate(str(p, "titleText", "How we draw squiggles"), 13, w - 28), 13, { bold: true })) } if (controls) { prims.push(line(0, stageH, w, stageH, { stroke: "faint" })) const sx = 12 const sw = Math.max(20, w - 24) const sy = stageH + 11 - const prog = clamp(num(p, "progress", 35), 0, 100) / 100 + const pct = clamp(num(p, "progress", 35), 0, 100) + const prog = pct / 100 prims.push(line(sx, sy, sx + sw, sy, { stroke: "faint", strokeWidth: 2 })) prims.push(line(sx, sy, sx + sw * prog, sy, { strokeWidth: 2.4 })) prims.push(ellipse(sx + sw * prog - 5, sy - 5, 10, 10, { fill: "solid", fillColor: "ink" })) const ry = stageH + cH - 10 prims.push(...icon("play", 16, ry, 12)) - prims.push(text(30, ry + 4, "1:24 / 4:03", 10, { color: "muted" })) + // Progress arrives as a whole percent, which on a 4:03 clip covers a band + // about two and a half seconds wide rather than one instant. Read the + // start of the band, so 35% keeps saying 1:24 and a full bar says 4:03. + const el = pct >= 100 ? TOTAL_S : Math.max(0, Math.ceil(((pct - 0.5) / 100) * TOTAL_S)) + prims.push(text(30, ry + 4, `${mmss(el)} / ${mmss(TOTAL_S)}`, 10, { color: "muted" })) prims.push(...icon("arrows-out", w - 16, ry, 12, { stroke: "muted" })) } return prims @@ -2523,10 +2673,11 @@ export const mapDef: ComponentDef = { group: "Media", keywords: ["location", "pin", "streets", "geo", "address"], size: { w: 300, h: 220 }, - defaults: { pins: 1, label: true, controls: true }, + defaults: { pins: 1, label: true, labelText: "You are here", controls: true }, controls: [ { key: "pins", label: "Pins", type: "number", min: 1, max: 3, quick: true }, { key: "label", label: "Label", type: "toggle", quick: true }, + { key: "labelText", label: "Label text", type: "text" }, { key: "controls", label: "Zoom controls", type: "toggle", quick: true }, ], render(p, w, h) { @@ -2564,15 +2715,18 @@ export const mapDef: ComponentDef = { prims.push(ellipse(px - sz * 0.3, py + sz * 0.22, sz * 0.6, sz * 0.2, { stroke: "faint" })) prims.push(...icon("map-pin", px, py - sz * 0.15, sz)) } + const zoom = bool(p, "controls") && h > 110 if (bool(p, "label") && w > 160) { - const lw = Math.min(w * 0.42, 118) + const labelText = str(p, "labelText", "You are here") const lh = 26 - const lx = clamp(w * 0.5 - lw / 2, 8, w - lw - 8) + // a long label grows both ways from centre — stop it short of the zoom stack + const lw = clamp(advance(labelText, 12) + 26, 60, Math.max(60, w - (zoom ? 92 : 16))) + const lx = clamp(w * 0.5 - lw / 2, 8, Math.max(8, w - lw - 8)) const ly = clamp(h * 0.5 - base * 0.85 - lh, 6, h - lh - 6) prims.push(rect(lx, ly, lw, lh, PAPER_FILL), rect(lx, ly, lw, lh)) - prims.push(text(lx + lw / 2, ly + lh / 2 + 4, truncate("You are here", 12, lw - 12), 12, { align: "center" })) + prims.push(text(lx + lw / 2, ly + lh / 2 + 4, truncate(labelText, 12, lw - 12), 12, { align: "center" })) } - if (bool(p, "controls") && h > 110) { + if (zoom) { const cw = 26 const chh = 52 const cx = w - 12 - cw diff --git a/lib/library/defs-nav.ts b/lib/library/defs-nav.ts index f46c088..128c5ec 100644 --- a/lib/library/defs-nav.ts +++ b/lib/library/defs-nav.ts @@ -3,11 +3,14 @@ // --------------------------------------------------------------------------- import type { Prim } from "@/lib/sketch/kit" -import { rect, ellipse, line, text, icon, textWidth } from "@/lib/sketch/kit" +import { rect, ellipse, line, text, icon, textWidth, truncate } from "@/lib/sketch/kit" import type { ComponentDef, Props } from "./registry" const str = (p: Props, k: string, fallback = ""): string => String(p[k] ?? fallback) const bool = (p: Props, k: string): boolean => Boolean(p[k]) +// For toggles added after the fact: an older saved node has no key at all, so +// `undefined` has to mean "the way it always looked", not "off". +const boolOn = (p: Props, k: string): boolean => p[k] === undefined || Boolean(p[k]) const num = (p: Props, k: string, fallback = 0): number => Number(p[k] ?? fallback) const list = (p: Props, k: string, fallback: string): string[] => str(p, k, fallback).split(",").map((s) => s.trim()).filter(Boolean) @@ -21,31 +24,62 @@ export const navbarDef: ComponentDef = { group: "Navigation", keywords: ["header", "topbar", "menu", "nav"], size: { w: 560, h: 56 }, - defaults: { links: "Home, Docs, Pricing", search: false, avatar: true, cta: true }, + defaults: { + brand: "", + logo: true, + links: "Home, Docs, Pricing", + search: false, + avatar: true, + cta: true, + ctaLabel: "Sign up", + }, controls: [ + { key: "brand", label: "Product name", type: "text" }, + { key: "logo", label: "Logo", type: "toggle" }, { key: "links", label: "Links (comma-sep)", type: "text" }, { key: "search", label: "Search", type: "toggle", quick: true }, { key: "avatar", label: "Avatar", type: "toggle", quick: true }, { key: "cta", label: "Button", type: "toggle", quick: true }, + { key: "ctaLabel", label: "Button text", type: "text" }, ], render(p, w, h) { const cy = h / 2 const prims: Prim[] = [rect(0, 0, w, h)] - prims.push(...icon("logo", 28, cy, 22)) - let x = 56 - for (const link of list(p, "links", "Home, Docs, Pricing")) { - prims.push(text(x, cy + 5, link, 14)) - x += textWidth(link, 14) + 22 - } + // Right side first: the brand and the links need to know where to stop. let rx = w - 16 if (bool(p, "avatar")) { prims.push(ellipse(rx - 32, cy - 16, 32, 32), ...icon("user", rx - 16, cy, 16)) rx -= 44 } if (bool(p, "cta")) { - prims.push(rect(rx - 84, cy - 15, 84, 30, { fill: "shade", fillColor: "ink" })) - prims.push(text(rx - 42, cy + 5, "Sign up", 13, { align: "center" })) - rx -= 96 + const label = str(p, "ctaLabel", "Sign up") + // Grows with the label, but never past 40% of the bar and never off the + // left edge — `rx - 4` is what's actually left after the avatar. + const want = Math.max(84, textWidth(label, 13) + 28) + const bw = Math.max(40, Math.min(want, Math.max(60, w * 0.4), rx - 4)) + prims.push(rect(rx - bw, cy - 15, bw, 30, { fill: "shade", fillColor: "ink" })) + prims.push(text(rx - bw / 2, cy + 5, truncate(label, 13, bw - 16), 13, { align: "center" })) + rx -= bw + 12 + } + let x = 16 + if (boolOn(p, "logo")) { + prims.push(...icon("logo", 28, cy, 22)) + x = 56 + } + const brand = str(p, "brand").trim() + const room = rx - x - 12 + if (brand && room > 44) { + const bt = truncate(brand, 15, Math.min(room, 220)) + prims.push(text(x, cy + 5, bt, 15, { bold: true })) + x += textWidth(bt, 15) + 28 + } + // Links drop off the tail once they'd run into the search box or the + // button — keeping the ones that fit in order, rather than letting a short + // link jump the queue past a long one that didn't. + for (const link of list(p, "links", "Home, Docs, Pricing")) { + if (x + textWidth(link, 14) > rx - 12) break + prims.push(text(x, cy + 5, link, 14)) + x += textWidth(link, 14) + 22 } if (bool(p, "search")) { const sw = Math.min(150, rx - x - 12) @@ -61,6 +95,63 @@ export const navbarDef: ComponentDef = { // -- sidebar ---------------------------------------------------------------- +// Fallback cycle, kept so unmatched rows still get distinct glyphs. +const iconNames = ["home", "grid", "check", "mail", "gear", "star", "user", "file"] as const + +// Rows guess their own glyph from the label, so renaming an item doesn't leave +// an envelope sitting next to "Settings". Matching is whole-word; for a label +// with two matches ("Team Settings") the first entry listed here wins, so the +// more specific nouns sit near the top. +const GLYPH_WORDS: ReadonlyArray = [ + ["dashboard", "house"], + ["overview", "house"], + ["home", "house"], + ["preferences", "gear"], + ["settings", "gear"], + ["admin", "gear"], + ["notifications", "bell"], + ["alerts", "bell"], + ["messages", "envelope"], + ["inbox", "envelope"], + ["mail", "envelope"], + ["analytics", "chart-bar"], + ["insights", "chart-bar"], + ["reports", "chart-bar"], + ["report", "chart-bar"], + ["stats", "chart-bar"], + ["invoices", "credit-card"], + ["payments", "credit-card"], + ["billing", "credit-card"], + ["documents", "file-text"], + ["files", "file-text"], + ["docs", "file-text"], + ["file", "file-text"], + ["customers", "users"], + ["members", "users"], + ["people", "users"], + ["users", "users"], + ["team", "users"], + ["projects", "squares-four"], + ["project", "squares-four"], + ["boards", "squares-four"], + ["apps", "squares-four"], + ["calendar", "calendar-blank"], + ["schedule", "calendar-blank"], + ["search", "magnifying-glass"], + ["tasks", "check"], + ["task", "check"], + ["todo", "check"], + ["done", "check"], +] + +function glyphFor(label: string, i: number): string { + const words = label.toLowerCase().split(/[^a-z]+/).filter(Boolean) + for (const [key, glyph] of GLYPH_WORDS) { + if (words.includes(key)) return glyph + } + return iconNames[i % iconNames.length] +} + export const sidebarDef: ComponentDef = { kind: "sidebar", name: "Sidebar", @@ -68,33 +159,50 @@ export const sidebarDef: ComponentDef = { group: "Navigation", keywords: ["nav", "menu", "drawer", "left"], size: { w: 200, h: 360 }, - defaults: { items: "Home, Projects, Tasks, Inbox, Settings", icons: true, active: 1, user: true }, + defaults: { + brand: "", + header: true, + items: "Home, Projects, Tasks, Inbox, Settings", + icons: true, + active: 1, + user: true, + }, controls: [ + { key: "brand", label: "Product name", type: "text" }, + { key: "header", label: "Logo row", type: "toggle" }, { key: "items", label: "Items (comma-sep)", type: "text" }, { key: "icons", label: "Icons", type: "toggle", quick: true }, { key: "active", label: "Active item", type: "number", min: 1, max: 8, quick: true }, - { key: "user", label: "User footer", type: "toggle" }, + { key: "user", label: "User footer", type: "toggle", quick: true }, ], render(p, w, h) { - const items = list(p, "items", "Home, Projects, Tasks, Settings") + const items = list(p, "items", "Home, Projects, Tasks, Inbox, Settings") const icons = bool(p, "icons") const active = Math.max(1, Math.min(items.length, num(p, "active", 1))) - 1 - const iconNames = ["home", "grid", "check", "mail", "gear", "star", "user", "file"] as const const prims: Prim[] = [rect(0, 0, w, h)] // logo row - prims.push(...icon("logo", 24, 26, 20)) - prims.push(rect(44, 18, w * 0.4, 14, { fill: "shade", fillColor: "muted", stroke: "faint", strokeWidth: 0.8 })) - prims.push(line(0, 50, w, 50, { stroke: "faint" })) + const header = boolOn(p, "header") + if (header) { + prims.push(...icon("logo", 24, 26, 20)) + const brand = str(p, "brand").trim() + if (brand) { + prims.push(text(44, 31, truncate(brand, 14, Math.max(24, w - 56)), 14, { bold: true })) + } else { + prims.push(rect(44, 18, w * 0.4, 14, { fill: "shade", fillColor: "muted", stroke: "faint", strokeWidth: 0.8 })) + } + prims.push(line(0, 50, w, 50, { stroke: "faint" })) + } const rowH = 40 + const top = header ? 60 : 10 items.forEach((item, i) => { - const y = 60 + i * rowH + const y = top + i * rowH if (y + rowH > h - (bool(p, "user") ? 60 : 10)) return if (i === active) { prims.push(rect(8, y, w - 16, rowH - 8, { fill: "shade", fillColor: "faint", stroke: "faint" })) } let tx = 18 if (icons) { - prims.push(...icon(iconNames[i % iconNames.length], 26, y + rowH / 2 - 4, 15, { stroke: i === active ? "ink" : "muted" })) + prims.push(...icon(glyphFor(item, i), 26, y + rowH / 2 - 4, 15, { stroke: i === active ? "ink" : "muted" })) tx = 44 } prims.push(text(tx, y + rowH / 2 + 1, item, 14, { color: i === active ? "ink" : "muted", bold: i === active })) diff --git a/lib/library/defs-templates.ts b/lib/library/defs-templates.ts index 70628b0..fbcc417 100644 --- a/lib/library/defs-templates.ts +++ b/lib/library/defs-templates.ts @@ -4,20 +4,30 @@ // --------------------------------------------------------------------------- import type { Prim } from "@/lib/sketch/kit" -import { rect, ellipse, line, text, icon, place, loremLines } from "@/lib/sketch/kit" +import { rect, ellipse, line, text, icon, place, loremLines, truncate } from "@/lib/sketch/kit" import type { ComponentDef, Props } from "./registry" import { buttonDef, inputDef, checkboxDef, switchDef, avatarDef } from "./defs-basic" import { chartDef, tableDef, dividerDef } from "./defs-display" import { navbarDef, sidebarDef } from "./defs-nav" const str = (p: Props, k: string, fallback = ""): string => String(p[k] ?? fallback) -const bool = (p: Props, k: string): boolean => Boolean(p[k]) +// the fallback matters for keys added after a document was saved: a node drawn +// from props that predate the key must land on the old hardcoded behaviour +const bool = (p: Props, k: string, fallback = false): boolean => (p[k] === undefined ? fallback : Boolean(p[k])) const num = (p: Props, k: string, fallback = 0): number => Number(p[k] ?? fallback) function sub(def: ComponentDef, props: Props, x: number, y: number, w: number, h: number): Prim[] { return place(def.render({ ...def.defaults, ...props }, w, h), x, y) } +// The height each screen is drawn for. A template laid out against a fraction +// of this reproduces its shipped layout exactly at the default size, so screens +// already sitting in saved documents don't move. +const LOGIN_H = 460 +const SIGNUP_H = 480 +const SETTINGS_H = 460 +const EMPTY_H = 300 + // -- login ------------------------------------------------------------------ export const loginDef: ComponentDef = { @@ -26,10 +36,11 @@ export const loginDef: ComponentDef = { category: "blocks", group: "Screens", keywords: ["signin", "auth", "form", "welcome"], - size: { w: 360, h: 460 }, - defaults: { title: "Welcome back", social: true, signup: true }, + size: { w: 360, h: LOGIN_H }, + defaults: { title: "Welcome back", logo: true, social: true, signup: true }, controls: [ { key: "title", label: "Title", type: "text" }, + { key: "logo", label: "Logo", type: "toggle" }, { key: "social", label: "Social buttons", type: "toggle", quick: true }, { key: "signup", label: "Sign-up link", type: "toggle", quick: true }, ], @@ -37,29 +48,46 @@ export const loginDef: ComponentDef = { const prims: Prim[] = [rect(0, 0, w, h)] const pad = 32 const cw = w - pad * 2 - let y = 36 - prims.push(...icon("logo", w / 2, y, 34)) - y += 32 - prims.push(text(w / 2, y + 10, str(p, "title", "Welcome back"), 22, { align: "center", bold: true })) - y += 34 - prims.push(...sub(inputDef, { label: "Email", icon: "mail", placeholder: "you@wherever.com" }, pad, y, cw, 60)) - y += 72 - prims.push(...sub(inputDef, { label: "Password", icon: "lock", placeholder: "••••••••" }, pad, y, cw, 60)) - y += 74 - prims.push(...sub(buttonDef, { label: "Log in", variant: "filled" }, pad, y, cw, 42)) - y += 54 - prims.push(text(w / 2, y, "forgot password?", 13, { align: "center", color: "muted" })) - y += 18 - if (bool(p, "social")) { + // Drag the screen shorter and the gaps close first (`g`), then the fields + // and the button share out whatever height is left (`fit`), and only then + // do the trailing rows drop. At the size it ships at both are 1, so the + // whole stack lands on the numbers it always did. + const k = Math.min(1, h / LOGIN_H) + const g = (n: number) => n * k + let y = g(36) + // the mark is drawn from its centre, so it needs its own radius of headroom + const showLogo = bool(p, "logo", true) && y >= 18 + const chrome = y + (showLogo ? g(32) : 0) + g(34) + g(12) + g(14) + g(12) + g(18) + const fit = Math.min(1, Math.max(0, h - chrome) / 162) + const fieldH = 60 * fit + const btnH = 42 * fit + if (showLogo) { + prims.push(...icon("logo", w / 2, y, 34)) + y += g(32) + } + prims.push(text(w / 2, y + 10, truncate(str(p, "title", "Welcome back"), 22, cw), 22, { align: "center", bold: true })) + y += g(34) + prims.push(...sub(inputDef, { label: "Email", icon: "mail", placeholder: "you@wherever.com" }, pad, y, cw, fieldH)) + y += fieldH + g(12) + prims.push(...sub(inputDef, { label: "Password", icon: "lock", placeholder: "••••••••" }, pad, y, cw, fieldH)) + y += fieldH + g(14) + prims.push(...sub(buttonDef, { label: "Log in", variant: "filled" }, pad, y, cw, btnH)) + y += btnH + g(12) + if (y + g(18) <= h) { + prims.push(text(w / 2, y, "forgot password?", 13, { align: "center", color: "muted" })) + y += g(18) + } + if (bool(p, "social") && y + 20 + g(10) + 38 + g(12) <= h) { prims.push(...sub(dividerDef, { showLabel: true, label: "or" }, pad, y, cw, 20)) - y += 30 + y += 20 + g(10) const half = (cw - 12) / 2 prims.push(...sub(buttonDef, { label: "Google", variant: "outline" }, pad, y, half, 38)) prims.push(...sub(buttonDef, { label: "GitHub", variant: "outline" }, pad + half + 12, y, half, 38)) - y += 50 + y += 38 + g(12) } if (bool(p, "signup")) { - prims.push(text(w / 2, Math.max(y + 6, h - 20), "no account? sign up", 13, { align: "center", color: "muted" })) + const sy = Math.max(y + 6, h - 20) + if (sy <= h - 6) prims.push(text(w / 2, sy, "no account? sign up", 13, { align: "center", color: "muted" })) } return prims }, @@ -73,32 +101,50 @@ export const signupDef: ComponentDef = { category: "blocks", group: "Screens", keywords: ["register", "auth", "create account"], - size: { w: 360, h: 480 }, - defaults: { title: "Join the club", terms: true }, + size: { w: 360, h: SIGNUP_H }, + defaults: { title: "Join the club", terms: true, social: false }, controls: [ { key: "title", label: "Title", type: "text" }, { key: "terms", label: "Terms checkbox", type: "toggle", quick: true }, + { key: "social", label: "Social buttons", type: "toggle", quick: true }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] const pad = 32 const cw = w - pad * 2 - let y = 30 - prims.push(text(w / 2, y + 12, str(p, "title", "Join the club"), 22, { align: "center", bold: true })) - y += 30 + // same two dials as the login screen: gaps close, then the three fields and + // the button give up height together, both landing on 1 at the shipped size + const k = Math.min(1, h / SIGNUP_H) + const g = (n: number) => n * k + const terms = bool(p, "terms") + let y = g(30) + const chrome = y + g(30) + g(24) + g(10) + g(10) + g(12) + (terms ? 22 + g(12) : 0) + g(12) + const fit = Math.min(1, Math.max(0, h - chrome) / 222) + const fieldH = 60 * fit + const btnH = 42 * fit + prims.push(text(w / 2, y + 12, truncate(str(p, "title", "Join the club"), 22, cw), 22, { align: "center", bold: true })) + y += g(30) prims.push(line(w / 2 - cw * 0.3, y + 8, w / 2 + cw * 0.3, y + 8, { stroke: "muted", strokeWidth: 1.2 })) - y += 24 - prims.push(...sub(inputDef, { label: "Name", icon: "none", placeholder: "Maria Scribbles" }, pad, y, cw, 60)) - y += 70 - prims.push(...sub(inputDef, { label: "Email", icon: "mail", placeholder: "you@wherever.com" }, pad, y, cw, 60)) - y += 70 - prims.push(...sub(inputDef, { label: "Password", icon: "lock", placeholder: "make it weird" }, pad, y, cw, 60)) - y += 72 - if (bool(p, "terms")) { + y += g(24) + prims.push(...sub(inputDef, { label: "Name", icon: "none", placeholder: "Maria Scribbles" }, pad, y, cw, fieldH)) + y += fieldH + g(10) + prims.push(...sub(inputDef, { label: "Email", icon: "mail", placeholder: "you@wherever.com" }, pad, y, cw, fieldH)) + y += fieldH + g(10) + prims.push(...sub(inputDef, { label: "Password", icon: "lock", placeholder: "make it weird" }, pad, y, cw, fieldH)) + y += fieldH + g(12) + if (terms) { prims.push(...sub(checkboxDef, { label: "I agree to the scribbly terms", checked: false }, pad, y, cw, 22)) - y += 34 + y += 22 + g(12) + } + prims.push(...sub(buttonDef, { label: "Create account", variant: "filled" }, pad, y, cw, btnH)) + y += btnH + g(12) + if (bool(p, "social") && y + 20 + g(10) + 38 <= h) { + prims.push(...sub(dividerDef, { showLabel: true, label: "or" }, pad, y, cw, 20)) + y += 20 + g(10) + const half = (cw - 12) / 2 + prims.push(...sub(buttonDef, { label: "Google", variant: "outline" }, pad, y, half, 38)) + prims.push(...sub(buttonDef, { label: "GitHub", variant: "outline" }, pad + half + 12, y, half, 38)) } - prims.push(...sub(buttonDef, { label: "Create account", variant: "filled" }, pad, y, cw, 42)) return prims }, } @@ -111,10 +157,12 @@ export const settingsDef: ComponentDef = { category: "blocks", group: "Screens", keywords: ["preferences", "account", "profile", "form"], - size: { w: 640, h: 460 }, - defaults: { nav: true, danger: true }, + size: { w: 640, h: SETTINGS_H }, + defaults: { title: "Settings", nav: true, active: 1, danger: true }, controls: [ + { key: "title", label: "Title", type: "text" }, { key: "nav", label: "Side nav", type: "toggle", quick: true }, + { key: "active", label: "Active section", type: "number", min: 1, max: 5, quick: true }, { key: "danger", label: "Danger zone", type: "toggle", quick: true }, ], render(p, w, h) { @@ -124,35 +172,73 @@ export const settingsDef: ComponentDef = { left = Math.min(170, w * 0.28) prims.push(line(left, 0, left, h, { stroke: "faint" })) const items = ["Profile", "Account", "Billing", "Alerts", "Team"] + const active = Math.max(1, Math.min(items.length, Math.round(num(p, "active", 1)))) - 1 items.forEach((item, i) => { const y = 30 + i * 36 if (y > h - 20) return - if (i === 0) prims.push(rect(10, y - 18, left - 20, 30, { fill: "shade", fillColor: "faint", stroke: "faint" })) - prims.push(text(22, y + 2, item, 14, { color: i === 0 ? "ink" : "muted", bold: i === 0 })) + if (i === active) prims.push(rect(10, y - 18, left - 20, 30, { fill: "shade", fillColor: "faint", stroke: "faint" })) + prims.push(text(22, y + 2, item, 14, { color: i === active ? "ink" : "muted", bold: i === active })) }) } const pad = 28 const cx = left + pad const cw = w - left - pad * 2 - let y = 34 - prims.push(text(cx, y, "Settings", 22, { bold: true })) - y += 24 - prims.push(...place(avatarDef.render({ ...avatarDef.defaults, content: "icon" }, 56, 56), cx, y)) - prims.push(...sub(buttonDef, { label: "Change photo", variant: "outline", size: "sm" }, cx + 72, y + 12, 120, 32)) - y += 74 - prims.push(...sub(inputDef, { label: "Display name", placeholder: "Pablo S." }, cx, y, cw, 58)) - y += 70 - prims.push(...sub(inputDef, { label: "Email", icon: "mail", placeholder: "pablo@squig.sh" }, cx, y, cw, 58)) - y += 74 + // Gaps close first. After that the photo row is what gives — two legible + // fields are worth more on a settings page than a portrait — and only then + // do the fields themselves shrink. The Save button is pinned to the bottom, + // so its band never belongs to the stack. All of it lands on the shipped + // numbers at the shipped height. + const k = Math.min(1, h / SETTINGS_H) + const g = (n: number) => n * k + const gaps = g(34) + g(24) + g(12) + g(16) + (26 + g(10)) + (26 + g(14)) + 46 + const spare = h - gaps - g(18) + const showPhoto = spare >= 44 * 2 + 40 + const avatarH = showPhoto ? 56 * Math.min(1, (spare - 44 * 2) / 56) : 0 + const stack = h - gaps - (showPhoto ? avatarH + g(18) : 0) + const fieldH = Math.max(0, Math.min(58, stack / 2)) + let y = g(34) + prims.push(text(cx, y, truncate(str(p, "title", "Settings"), 22, cw), 22, { bold: true })) + y += g(24) + if (showPhoto) { + prims.push(...place(avatarDef.render({ ...avatarDef.defaults, content: "icon" }, avatarH, avatarH), cx, y)) + const photoH = Math.min(32, avatarH) + const photoW = Math.min(120, cw - avatarH - 16) + if (photoW >= 60) { + prims.push( + ...sub( + buttonDef, + { label: "Change photo", variant: "outline", size: "sm" }, + cx + avatarH + 16, + y + (avatarH - photoH) / 2, + photoW, + photoH + ) + ) + } + y += avatarH + g(18) + } + prims.push(...sub(inputDef, { label: "Display name", placeholder: "Pablo S." }, cx, y, cw, fieldH)) + y += fieldH + g(12) + prims.push(...sub(inputDef, { label: "Email", icon: "mail", placeholder: "pablo@squig.sh" }, cx, y, cw, fieldH)) + y += fieldH + g(16) prims.push(...sub(switchDef, { label: "Email me way too often", on: true }, cx, y, cw, 26)) - y += 36 + y += 26 + g(10) prims.push(...sub(switchDef, { label: "Dark mode (for night scribbles)", on: false }, cx, y, cw, 26)) - y += 40 + y += 26 + g(14) if (bool(p, "danger") && y < h - 50) { prims.push(line(cx, y, cx + cw, y, { stroke: "faint" })) y += 24 prims.push(...icon("warning", cx + 10, y + 6, 18)) - prims.push(...sub(buttonDef, { label: "Delete everything", variant: "outline", size: "sm" }, cx + 30, y - 8, 150, 32)) + prims.push( + ...sub( + buttonDef, + { label: "Delete everything", variant: "outline", size: "sm" }, + cx + 30, + y - 8, + Math.min(150, cw - 30), + 32 + ) + ) } prims.push(...sub(buttonDef, { label: "Save", variant: "filled", size: "sm" }, w - 110, h - 46, 82, 32)) return prims @@ -168,11 +254,13 @@ export const dashboardDef: ComponentDef = { group: "Screens", keywords: ["admin", "analytics", "stats", "app"], size: { w: 760, h: 520 }, - defaults: { sidebar: true, stats: 3, chart: "line" }, + defaults: { title: "Good morning, doodler", sidebar: true, stats: 3, chart: "line", table: true }, controls: [ + { key: "title", label: "Title", type: "text" }, { key: "sidebar", label: "Sidebar", type: "toggle", quick: true }, { key: "stats", label: "Stat cards", type: "number", min: 2, max: 4, quick: true }, - { key: "chart", label: "Chart style", type: "select", options: ["line", "bars"], quick: true }, + { key: "chart", label: "Chart style", type: "select", options: ["line", "bars", "pie"], quick: true }, + { key: "table", label: "Side table", type: "toggle" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] @@ -187,10 +275,10 @@ export const dashboardDef: ComponentDef = { const cx = left + pad const cw = w - left - pad * 2 let y = navH + pad - prims.push(text(cx, y + 8, "Good morning, doodler", 20, { bold: true })) + prims.push(text(cx, y + 8, truncate(str(p, "title", "Good morning, doodler"), 20, cw), 20, { bold: true })) y += 26 // stat cards - const n = Math.max(2, Math.min(4, num(p, "stats", 3))) + const n = Math.max(2, Math.min(4, Math.round(num(p, "stats", 3)))) const gap = 14 const cardW = (cw - gap * (n - 1)) / n for (let i = 0; i < n; i++) { @@ -204,11 +292,14 @@ export const dashboardDef: ComponentDef = { // chart + table row const chartH = h - y - pad if (chartH > 80) { - const chartW = cw * 0.58 + const hasTable = bool(p, "table", true) + const chartW = hasTable ? cw * 0.58 : cw prims.push(rect(cx, y, chartW, chartH)) prims.push(...place(chartDef.render({ ...chartDef.defaults, style: str(p, "chart", "line"), title: true }, chartW - 28, chartH - 28), cx + 14, y + 14)) - const tw = cw - chartW - 14 - prims.push(...place(tableDef.render({ ...tableDef.defaults, cols: 2, rows: Math.max(2, Math.floor(chartH / 44)), header: true }, tw, chartH), cx + chartW + 14, y)) + if (hasTable) { + const tw = cw - chartW - 14 + prims.push(...place(tableDef.render({ ...tableDef.defaults, cols: 2, rows: Math.max(2, Math.floor(chartH / 44)), header: true }, tw, chartH), cx + chartW + 14, y)) + } } return prims }, @@ -317,7 +408,7 @@ export const emptyStateDef: ComponentDef = { category: "blocks", group: "Screens", keywords: ["blank", "zero", "nothing", "onboarding"], - size: { w: 360, h: 300 }, + size: { w: 360, h: EMPTY_H }, defaults: { title: "Nothing here yet", cta: true, icon: "image" }, controls: [ { key: "title", label: "Title", type: "text" }, @@ -326,13 +417,22 @@ export const emptyStateDef: ComponentDef = { ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h, { stroke: "faint", dashed: true })] + // the bubble already rides on h; everything hanging off it does too now, so + // a short box tightens the stack instead of pushing the button through the + // floor. Only shrinks — a taller box keeps the airy spacing it has today. + const k = Math.min(1, h / EMPTY_H) + const g = (n: number) => n * k const cy = h * 0.34 - prims.push(ellipse(w / 2 - 44, cy - 44, 88, 88, { fill: "shade", fillColor: "faint" })) - prims.push(...icon(str(p, "icon", "image") as "image", w / 2, cy, 40, { stroke: "muted" })) - prims.push(text(w / 2, cy + 76, str(p, "title", "Nothing here yet"), 19, { align: "center", bold: true })) - prims.push(line(w / 2 - w * 0.28, cy + 96, w / 2 + w * 0.28, cy + 96, { stroke: "muted", strokeWidth: 1.2 })) - if (bool(p, "cta")) { - prims.push(...sub(buttonDef, { label: "Make a thing", variant: "filled" }, w / 2 - 75, cy + 116, 150, 40)) + const ring = 88 * k + prims.push(ellipse(w / 2 - ring / 2, cy - ring / 2, ring, ring, { fill: "shade", fillColor: "faint" })) + prims.push(...icon(str(p, "icon", "image") as "image", w / 2, cy, 40 * k, { stroke: "muted" })) + prims.push(text(w / 2, cy + g(76), str(p, "title", "Nothing here yet"), 19, { align: "center", bold: true })) + prims.push(line(w / 2 - w * 0.28, cy + g(96), w / 2 + w * 0.28, cy + g(96), { stroke: "muted", strokeWidth: 1.2 })) + const by = cy + g(116) + const bh = Math.max(28, 40 * k) + const bw = Math.min(150, w - 32) + if (bool(p, "cta") && by + bh <= h) { + prims.push(...sub(buttonDef, { label: "Make a thing", variant: "filled" }, w / 2 - bw / 2, by, bw, bh)) } return prims }, diff --git a/package.json b/package.json index 0ba3522..a1b77ac 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "tsc --noEmit -p scripts/tsconfig.json && node --experimental-strip-types --import ./scripts/register-loader.mjs scripts/test-geometry.ts && node --experimental-strip-types --import ./scripts/register-loader.mjs scripts/test-selection.ts && node --experimental-strip-types --import ./scripts/register-loader.mjs scripts/test-clipboard.ts" + "test": "tsc --noEmit -p scripts/tsconfig.json && node --experimental-strip-types --import ./scripts/register-loader.mjs scripts/test-geometry.ts && node --experimental-strip-types --import ./scripts/register-loader.mjs scripts/test-selection.ts && node --experimental-strip-types --import ./scripts/register-loader.mjs scripts/test-clipboard.ts && node --experimental-strip-types --import ./scripts/register-loader.mjs scripts/test-defs.ts" }, "dependencies": { "@base-ui/react": "^1.6.0", diff --git a/scripts/test-defs.ts b/scripts/test-defs.ts new file mode 100644 index 0000000..c0ce043 --- /dev/null +++ b/scripts/test-defs.ts @@ -0,0 +1,198 @@ +// --------------------------------------------------------------------------- +// Render smoke test for the component library. Every def gets drawn at three +// sizes — the one it ships with, and the two ends of a plausible resize — and +// the prims that come back are inspected for the failures a wireframe tool +// can't survive: a throw, an empty component, a NaN in a coordinate, or a +// layout that spills outside the box the user dragged. +// +// node --experimental-strip-types --import ./scripts/register-loader.mjs \ +// scripts/test-defs.ts +// +// It also reads the control metadata, which is where the inspector gets its +// fields from: a control with no default, or a select whose default isn't one +// of its options, shows up as a blank or wrong-looking field in the panel. +// --------------------------------------------------------------------------- + +import { ALL_DEFS, type ComponentDef } from "../lib/library/registry.ts" +import type { Prim } from "../lib/sketch/kit.ts" + +let passed = 0 +const failures: string[] = [] + +function check(name: string, cond: boolean, detail = "") { + if (cond) passed++ + else failures.push(`${name}${detail ? ` — ${detail}` : ""}`) +} + +// -- reading a prim --------------------------------------------------------- + +/** + * Every number a prim carries that has to be a real number, labelled so a + * failure names the field. Strings (path data, label text) are skipped, and so + * are the style opts, which are checked separately. + */ +function coords(p: Prim): [string, number][] { + const out: [string, number][] = [] + const add = (...entries: [string, number | undefined][]) => { + for (const [k, v] of entries) if (v !== undefined) out.push([k, v]) + } + switch (p.t) { + case "rect": + add(["x", p.x], ["y", p.y], ["w", p.w], ["h", p.h], ["r", p.r]) + break + case "ellipse": + add(["x", p.x], ["y", p.y], ["w", p.w], ["h", p.h]) + break + case "line": + add(["x1", p.x1], ["y1", p.y1], ["x2", p.x2], ["y2", p.y2]) + break + case "poly": + p.pts.forEach(([x, y], i) => add([`pts[${i}].x`, x], [`pts[${i}].y`, y])) + break + case "text": + add(["x", p.x], ["y", p.y], ["size", p.size], ["maxW", p.maxW]) + break + case "path": + add(["x", p.x], ["y", p.y], ["size", p.size], ["vb", p.vb]) + break + } + if ("o" in p && p.o) { + add(["o.strokeWidth", p.o.strokeWidth], ["o.roughness", p.o.roughness], ["o.r", p.o.r]) + } + return out +} + +/** + * The box a prim occupies. Text is the exception: its width depends on font + * metrics we don't have here, so only the anchor is measured — enough to catch + * a label positioned off the component, not enough to catch one that runs long. + */ +function span(p: Prim): { minX: number; minY: number; maxX: number; maxY: number } { + switch (p.t) { + case "rect": + case "ellipse": + return { minX: p.x, minY: p.y, maxX: p.x + p.w, maxY: p.y + p.h } + case "line": + return { + minX: Math.min(p.x1, p.x2), + minY: Math.min(p.y1, p.y2), + maxX: Math.max(p.x1, p.x2), + maxY: Math.max(p.y1, p.y2), + } + case "poly": { + const xs = p.pts.map(([x]) => x) + const ys = p.pts.map(([, y]) => y) + return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) } + } + case "path": + return { minX: p.x, minY: p.y, maxX: p.x + p.size, maxY: p.y + p.size } + case "text": + return { minX: p.x, minY: p.y, maxX: p.x, maxY: p.y } + } +} + +const where = (p: Prim) => JSON.stringify(p).slice(0, 120) + +/** + * How far outside its own box a component is allowed to draw. Strokes are + * centred on the path, so a shape sitting flush on the edge legitimately hangs + * half a stroke over it, and a couple of defs inset a ring by an eyelash more + * than that. Anything past this is layout that doesn't fit, not ink bleed. + */ +const OVERHANG = 2 + +/** + * Defs that draw outside the box on purpose. `frame` hangs its name above the + * artboard the way Figma does — the label is about the frame, not in it. + */ +const DRAWS_OUTSIDE = new Set(["frame"]) + +// the sizes a def has to survive: its own, dragged in, dragged out +const SCALES = [1, 0.6, 1.5] +const sizeAt = (def: ComponentDef, s: number) => ({ + w: Math.round(def.size.w * s), + h: Math.round(def.size.h * s), + label: s === 1 ? "default" : `${Math.round(s * 100)}%`, +}) + +// -- rendering -------------------------------------------------------------- + +for (const def of ALL_DEFS) { + const renders: { label: string; w: number; h: number; prims: Prim[] }[] = [] + + let threw = "" + for (const s of SCALES) { + const { w, h, label } = sizeAt(def, s) + try { + renders.push({ label, w, h, prims: def.render({ ...def.defaults }, w, h) }) + } catch (e) { + threw ||= `${label} (${w}×${h}): ${(e as Error).message}` + } + } + check(`${def.kind} renders at every size`, !threw, threw) + if (threw) continue + + const empty = renders.find((r) => !Array.isArray(r.prims) || r.prims.length === 0) + check(`${def.kind} draws something at every size`, !empty, empty && `nothing at ${empty.label}`) + + // a NaN reaches rough.js as a broken path and takes the whole canvas with it + let bad = "" + for (const r of renders) { + for (const p of r.prims) { + for (const [field, v] of coords(p)) { + if (!Number.isFinite(v)) bad ||= `${r.label}: ${field}=${v} in ${where(p)}` + } + } + } + check(`${def.kind} has no NaN coordinates`, !bad, bad) + + // a component that draws past its own box overlaps its neighbours on the + // board and can't be laid out against anything + let over = 0 + let worst = "" + for (const r of DRAWS_OUTSIDE.has(def.kind) ? [] : renders) { + for (const p of r.prims) { + const b = span(p) + const out = Math.max(-b.minX, -b.minY, b.maxX - r.w, b.maxY - r.h) + if (out > over) { + over = out + worst = `${r.label} (${r.w}×${r.h}) by ${out.toFixed(1)}px: ${where(p)}` + } + } + } + check(`${def.kind} stays inside its box`, over <= OVERHANG, worst) +} + +// -- controls --------------------------------------------------------------- + +for (const def of ALL_DEFS) { + // the inspector reads a field's current value out of defaults; a control + // without one renders empty and writes undefined back into the node + const orphans = def.controls.filter((c) => !(c.key in def.defaults)).map((c) => c.key) + check(`${def.kind} defaults every control it declares`, orphans.length === 0, orphans.join(", ")) + + // a select whose default isn't one of its options opens with nothing chosen + const strays = def.controls + .filter((c) => c.type === "select" && c.key in def.defaults) + .filter((c) => !(c.options ?? []).includes(String(def.defaults[c.key]))) + .map((c) => `${c.key}=${JSON.stringify(def.defaults[c.key])} not in [${(c.options ?? []).join("|")}]`) + check(`${def.kind} select defaults are one of their options`, strays.length === 0, strays.join("; ")) + + // the floating row sits over the canvas, so it stays a handful of knobs — + // past three it stops being a shortcut and starts being the panel again + const quick = def.controls.filter((c) => c.quick && c.type !== "text") + check(`${def.kind} keeps the quick row to three`, quick.length <= 3, quick.map((c) => c.key).join(", ")) + + // and a text field can't live there: it needs room to type in + const quickText = def.controls.filter((c) => c.quick && c.type === "text").map((c) => c.key) + check(`${def.kind} keeps text out of the quick row`, quickText.length === 0, quickText.join(", ")) +} + +// --------------------------------------------------------------------------- + +if (failures.length) { + console.error(`\n✗ ${failures.length} failed, ${passed} passed\n`) + for (const f of failures) console.error(" ✗ " + f) + process.exit(1) +} +console.log(`✓ ${passed} def checks passed across ${ALL_DEFS.length} components`) From 0f4a5e3f2b4c256105c938726c2f719e53b20f85 Mon Sep 17 00:00:00 2001 From: pablostanley Date: Thu, 30 Jul 2026 15:27:50 -0700 Subject: [PATCH 2/2] Let the big screens say something other than what they shipped with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass over the library, aimed at what the first one missed. The screens had nothing to say. landing-page is the biggest thing you can drop — 1000x830 — and its 34px headline was a string literal, so every landing page anyone wireframed said the same sentence. app-shell had three chrome toggles and no way to name the page you were on. pricing shipped two count knobs against frozen arrays of plan names and prices, which is everything a pricing table exists to say. chat-screen was permanently a conversation with Maya. inbox-screen's 20px subject was the largest type on the screen and hardcoded. The first sweep made its own inconsistencies. Seven marketing blocks got a quick heading toggle and no text control, so you could switch the heading on and off and were stuck with the joke it shipped with. profile-header gained a name control, but profile-screen composed it and dropped the prop, so the person the screen is about was uneditable one level up. "Heading" was a toggle in ten defs and a text box in four. A button's text field went by three different names. spinner labelled its toggle "Label" and its text field "Text", exactly backwards from the six defs it should match. Blocks that still argued with themselves: onboarding-checklist's progress readout was off by one against its own Current step, product-grid drew stars unconditionally while its sibling gated them, command-palette pinned the highlight to row 0, payment-methods to card 0. Number controls accepted fractions. The field parsed "2.5" and only clamped to min/max, and defs index arrays and divide grids by that raw value, so a typed fraction wasn't a smaller count, it was a broken layout. All 70 number controls are counts, indices or percents, so this rounds once at commit in variant-controls.tsx, covering the panel and the quick row while geometry fields keep their decimals. 48 controls added, one removed: button-group's Segments, which fought its own comma-separated label list — accordion lost the same fight last commit, so it now follows the same rule. Saved documents still honour a frozen count. Saved documents keep rendering the same, verified by diffing every def's prims against the previous commit across the old prop set rather than by eye. No prop key was renamed; relabelling is safe because labels aren't persisted, which is why most of the consistency fixes cost nothing. Co-Authored-By: Claude Opus 5 --- components/chrome/variant-controls.tsx | 10 +- lib/library/defs-basic.ts | 9 +- lib/library/defs-blocks-app.ts | 226 +++++++++++++++++-------- lib/library/defs-blocks-marketing.ts | 218 +++++++++++++++++------- lib/library/defs-display.ts | 17 +- lib/library/defs-more.ts | 27 +-- lib/library/defs-templates.ts | 32 +++- 7 files changed, 380 insertions(+), 159 deletions(-) diff --git a/components/chrome/variant-controls.tsx b/components/chrome/variant-controls.tsx index 79f7e67..6490bcc 100644 --- a/components/chrome/variant-controls.tsx +++ b/components/chrome/variant-controls.tsx @@ -125,8 +125,16 @@ export function VariantControl({ // the field groups a whole drag into one checkpoint; writing another // per tick underneath it would undo the grouping it just did onGestureStart={() => st().checkpoint()} + // rounded because every variant number is a count, an index or a + // percent — components index arrays and divide grids by these, so a + // typed "2.5" isn't a smaller 2, it's a broken layout. Dragging and + // the arrow keys already step whole units; this catches the typed + // path. Geometry fields (x/y/w/h) use MixedNumberField directly and + // keep their fractions. onCommit={(n) => - setValue(Math.min(control.max ?? Infinity, Math.max(control.min ?? -Infinity, n)), { checkpoint: false }) + setValue(Math.round(Math.min(control.max ?? Infinity, Math.max(control.min ?? -Infinity, n))), { + checkpoint: false, + }) } /> ) diff --git a/lib/library/defs-basic.ts b/lib/library/defs-basic.ts index ee54ae8..d370466 100644 --- a/lib/library/defs-basic.ts +++ b/lib/library/defs-basic.ts @@ -333,10 +333,11 @@ export const sliderDef: ComponentDef = { { key: "label", label: "Label", type: "text" }, { key: "showLabel", label: "Show label", type: "toggle", quick: true }, { key: "value", label: "Value", type: "number", min: 0, max: 100, quick: true }, - { key: "showValue", label: "Show value", type: "toggle" }, + { key: "showValue", label: "Show value", type: "toggle", quick: true }, ], render(p, w, h) { - const v = Math.max(0, Math.min(100, num(p, "value", 60))) / 100 + const raw = Math.max(0, Math.min(100, num(p, "value", 60))) + const v = raw / 100 // Below 26px tall the label would sit on top of the track, so it steps aside. const showLabel = bool(p, "showLabel") && h >= 26 const top = showLabel ? Math.min(22, h * 0.5) : 0 @@ -358,7 +359,9 @@ export const sliderDef: ComponentDef = { if (bool(p, "showValue")) { // The label pushes the track down; keep the number off the bottom edge. const vy = showLabel ? Math.min(cy + 5, h - 4) : cy + 5 - prims.push(text(trackW + 8, vy, String(num(p, "value", 60)), 13, { color: "muted" })) + // Same number the knob is standing on, and short enough for the 36px + // the track gave up — "33.333" would run off the right edge. + prims.push(text(trackW + 8, vy, String(Math.round(raw)), 13, { color: "muted" })) } return prims }, diff --git a/lib/library/defs-blocks-app.ts b/lib/library/defs-blocks-app.ts index cf3d19e..5ff07ce 100644 --- a/lib/library/defs-blocks-app.ts +++ b/lib/library/defs-blocks-app.ts @@ -28,7 +28,16 @@ const bool = (p: Props, k: string): boolean => Boolean(p[k]) * `undefined` has to mean "the way it always looked", not "off". */ const boolOn = (p: Props, k: string): boolean => p[k] === undefined || Boolean(p[k]) const num = (p: Props, k: string, fallback = 0): number => Number(p[k] ?? fallback) +const list = (p: Props, k: string, fallback: string): string[] => { + const out = str(p, k, fallback) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + return out.length ? out : fallback.split(",").map((s) => s.trim()) +} const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v)) +/** safe indexed pick from a cycling pool */ +const pick = (pool: string[], i: number): string => pool[((i % pool.length) + pool.length) % pool.length] function sub(def: ComponentDef, props: Props, x: number, y: number, w: number, h: number): Prim[] { return place(def.render({ ...def.defaults, ...props }, w, h), x, y) @@ -344,19 +353,24 @@ export const notificationListDef: ComponentDef = { group: "App", keywords: ["alerts", "bell", "updates", "unread", "inbox"], size: { w: 420, h: 400 }, - defaults: { rows: 4, header: true, avatars: true }, + defaults: { rows: 4, header: true, avatars: true, unread: 2, title: "Notifications" }, controls: [ { key: "rows", label: "Rows", type: "number", min: 2, max: 6, quick: true }, { key: "header", label: "Header", type: "toggle", quick: true }, + { key: "unread", label: "Unread", type: "number", min: 0, max: 6, quick: true }, { key: "avatars", label: "Avatars", type: "toggle" }, + { key: "title", label: "Title", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] const pad = 14 let y = pad + const nUnread = clamp(num(p, "unread", 2), 0, 6) if (bool(p, "header")) { - prims.push(text(pad, y + 16, "Notifications", 16, { bold: true })) - if (w > 260) prims.push(text(w - pad, y + 16, "Mark all read", 12, { align: "right", color: "muted" })) + const link = w > 260 && nUnread > 0 ? "Mark all read" : "" + const reserve = link ? textWidth(link, 12) + 12 : 0 + prims.push(text(pad, y + 16, truncate(str(p, "title", "Notifications"), 16, w - pad * 2 - reserve), 16, { bold: true })) + if (link) prims.push(text(w - pad, y + 16, link, 12, { align: "right", color: "muted" })) prims.push(hair(pad, y + 28, w - pad * 2)) y += 38 } @@ -380,11 +394,11 @@ export const notificationListDef: ComponentDef = { for (let i = 0; i < n; i++) { const cy = y + rowH / 2 if (i > 0) prims.push(hair(pad, y, w - pad * 2)) - if (i < 2) prims.push(ellipse(pad - 2, cy - 3, 6, 6, { fill: "solid", fillColor: "ink" })) + if (i < nUnread) prims.push(ellipse(pad - 2, cy - 3, 6, 6, { fill: "solid", fillColor: "ink" })) if (useAvatar) prims.push(...sub(avatarDef, { content: "icon" }, ax, cy - d / 2, d, d)) else prims.push(...icon(icons[i], ax + d / 2, cy, d * 0.6, { stroke: "muted" })) const tw = w - tx - pad - 34 - prims.push(text(tx, cy - 4, truncate(titles[i], 14, tw), 14, { bold: i < 2 })) + prims.push(text(tx, cy - 4, truncate(titles[i], 14, tw), 14, { bold: i < nUnread })) prims.push(body(tx, cy + 13, tw * 0.86)) prims.push(text(w - pad, cy - 4, times[i], 11, { align: "right", color: "muted" })) y += rowH @@ -402,10 +416,11 @@ export const activityFeedDef: ComponentDef = { group: "App", keywords: ["timeline", "history", "log", "recent", "events"], size: { w: 400, h: 320 }, - defaults: { rows: 4, header: true, avatars: true }, + defaults: { rows: 4, header: true, avatars: true, title: "Recently" }, controls: [ { key: "rows", label: "Entries", type: "number", min: 2, max: 6, quick: true }, { key: "header", label: "Header", type: "toggle", quick: true }, + { key: "title", label: "Title", type: "text" }, { key: "avatars", label: "Avatars", type: "toggle" }, ], render(p, w, h) { @@ -413,7 +428,7 @@ export const activityFeedDef: ComponentDef = { const pad = 16 let y = pad if (bool(p, "header")) { - prims.push(text(pad, y + 14, "Recently", 15, { bold: true })) + prims.push(text(pad, y + 14, truncate(str(p, "title", "Recently"), 15, w - pad * 2), 15, { bold: true })) y += 28 } const avail = h - y - pad @@ -597,8 +612,9 @@ export const kanbanBoardDef: ComponentDef = { group: "App", keywords: ["board", "columns", "cards", "tasks", "trello", "backlog"], size: { w: 680, h: 420 }, - defaults: { columns: 3, cards: 3, avatars: true }, + defaults: { names: "To do, Doing, Done, Nope", columns: 3, cards: 3, avatars: true }, controls: [ + { key: "names", label: "Column names (comma-sep)", type: "text" }, { key: "columns", label: "Columns", type: "number", min: 2, max: 4, quick: true }, { key: "cards", label: "Cards", type: "number", min: 1, max: 4, quick: true }, { key: "avatars", label: "Avatars", type: "toggle" }, @@ -608,7 +624,7 @@ export const kanbanBoardDef: ComponentDef = { const gap = 14 const cols = clamp(num(p, "columns", 3), 2, 4) const colW = (w - gap * (cols - 1)) / cols - const names = ["To do", "Doing", "Done", "Nope"] + const names = list(p, "names", "To do, Doing, Done, Nope") const tags = ["bug", "copy", "design", "chore", "spike", "ugh"] const headH = 30 const bodyY = headH + 4 @@ -619,7 +635,7 @@ export const kanbanBoardDef: ComponentDef = { for (let c = 0; c < cols; c++) { const x = c * (colW + gap) - prims.push(text(x + 2, 18, truncate(names[c], 14, colW - 40), 14, { bold: true })) + prims.push(text(x + 2, 18, truncate(names[c] ?? "Column", 14, colW - 40), 14, { bold: true })) prims.push(rect(x, bodyY, colW, bodyH, { stroke: "faint", dashed: true })) let y = bodyY + 10 let drawn = 0 @@ -757,8 +773,9 @@ export const fileBrowserDef: ComponentDef = { group: "App", keywords: ["files", "folders", "drive", "documents", "grid", "explorer"], size: { w: 640, h: 400 }, - defaults: { layout: "grid", items: 8, toolbar: true }, + defaults: { path: "Home, Projects, Doodles", layout: "grid", items: 8, toolbar: true }, controls: [ + { key: "path", label: "Path (comma-sep)", type: "text" }, { key: "layout", label: "Layout", type: "select", options: ["grid", "list"], quick: true }, { key: "items", label: "Items", type: "number", min: 3, max: 12, quick: true }, { key: "toolbar", label: "Toolbar", type: "toggle", quick: true }, @@ -770,7 +787,10 @@ export const fileBrowserDef: ComponentDef = { const isGrid = str(p, "layout", "grid") === "grid" let y = pad if (bool(p, "toolbar")) { - prims.push(...sub(breadcrumbDef, { items: "Home, Projects, Doodles" }, pad, y, Math.min(cw * 0.55, 240), 24)) + // Through list() first: an emptied field has to fall back to this def's + // path, not to the breadcrumb's own "Home, Library, Data". + const crumbs = list(p, "path", "Home, Projects, Doodles").join(", ") + prims.push(...sub(breadcrumbDef, { items: crumbs }, pad, y, Math.min(cw * 0.55, 240), 24)) let rx = w - pad if (cw > 380) { prims.push(...sub(buttonDef, { label: "New", variant: "filled", size: "sm", icon: "left" }, rx - 84, y - 3, 84, 30)) @@ -924,11 +944,12 @@ export const onboardingChecklistDef: ComponentDef = { group: "App", keywords: ["setup", "getting started", "steps", "progress", "todo"], size: { w: 380, h: 310 }, - defaults: { steps: 4, active: 3, progress: true }, + defaults: { steps: 4, active: 3, progress: true, title: "Almost a real account" }, controls: [ { key: "steps", label: "Steps", type: "number", min: 3, max: 5, quick: true }, { key: "active", label: "Current step", type: "number", min: 1, max: 5, quick: true }, { key: "progress", label: "Progress bar", type: "toggle" }, + { key: "title", label: "Title", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] @@ -937,8 +958,8 @@ export const onboardingChecklistDef: ComponentDef = { const n = clamp(num(p, "steps", 4), 3, 5) const active = clamp(num(p, "active", 3), 1, n) - 1 let y = pad + 16 - prims.push(text(pad, y, truncate("Almost a real account", 16, cw - 70), 16, { bold: true })) - prims.push(text(w - pad, y, `${active} of ${n}`, 12, { align: "right", color: "muted" })) + prims.push(text(pad, y, truncate(str(p, "title", "Almost a real account"), 16, cw - 70), 16, { bold: true })) + prims.push(text(w - pad, y, `${active} of ${n} done`, 12, { align: "right", color: "muted" })) y += 14 if (bool(p, "progress")) { prims.push(...sub(progressDef, { value: Math.round((active / n) * 100) }, pad, y, cw, 10)) @@ -1151,18 +1172,20 @@ export const commandPaletteBlockDef: ComponentDef = { group: "App", keywords: ["cmdk", "palette", "quick actions", "spotlight", "shortcut"], size: { w: 460, h: 340 }, - defaults: { rows: 5, hints: true, groups: true }, + defaults: { rows: 5, hints: true, groups: true, query: "Type a thing…", selected: 1 }, controls: [ { key: "rows", label: "Rows", type: "number", min: 3, max: 7, quick: true }, { key: "hints", label: "Key hints", type: "toggle", quick: true }, + { key: "selected", label: "Selected row", type: "number", min: 1, max: 7 }, { key: "groups", label: "Group labels", type: "toggle" }, + { key: "query", label: "Query", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h, { fill: "solid", fillColor: "paper" }), rect(0, 0, w, h)] const pad = 14 const searchH = Math.min(52, h * 0.18) prims.push(...icon("magnifying-glass", 26, searchH / 2, 16, { stroke: "muted" })) - prims.push(text(48, searchH / 2 + 6, truncate("Type a thing…", 15, w - 110), 15, { color: "muted" })) + prims.push(text(48, searchH / 2 + 6, truncate(str(p, "query", "Type a thing…"), 15, w - 110), 15, { color: "muted" })) if (w > 260) prims.push(...kbd(w - 48, searchH / 2, "esc")) prims.push(hair(0, searchH, w)) @@ -1171,6 +1194,7 @@ export const commandPaletteBlockDef: ComponentDef = { const hints = ["Ctrl N", "/", "Ctrl I", "Ctrl D", "Ctrl E", "Ctrl ,", ""] const showGroups = bool(p, "groups") const n = clamp(num(p, "rows", 5), 1, 7) + const sel = clamp(num(p, "selected", 1), 1, n) - 1 const groupAt = showGroups ? Math.min(3, n) : -1 const groupCount = showGroups ? (n > 3 ? 2 : 1) : 0 const avail = h - searchH - pad @@ -1182,11 +1206,11 @@ export const commandPaletteBlockDef: ComponentDef = { y += 22 } if (y + rowH > h - 4) break - if (i === 0) prims.push(rect(6, y, w - 12, rowH - 2, { fill: "shade", fillColor: "faint", stroke: "faint", strokeWidth: 0.8 })) + if (i === sel) prims.push(rect(6, y, w - 12, rowH - 2, { fill: "shade", fillColor: "faint", stroke: "faint", strokeWidth: 0.8 })) const cy = y + (rowH - 2) / 2 - prims.push(...icon(names[i], 26, cy, 15, { stroke: i === 0 ? "ink" : "muted" })) + prims.push(...icon(names[i], 26, cy, 15, { stroke: i === sel ? "ink" : "muted" })) const hw = bool(p, "hints") && hints[i] ? kbdW(hints[i]) + 16 : 0 - prims.push(text(48, cy + 5, truncate(labels[i], 14, w - 60 - hw), 14, { bold: i === 0 })) + prims.push(text(48, cy + 5, truncate(labels[i], 14, w - 60 - hw), 14, { bold: i === sel })) if (hw && w > 240) prims.push(...kbd(w - pad - kbdW(hints[i]), cy, hints[i])) y += rowH } @@ -1256,8 +1280,9 @@ export const aiChatDef: ComponentDef = { group: "AI", keywords: ["assistant", "conversation", "bubbles", "chatbot", "thread"], size: { w: 460, h: 440 }, - defaults: { turns: 2, typing: true, composer: true }, + defaults: { turns: 2, typing: true, composer: true, placeholder: "Ask anything, even the dumb one" }, controls: [ + { key: "placeholder", label: "Placeholder", type: "text" }, { key: "turns", label: "Turns", type: "number", min: 1, max: 3, quick: true }, { key: "typing", label: "Typing dots", type: "toggle", quick: true }, { key: "composer", label: "Composer", type: "toggle", quick: true }, @@ -1306,7 +1331,7 @@ export const aiChatDef: ComponentDef = { const cy0 = h - pad - 48 prims.push(rect(pad, cy0, cw, 48)) prims.push(...icon("paperclip", pad + 22, cy0 + 24, 15, { stroke: "muted" })) - prims.push(text(pad + 40, cy0 + 29, truncate("Ask anything, even the dumb one", 14, cw - 96), 14, { color: "muted" })) + prims.push(text(pad + 40, cy0 + 29, truncate(str(p, "placeholder", "Ask anything, even the dumb one"), 14, cw - 96), 14, { color: "muted" })) prims.push(ellipse(pad + cw - 42, cy0 + 8, 32, 32, { fill: "shade", fillColor: "ink" })) prims.push(...icon("paper-plane-tilt", pad + cw - 26, cy0 + 24, 15)) } @@ -1323,14 +1348,23 @@ export const aiPromptSuggestionsDef: ComponentDef = { group: "AI", keywords: ["prompts", "starters", "suggestions", "chips", "ideas"], size: { w: 520, h: 120 }, - defaults: { count: 3, style: "card" }, + defaults: { + count: 3, + style: "card", + prompts: "Explain this like I'm five, Make it 30% funnier, Write the boring parts, Find the bug I made", + }, controls: [ + { key: "prompts", label: "Prompts (comma-sep)", type: "text" }, { key: "count", label: "Suggestions", type: "number", min: 2, max: 4, quick: true }, { key: "style", label: "Style", type: "select", options: ["card", "pill"], quick: true }, ], render(p, w, h) { const prims: Prim[] = [] - const copy = ["Explain this like I'm five", "Make it 30% funnier", "Write the boring parts", "Find the bug I made"] + const copy = list( + p, + "prompts", + "Explain this like I'm five, Make it 30% funnier, Write the boring parts, Find the bug I made", + ) const names = ["graduation-cap", "smiley", "pencil-simple", "bug"] const n = clamp(num(p, "count", 3), 2, 4) @@ -1340,7 +1374,7 @@ export const aiPromptSuggestionsDef: ComponentDef = { let x = 0 let y = 0 for (let i = 0; i < n; i++) { - const label = truncate(copy[i], 13, w - 60) + const label = truncate(pick(copy, i), 13, w - 60) const pw = Math.min(w, textWidth(label, 13) + 46) if (x + pw > w && x > 0) { x = 0 @@ -1364,7 +1398,7 @@ export const aiPromptSuggestionsDef: ComponentDef = { const iy = Math.min(24, ch * 0.26) prims.push(...icon(names[i], x + 22, iy, Math.min(16, ch * 0.2), { stroke: "muted" })) const ty = Math.min(54, iy + 22) - const lines = wrap(copy[i], 13, cw - 28, ch - ty > 22 ? 2 : 1) + const lines = wrap(pick(copy, i), 13, cw - 28, ch - ty > 22 ? 2 : 1) lines.forEach((l, li) => prims.push(text(x + 14, ty + li * 17, l, 13))) if (cw > 110 && ch > 76) prims.push(...icon("arrow-right", x + cw - 20, ch - 16, 12, { stroke: "faint" })) } @@ -1381,8 +1415,9 @@ export const aiResponseDef: ComponentDef = { group: "AI", keywords: ["assistant", "message", "answer", "code", "reply"], size: { w: 460, h: 320 }, - defaults: { lines: 3, code: true, actions: true }, + defaults: { name: "Squig", lines: 3, code: true, actions: true }, controls: [ + { key: "name", label: "Assistant name", type: "text" }, { key: "lines", label: "Body lines", type: "number", min: 2, max: 5, quick: true }, { key: "code", label: "Code block", type: "toggle", quick: true }, { key: "actions", label: "Actions", type: "toggle", quick: true }, @@ -1391,8 +1426,12 @@ export const aiResponseDef: ComponentDef = { const prims: Prim[] = [] const markD = 28 prims.push(...aiMark(0, 0, markD)) - prims.push(text(markD + 12, 19, "Squig", 14, { bold: true })) - prims.push(text(markD + 12 + textWidth("Squig", 14) + 10, 19, "just now", 11, { color: "muted" })) + const stamp = "just now" + const nameX = markD + 12 + const nameMaxW = Math.max(24, w - nameX - textWidth(stamp, 11) - 20) + const who = truncate(str(p, "name", "Squig"), 14, nameMaxW) + prims.push(text(nameX, 19, who, 14, { bold: true })) + prims.push(text(nameX + textWidth(who, 14) + 10, 19, stamp, 11, { color: "muted" })) const actions = bool(p, "actions") let y = markD + 18 @@ -1481,25 +1520,31 @@ export const aiThinkingDef: ComponentDef = { group: "AI", keywords: ["reasoning", "steps", "spinner", "loading", "chain"], size: { w: 400, h: 200 }, - defaults: { state: "steps", steps: 3 }, + defaults: { state: "steps", steps: 3, done: false }, controls: [ { key: "state", label: "State", type: "select", options: ["steps", "collapsed"], quick: true }, { key: "steps", label: "Steps", type: "number", min: 2, max: 5, quick: true }, + { key: "done", label: "Finished", type: "toggle", quick: true }, ], render(p, w, h) { const collapsed = str(p, "state", "steps") === "collapsed" + const done = bool(p, "done") const headH = collapsed ? Math.min(h, 46) : Math.min(44, h * 0.3) const prims: Prim[] = [rect(0, 0, w, collapsed ? headH : h)] const cy = headH / 2 - // a spinner: three-quarter arc drawn as a polyline - const r = 8 - const arc: [number, number][] = [] - for (let a = -90; a <= 180; a += 30) { - const rad = (a * Math.PI) / 180 - arc.push([22 + Math.cos(rad) * r, cy + Math.sin(rad) * r]) - } - prims.push(poly(arc, false, { stroke: "muted", strokeWidth: 1.6 })) - prims.push(text(42, cy + 5, "Thinking about it…", 14)) + if (done) { + prims.push(...icon("check", 22, cy, 14, { stroke: "muted" })) + } else { + // a spinner: three-quarter arc drawn as a polyline + const r = 8 + const arc: [number, number][] = [] + for (let a = -90; a <= 180; a += 30) { + const rad = (a * Math.PI) / 180 + arc.push([22 + Math.cos(rad) * r, cy + Math.sin(rad) * r]) + } + prims.push(poly(arc, false, { stroke: "muted", strokeWidth: 1.6 })) + } + prims.push(text(42, cy + 5, done ? "Thought about it" : "Thinking about it…", 14)) prims.push(text(w - 44, cy + 5, "4.2s", 11, { align: "right", color: "muted" })) prims.push(...icon(collapsed ? "caret-right" : "caret-down", w - 20, cy, 12, { stroke: "muted" })) if (collapsed) return prims @@ -1518,7 +1563,7 @@ export const aiThinkingDef: ComponentDef = { for (let i = 0; i < n; i++) { const ry = headH + 8 + i * rowH const rcy = ry + rowH / 2 - const current = i === n - 1 + const current = !done && i === n - 1 if (current) prims.push(ellipse(16, rcy - 6, 12, 12, { stroke: "muted" })) else prims.push(...icon("check", 22, rcy, 12, { stroke: "muted" })) if (i < n - 1) prims.push(line(22, rcy + 8, 22, rcy + rowH - 8, { stroke: "faint" })) @@ -1544,7 +1589,7 @@ export const orderSummaryDef: ComponentDef = { defaults: { items: 3, title: true, frame: true }, controls: [ { key: "items", label: "Line items", type: "number", min: 1, max: 4, quick: true }, - { key: "title", label: "Title", type: "toggle", quick: true }, + { key: "title", label: "Header", type: "toggle", quick: true }, { key: "frame", label: "Frame", type: "toggle" }, ], render(p, w, h) { @@ -1607,7 +1652,7 @@ export const cartDef: ComponentDef = { size: { w: 560, h: 430 }, defaults: { items: 3, totals: true, cta: true }, controls: [ - { key: "items", label: "Items", type: "number", min: 1, max: 4, quick: true }, + { key: "items", label: "Items", type: "number", min: 0, max: 4, quick: true }, { key: "totals", label: "Totals", type: "toggle", quick: true }, { key: "cta", label: "Checkout button", type: "toggle", quick: true }, ], @@ -1624,7 +1669,8 @@ export const cartDef: ComponentDef = { const avail = h - (pad + 38) - pad - footH // 58 is the shortest row that still fits the thumbnail and both lines of // text — at 66 the fourth item had nowhere to go at the default height. - const n = clamp(Math.min(num(p, "items", 3), Math.floor(avail / 58)), 0, 4) + const want = clamp(num(p, "items", 3), 0, 4) + const n = clamp(Math.min(want, Math.floor(avail / 58)), 0, 4) const rowH = n > 0 ? Math.min(84, avail / n) : 0 const showStepper = w > 420 @@ -1653,6 +1699,21 @@ export const cartDef: ComponentDef = { prims.push(...icon("x", rightEdge + 12, cy, 12, { stroke: "faint" })) } + // Only when the cart is actually empty. A cart squeezed too short for one + // row drops to n === 0 as well, and that already drew nothing — an old node + // shouldn't sprout an empty state just because it got resized. + if (want === 0 && avail > 56) { + const bandH = avail - 6 + prims.push(rect(pad, y, cw, bandH, { fill: "shade", fillColor: "faint" })) + prims.push(...icon("shopping-cart", w / 2, y + bandH / 2 - 12, 24, { stroke: "faint" })) + prims.push( + text(w / 2, y + bandH / 2 + 20, truncate("Nothing in here yet", 14, cw - 32), 14, { + align: "center", + color: "muted", + }), + ) + } + const fy = h - pad - footH + 10 if (totals) { const subtotal = prices.slice(0, n).reduce((s, v) => s + v, 0) @@ -1678,8 +1739,9 @@ export const checkoutDef: ComponentDef = { group: "Commerce", keywords: ["payment", "pay", "card", "billing", "order"], size: { w: 720, h: 520 }, - defaults: { summary: true, express: true, items: 3 }, + defaults: { title: "Checkout", summary: true, express: true, items: 3 }, controls: [ + { key: "title", label: "Title", type: "text" }, { key: "summary", label: "Order summary", type: "toggle", quick: true }, { key: "items", label: "Summary items", type: "number", min: 1, max: 4, quick: true }, { key: "express", label: "Express pay", type: "toggle", quick: true }, @@ -1693,7 +1755,7 @@ export const checkoutDef: ComponentDef = { const sumW = withSummary ? clamp(w * 0.36, 220, 280) : 0 const cw = w - pad * 2 - (sumW ? sumW + 24 : 0) let y = pad + 18 - prims.push(text(pad, y, "Checkout", 20, { bold: true })) + prims.push(text(pad, y, truncate(str(p, "title", "Checkout"), 20, cw), 20, { bold: true })) y += 18 if (bool(p, "express") && cw > 240) { @@ -1836,11 +1898,12 @@ export const productGridDef: ComponentDef = { group: "Commerce", keywords: ["catalog", "shop", "listing", "cards", "store"], size: { w: 660, h: 440 }, - defaults: { cols: 3, rows: 2, price: true }, + defaults: { cols: 3, rows: 2, price: true, rating: true }, controls: [ { key: "cols", label: "Columns", type: "number", min: 2, max: 4, quick: true }, { key: "rows", label: "Rows", type: "number", min: 1, max: 3, quick: true }, { key: "price", label: "Prices", type: "toggle", quick: true }, + { key: "rating", label: "Rating", type: "toggle" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1876,7 +1939,7 @@ export const productGridDef: ComponentDef = { prims.push(...icon("image", x + cw / 2, y + imgH / 2, Math.min(36, imgH * 0.34), { stroke: "faint" })) if (imgH + 28 < ch) prims.push(text(x + 12, y + imgH + 22, truncate(names[i % names.length], 13, cw - 24), 13)) if (showPrice && imgH + 50 < ch) prims.push(text(x + 12, y + imgH + 42, prices[i % prices.length], 15, { bold: true })) - if (ch - imgH > 60) prims.push(...starRow(x + cw - 74, y + imgH + 38, 10, 5)) + if (boolOn(p, "rating") && ch - imgH > 60) prims.push(...starRow(x + cw - 74, y + imgH + 38, 10, 5)) } } return prims @@ -1892,11 +1955,13 @@ export const paymentMethodsDef: ComponentDef = { group: "Commerce", keywords: ["cards", "wallet", "saved cards", "billing", "default"], size: { w: 480, h: 280 }, - defaults: { cards: 2, addRow: true, header: true }, + defaults: { cards: 2, addRow: true, header: true, title: "Payment methods", defaultCard: 1 }, controls: [ { key: "cards", label: "Cards", type: "number", min: 1, max: 4, quick: true }, { key: "addRow", label: "Add row", type: "toggle", quick: true }, { key: "header", label: "Header", type: "toggle" }, + { key: "defaultCard", label: "Default card", type: "number", min: 1, max: 4 }, + { key: "title", label: "Title", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] @@ -1904,7 +1969,7 @@ export const paymentMethodsDef: ComponentDef = { const cw = w - pad * 2 let y = pad if (bool(p, "header")) { - prims.push(text(pad, y + 14, "Payment methods", 16, { bold: true })) + prims.push(text(pad, y + 14, truncate(str(p, "title", "Payment methods"), 16, cw), 16, { bold: true })) y += 28 } const brands = ["Visa ···· 4242", "Mastercard ···· 8210", "Amex ···· 0031", "Visa ···· 7749"] @@ -1919,13 +1984,14 @@ export const paymentMethodsDef: ComponentDef = { const roomy = Math.floor(avail / 58) const n = want <= roomy ? want : clamp(Math.min(want, Math.floor(avail / 38)), 1, 4) const rowH = Math.min(70, avail / Math.max(1, n)) + const dflt = clamp(num(p, "defaultCard", 1), 1, n) - 1 for (let i = 0; i < n; i++) { const ry = y + i * rowH const bh = Math.min(58, rowH - 8) const cy = ry + bh / 2 // Below this the expiry baseline would sit on the card's bottom edge. const stacked = bh >= 44 - prims.push(rect(pad, ry, cw, bh, i === 0 ? { strokeWidth: 1.6 } : { stroke: "muted" })) + prims.push(rect(pad, ry, cw, bh, i === dflt ? { strokeWidth: 1.6 } : { stroke: "muted" })) prims.push(...icon("credit-card", pad + 28, cy, 19, { stroke: "muted" })) const tx = pad + 52 const tw = cw - 52 - (w > 380 ? 110 : 30) @@ -1940,7 +2006,7 @@ export const paymentMethodsDef: ComponentDef = { prims.push(text(tx + bw + 12, cy + 4, expiries[i], 11, { color: "muted" })) } } - if (i === 0 && w > 380) prims.push(...sub(badgeDef, { label: "Default", variant: "outline" }, pad + cw - 96, cy - 11, 66, 22)) + if (i === dflt && w > 380) prims.push(...sub(badgeDef, { label: "Default", variant: "outline" }, pad + cw - 96, cy - 11, 66, 22)) prims.push(...icon("dots-three", pad + cw - 18, cy, 15, { stroke: "muted" })) } if (addRow) { @@ -1966,14 +2032,17 @@ export const appShellDef: ComponentDef = { group: "Screens", keywords: ["layout", "sidebar", "topbar", "frame", "skeleton", "admin"], size: { w: 1000, h: 660 }, - defaults: { sidebar: true, topbar: true, placeholder: true }, + defaults: { title: "Projects", subtitle: "Three of them are the same project.", sidebar: true, topbar: true, placeholder: true }, controls: [ { key: "sidebar", label: "Sidebar", type: "toggle", quick: true }, { key: "topbar", label: "Topbar", type: "toggle", quick: true }, { key: "placeholder", label: "Placeholder", type: "toggle", quick: true }, + { key: "title", label: "Title", type: "text" }, + { key: "subtitle", label: "Subtitle", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] + const title = str(p, "title", "Projects") const sw = bool(p, "sidebar") ? clamp(w * 0.22, 160, 230) : 0 const th = bool(p, "topbar") ? 58 : 0 if (sw) { @@ -1981,7 +2050,7 @@ export const appShellDef: ComponentDef = { } if (th) { prims.push(rect(sw, 0, w - sw, th)) - prims.push(...sub(breadcrumbDef, { items: "Workspace, Projects" }, sw + 20, th / 2 - 12, Math.min(240, (w - sw) * 0.4), 24)) + prims.push(...sub(breadcrumbDef, { items: `Workspace, ${title}` }, sw + 20, th / 2 - 12, Math.min(240, (w - sw) * 0.4), 24)) let rx = w - 18 prims.push(...sub(avatarDef, { content: "initials", initials: "PS" }, rx - 32, th / 2 - 16, 32, 32)) rx -= 46 @@ -1997,13 +2066,17 @@ export const appShellDef: ComponentDef = { const cx = sw + pad const cw = w - sw - pad * 2 let y = th + pad - prims.push(text(cx, y + 16, "Projects", 22, { bold: true })) + prims.push(text(cx, y + 16, truncate(title, 22, Math.max(20, cw - 160)), 22, { bold: true })) prims.push(...sub(buttonDef, { label: "New project", variant: "filled", size: "sm", icon: "left" }, cx + cw - 148, y - 2, 148, 36)) y += 46 - prims.push(text(cx, y, "Three of them are the same project.", 12, { color: "muted" })) - y += 20 + const subtitle = str(p, "subtitle", "Three of them are the same project.") + if (subtitle) { + prims.push(text(cx, y, truncate(subtitle, 12, cw), 12, { color: "muted" })) + y += 20 + } if (bool(p, "placeholder") && h - y - pad > 90) { - prims.push(...sub(emptyBlockDef, { title: "No projects yet", icon: "folder", cta: true }, cx, y, cw, h - y - pad)) + const emptyTitle = title.trim() ? `No ${title.trim().toLowerCase()} yet` : "Nothing here yet" + prims.push(...sub(emptyBlockDef, { title: emptyTitle, icon: "folder", cta: true }, cx, y, cw, h - y - pad)) } return prims }, @@ -2018,21 +2091,24 @@ export const landingPageDef: ComponentDef = { group: "Screens", keywords: ["marketing", "hero", "features", "website", "home", "site"], size: { w: 1000, h: 830 }, - defaults: { navbar: true, hero: "split", features: 3, footer: true }, + defaults: { navbar: true, hero: "split", features: 3, footer: true, headline: "Wireframes that look like you meant it", brand: "" }, controls: [ { key: "navbar", label: "Navbar", type: "toggle" }, { key: "hero", label: "Hero", type: "select", options: ["split", "center"], quick: true }, { key: "features", label: "Features", type: "number", min: 2, max: 4, quick: true }, { key: "footer", label: "Footer", type: "toggle", quick: true }, + { key: "headline", label: "Headline", type: "text" }, + { key: "brand", label: "Product name", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] const pad = clamp(w * 0.06, 24, 72) const cw = w - pad * 2 + const headline = str(p, "headline", "Wireframes that look like you meant it") // Old nodes predate the toggle and have no `navbar` key — they keep their navbar. const navH = p.navbar === false ? 0 : Math.min(64, h * 0.09) if (navH) { - prims.push(...sub(navbarDef, { links: "Product, Pricing, Docs, Blog", search: false, avatar: false, cta: true }, 0, 0, w, navH)) + prims.push(...sub(navbarDef, { brand: str(p, "brand"), links: "Product, Pricing, Docs, Blog", search: false, avatar: false, cta: true }, 0, 0, w, navH)) } const footerH = bool(p, "footer") ? Math.min(150, h * 0.19) : 0 @@ -2044,7 +2120,7 @@ export const landingPageDef: ComponentDef = { let y = navH + Math.min(48, heroH * 0.16) if (str(p, "hero", "split") === "split" && cw > 520) { const colW = cw * 0.48 - for (const l of wrap("Wireframes that look like you meant it", 34, colW, 3)) { + for (const l of wrap(headline, 34, colW, 3)) { prims.push(text(pad, y, l, 34, { bold: true })) y += 40 } @@ -2059,7 +2135,7 @@ export const landingPageDef: ComponentDef = { prims.push(rect(ix, navH + Math.min(48, heroH * 0.16) - 8, iw, ih, { fill: "shade", fillColor: "faint" })) prims.push(...icon("image", ix + iw / 2, navH + Math.min(48, heroH * 0.16) - 8 + ih / 2, 54, { stroke: "faint" })) } else { - for (const l of wrap("Wireframes that look like you meant it", 32, cw * 0.72, 2)) { + for (const l of wrap(headline, 32, cw * 0.72, 2)) { prims.push(text(w / 2, y, l, 32, { align: "center", bold: true })) y += 38 } @@ -2102,10 +2178,12 @@ export const landingPageDef: ComponentDef = { const foy = h - footerH prims.push(hair(0, foy, w)) prims.push(...icon("logo", pad + 12, foy + 34, 22)) - prims.push(text(pad + 32, foy + 40, "squig", 16, { bold: true })) - prims.push(text(pad, foy + footerH - 20, "© a person who draws boxes", 11, { color: "muted" })) const cols = 4 const fw = Math.min(120, (cw - 160) / cols) + const brand = str(p, "brand").trim() || "squig" + const brandMaxW = Math.max(40, w - pad - cols * fw - 20 - (pad + 32)) + prims.push(text(pad + 32, foy + 40, truncate(brand, 16, brandMaxW), 16, { bold: true })) + prims.push(text(pad, foy + footerH - 20, "© a person who draws boxes", 11, { color: "muted" })) for (let c = 0; c < cols; c++) { const x = w - pad - (cols - c) * fw if (x < pad + 140) continue @@ -2130,21 +2208,23 @@ export const chatScreenDef: ComponentDef = { group: "Screens", keywords: ["messages", "dm", "conversation", "messenger", "slack"], size: { w: 1000, h: 660 }, - defaults: { convos: 6, sidebar: true, composer: true }, + defaults: { convos: 6, sidebar: true, composer: true, who: "Maya" }, controls: [ { key: "convos", label: "Conversations", type: "number", min: 3, max: 8, quick: true }, { key: "sidebar", label: "List pane", type: "toggle", quick: true }, { key: "composer", label: "Composer", type: "toggle", quick: true }, + { key: "who", label: "Talking to", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] + const who = str(p, "who", "Maya") const lw = bool(p, "sidebar") ? clamp(w * 0.29, 200, 310) : 0 if (lw) { prims.push(line(lw, 0, lw, h, { stroke: "faint" })) prims.push(text(18, 34, "Messages", 18, { bold: true })) prims.push(...icon("pencil-simple", lw - 24, 28, 16, { stroke: "muted" })) prims.push(...sub(inputDef, { showLabel: false, icon: "search", placeholder: "Search people" }, 16, 50, lw - 32, 34)) - const names = ["Maya", "Luis", "Ana", "Kai", "Design crew", "Mom", "Robot", "Nobody"] + const names = [who, "Luis", "Ana", "Kai", "Design crew", "Mom", "Robot", "Nobody"] const previews = [ "ok but hear me out…", "merged, finally", @@ -2177,8 +2257,8 @@ export const chatScreenDef: ComponentDef = { const cw = w - lw const headH = 62 prims.push(hair(cx, headH, cw)) - prims.push(...sub(avatarDef, { content: "initials", initials: "MA", status: true }, cx + 18, headH / 2 - 18, 36, 36)) - prims.push(text(cx + 66, headH / 2 - 1, "Maya", 16, { bold: true })) + prims.push(...sub(avatarDef, { content: "initials", initials: who.slice(0, 2).toUpperCase(), status: true }, cx + 18, headH / 2 - 18, 36, 36)) + prims.push(text(cx + 66, headH / 2 - 1, truncate(who, 16, cw - 170), 16, { bold: true })) prims.push(text(cx + 66, headH / 2 + 16, "typing, allegedly", 11, { color: "muted" })) prims.push(...icon("phone", cx + cw - 88, headH / 2, 16, { stroke: "muted" })) prims.push(...icon("video-camera", cx + cw - 56, headH / 2, 16, { stroke: "muted" })) @@ -2197,7 +2277,7 @@ export const chatScreenDef: ComponentDef = { const mine = i % 2 === 1 const bx = mine ? cx + cw - pad - bw : cx + pad + 44 if (!mine) { - prims.push(...sub(avatarDef, { content: "initials", initials: "MA" }, cx + pad, y + bh - 32, 32, 32)) + prims.push(...sub(avatarDef, { content: "initials", initials: who.slice(0, 2).toUpperCase() }, cx + pad, y + bh - 32, 32, 32)) } if (mine) prims.push(rect(bx, y, bw, bh, { fill: "shade", fillColor: "faint", stroke: "faint", strokeWidth: 0.8 })) prims.push(rect(bx, y, bw, bh)) @@ -2227,11 +2307,12 @@ export const inboxScreenDef: ComponentDef = { group: "Screens", keywords: ["mail", "email", "reading pane", "three column", "client"], size: { w: 1040, h: 680 }, - defaults: { rows: 6, pane: true, folders: true }, + defaults: { rows: 6, pane: true, folders: true, subject: "Re: the thing we said we'd do" }, controls: [ { key: "rows", label: "Messages", type: "number", min: 3, max: 8, quick: true }, { key: "pane", label: "Reading pane", type: "toggle", quick: true }, { key: "folders", label: "Folder rail", type: "toggle", quick: true }, + { key: "subject", label: "Subject", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [rect(0, 0, w, h)] @@ -2275,7 +2356,7 @@ export const inboxScreenDef: ComponentDef = { prims.push(...icon("arrow-u-up-left", rx + rw - 96, 30, 16, { stroke: "muted" })) prims.push(...icon("trash", rx + rw - 62, 30, 16, { stroke: "muted" })) prims.push(...icon("dots-three", rx + rw - 28, 30, 16, { stroke: "muted" })) - for (const l of wrap("Re: the thing we said we'd do", 20, rw - pad * 2 - 120, 2)) { + for (const l of wrap(str(p, "subject", "Re: the thing we said we'd do"), 20, rw - pad * 2 - 120, 2)) { prims.push(text(rx + pad, y, l, 20, { bold: true })) y += 26 } @@ -2307,8 +2388,9 @@ export const profileScreenDef: ComponentDef = { group: "Screens", keywords: ["profile", "user page", "portfolio", "tabs", "grid"], size: { w: 960, h: 720 }, - defaults: { cards: 6, tabs: true, navbar: true }, + defaults: { name: "Pablo Scribbles", cards: 6, tabs: true, navbar: true }, controls: [ + { key: "name", label: "Name", type: "text" }, { key: "cards", label: "Cards", type: "number", min: 2, max: 9, quick: true }, { key: "tabs", label: "Tabs", type: "toggle", quick: true }, { key: "navbar", label: "Navbar", type: "toggle" }, @@ -2321,7 +2403,7 @@ export const profileScreenDef: ComponentDef = { } const pad = clamp(w * 0.04, 20, 40) const headerH = clamp(h * 0.32, 180, 250) - prims.push(...sub(profileHeaderDef, { cover: true, stats: true, cta: true }, pad, navH + 18, w - pad * 2, headerH)) + prims.push(...sub(profileHeaderDef, { name: str(p, "name", "Pablo Scribbles"), cover: true, stats: true, cta: true }, pad, navH + 18, w - pad * 2, headerH)) let y = navH + 18 + headerH + 18 if (bool(p, "tabs") && y + 48 < h) { prims.push(...sub(tabsDef, { labels: "Boards, Liked, About", active: 1 }, pad, y, Math.min(360, w - pad * 2), 40)) diff --git a/lib/library/defs-blocks-marketing.ts b/lib/library/defs-blocks-marketing.ts index 377189d..ddb3633 100644 --- a/lib/library/defs-blocks-marketing.ts +++ b/lib/library/defs-blocks-marketing.ts @@ -15,6 +15,13 @@ import { imageDef, breadcrumbDef } from "./defs-display" const str = (p: Props, k: string, fallback = ""): string => String(p[k] ?? fallback) const bool = (p: Props, k: string): boolean => Boolean(p[k]) const num = (p: Props, k: string, fallback = 0): number => Number(p[k] ?? fallback) +const list = (p: Props, k: string, fallback: string): string[] => { + const out = str(p, k, fallback) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + return out.length ? out : fallback.split(",").map((s) => s.trim()) +} const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v)) const int = (p: Props, k: string, fallback: number, lo: number, hi: number): number => clamp(Math.round(num(p, k, fallback)), lo, hi) @@ -185,6 +192,7 @@ export const heroDef: ComponentDef = { defaults: { layout: "centered", eyebrow: true, + eyebrowText: "now in open beta", secondCta: true, headline: "Draw it badly, ship it anyway", subline: "A wireframe tool that never pretends to be the final design.", @@ -199,6 +207,7 @@ export const heroDef: ComponentDef = { { key: "subline", label: "Subhead", type: "text" }, { key: "cta", label: "Button label", type: "text" }, { key: "cta2", label: "Second button label", type: "text" }, + { key: "eyebrowText", label: "Eyebrow text", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -210,6 +219,7 @@ export const heroDef: ComponentDef = { const second = bool(p, "secondCta") const l1 = str(p, "cta", "Start scribbling") const l2 = str(p, "cta2", "See examples") + const eyebrowText = str(p, "eyebrowText", "now in open beta").trim() if (layout === "centered") { const colW = clamp(w - pad * 2, 160, Math.max(200, w * 0.7)) @@ -221,8 +231,8 @@ export const heroDef: ComponentDef = { // laid out top-down leaves all its slack at the bottom and looks broken const stack: Prim[] = [] let y = 0 - if (bool(p, "eyebrow")) { - stack.push(...pillCentered("now in open beta", w / 2, y, 24)) + if (bool(p, "eyebrow") && eyebrowText) { + stack.push(...pillCentered(eyebrowText, w / 2, y, 24)) y += 36 } const head = fitHead(x0, y, colW, h - vpad * 2 - y - bh - 26, { heading, subline, align: "center", size: hs }) @@ -249,8 +259,8 @@ export const heroDef: ComponentDef = { const stack: Prim[] = [] let sy = 0 - if (bool(p, "eyebrow")) { - stack.push(...pill("now in open beta", 0, sy, 24)) + if (bool(p, "eyebrow") && eyebrowText) { + stack.push(...pill(truncate(eyebrowText, 12, Math.max(60, colW - 26)), 0, sy, 24)) sy += 34 } const head = fitHead(0, sy, colW, bodyH - sy - bh - 20, { @@ -464,7 +474,7 @@ export const featureGridDef: ComponentDef = { { key: "cols", label: "Columns", type: "number", min: 2, max: 4, quick: true }, { key: "rows", label: "Rows", type: "number", min: 1, max: 3, quick: true }, { key: "boxed", label: "Cards", type: "toggle", quick: true }, - { key: "heading", label: "Heading", type: "toggle" }, + { key: "heading", label: "Show headline", type: "toggle" }, { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { @@ -536,12 +546,13 @@ export const featureSplitDef: ComponentDef = { group: "Marketing", keywords: ["feature", "image left", "image right", "bullets", "two column"], size: { w: 800, h: 340 }, - defaults: { side: "left", bullets: 3, cta: true, headline: "Sketch first, pixel-push never" }, + defaults: { side: "left", bullets: 3, cta: true, headline: "Sketch first, pixel-push never", ctaLabel: "Show me more" }, controls: [ { key: "side", label: "Image side", type: "select", options: ["left", "right"], quick: true }, { key: "bullets", label: "Bullets", type: "number", min: 2, max: 5, quick: true }, { key: "cta", label: "Button", type: "toggle", quick: true }, { key: "headline", label: "Headline", type: "text" }, + { key: "ctaLabel", label: "Button label", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -575,7 +586,7 @@ export const featureSplitDef: ComponentDef = { } sy += rowH * n + 12 if (bool(p, "cta") && sy + bh <= bodyH) { - const label = "Show me more" + const label = str(p, "ctaLabel", "Show me more") stack.push(...sub(buttonDef, { label, variant: "outline" }, 0, sy, Math.min(btnW(label), colW), bh)) sy += bh } @@ -593,22 +604,37 @@ export const featureListDef: ComponentDef = { group: "Marketing", keywords: ["features", "rows", "alternating", "zigzag", "list"], size: { w: 740, h: 400 }, - defaults: { rows: 3, alternate: true, dividers: true }, + // `heading` is new, and renderComponent merges current defaults under a saved + // node's props — so a default of `true` would grow a headline on every list + // already sitting on somebody's canvas. Ships off; one click turns it on. + defaults: { rows: 3, alternate: true, dividers: true, heading: false, headline: "What it actually does" }, controls: [ { key: "rows", label: "Rows", type: "number", min: 2, max: 5, quick: true }, { key: "alternate", label: "Alternate sides", type: "toggle", quick: true }, { key: "dividers", label: "Dividers", type: "toggle" }, + { key: "heading", label: "Show headline", type: "toggle" }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] const n = int(p, "rows", 3, 2, 5) const alt = bool(p, "alternate") const pad = clamp(w * 0.04, 12, 32) - const rowH = (h - pad * 2) / n + let top = pad + if (bool(p, "heading")) { + const head = headBlock(pad, pad, w - pad * 2, { + heading: str(p, "headline", "What it actually does"), + align: "center", + size: clamp(w * 0.032, 16, 24), + }) + prims.push(...head.prims) + top = head.bottom + 18 + } + const rowH = (h - top - pad) / n if (rowH < 40) return prims const iconBox = clamp(Math.min(rowH * 0.6, 60), 32, 60) for (let i = 0; i < n; i++) { - const y = pad + i * rowH + const y = top + i * rowH const flip = alt && i % 2 === 1 const ix = flip ? w - pad - iconBox : pad const tx = flip ? pad : pad + iconBox + 22 @@ -639,11 +665,12 @@ export const testimonialBlockDef: ComponentDef = { group: "Marketing", keywords: ["quote", "reviews", "social proof", "customers", "praise"], size: { w: 800, h: 300 }, - defaults: { variant: "3-up", stars: true, heading: false }, + defaults: { variant: "3-up", stars: true, heading: false, headline: "People say nice things" }, controls: [ { key: "variant", label: "Variant", type: "select", options: ["single", "2-up", "3-up"], quick: true }, { key: "stars", label: "Stars", type: "toggle", quick: true }, - { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "heading", label: "Show headline", type: "toggle", quick: true }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -653,7 +680,7 @@ export const testimonialBlockDef: ComponentDef = { let top = pad if (bool(p, "heading")) { const head = headBlock(pad, pad, w - pad * 2, { - heading: "People say nice things", + heading: str(p, "headline", "People say nice things"), align: "center", size: clamp(w * 0.032, 16, 24), }) @@ -724,11 +751,12 @@ export const logoCloudDef: ComponentDef = { group: "Marketing", keywords: ["logos", "customers", "trusted by", "brands", "social proof"], size: { w: 800, h: 170 }, - defaults: { count: 5, heading: true, variant: "row" }, + defaults: { count: 5, heading: true, variant: "row", headline: "Teams that draw badly on purpose" }, controls: [ { key: "count", label: "Logos", type: "number", min: 3, max: 8, quick: true }, { key: "variant", label: "Layout", type: "select", options: ["row", "grid"], quick: true }, - { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "heading", label: "Show headline", type: "toggle", quick: true }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -737,8 +765,11 @@ export const logoCloudDef: ComponentDef = { const pad = clamp(w * 0.04, 12, 34) let top = pad if (bool(p, "heading")) { - prims.push(text(w / 2, top + 13, truncate("Teams that draw badly on purpose", 14, w - pad * 2), 14, { align: "center", color: "muted" })) - top += 30 + const head = str(p, "headline", "Teams that draw badly on purpose").trim() + if (head) { + prims.push(text(w / 2, top + 13, truncate(head, 14, w - pad * 2), 14, { align: "center", color: "muted" })) + top += 30 + } } const bodyH = h - top - pad if (bodyH < 24) return prims @@ -774,12 +805,23 @@ export const pricingBlockDef: ComponentDef = { group: "Marketing", keywords: ["plans", "tiers", "billing", "money", "subscribe"], size: { w: 840, h: 460 }, - defaults: { tiers: 3, highlight: 2, heading: true, billing: true }, + defaults: { + tiers: 3, + highlight: 2, + heading: true, + billing: true, + headline: "Pick a plan, any plan", + tierNames: "Doodle, Sketch, Masterpiece, Gallery", + prices: "$0, $12, $49, $199", + }, controls: [ { key: "tiers", label: "Tiers", type: "number", min: 2, max: 4, quick: true }, { key: "highlight", label: "Highlight tier", type: "number", min: 0, max: 4, quick: true }, - { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "heading", label: "Show headline", type: "toggle", quick: true }, { key: "billing", label: "Billing toggle", type: "toggle" }, + { key: "headline", label: "Headline", type: "text" }, + { key: "tierNames", label: "Tier names (comma-sep)", type: "text" }, + { key: "prices", label: "Prices (comma-sep)", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -792,7 +834,7 @@ export const pricingBlockDef: ComponentDef = { if (bool(p, "heading")) { const head = headBlock(pad, top, w - pad * 2, { eyebrow: "no hidden fees, promise", - heading: "Pick a plan, any plan", + heading: str(p, "headline", "Pick a plan, any plan"), align: "center", size: clamp(w * 0.032, 16, 26), }) @@ -810,8 +852,8 @@ export const pricingBlockDef: ComponentDef = { const gap = clamp(w * 0.02, 10, 22) const cw = (w - pad * 2 - gap * (n - 1)) / n - const names = ["Doodle", "Sketch", "Masterpiece", "Gallery"] - const prices = ["$0", "$12", "$49", "$199"] + const names = list(p, "tierNames", "Doodle, Sketch, Masterpiece, Gallery") + const prices = list(p, "prices", "$0, $12, $49, $199") for (let i = 0; i < n; i++) { const x = pad + i * (cw + gap) const isHi = i + 1 === hi @@ -824,9 +866,10 @@ export const pricingBlockDef: ComponentDef = { prims.push(text(x + cw / 2, y0 + 17, "most popular", 11, { align: "center" })) y = y0 + 48 } - prims.push(text(x + cw / 2, y, truncate(names[i], 16, cw - 16), 16, { align: "center", bold: true })) + prims.push(text(x + cw / 2, y, truncate(names[i % names.length] ?? "", 16, cw - 16), 16, { align: "center", bold: true })) y += 32 - prims.push(text(x + cw / 2, y, prices[i], clamp(cw * 0.13, 18, 28), { align: "center", bold: true })) + const ps = clamp(cw * 0.13, 18, 28) + prims.push(text(x + cw / 2, y, truncate(prices[i % prices.length] ?? "", ps, cw - 16), ps, { align: "center", bold: true })) prims.push(text(x + cw / 2, y + 16, "per month-ish", 11, { align: "center", color: "muted" })) y += 34 const btnH = clamp(bodyH * 0.09, 30, 38) @@ -870,12 +913,13 @@ export const faqDef: ComponentDef = { group: "Marketing", keywords: ["questions", "accordion", "help", "answers", "support"], size: { w: 740, h: 400 }, - defaults: { count: 5, columns: 1, heading: true, expanded: 1 }, + defaults: { count: 5, columns: 1, heading: true, expanded: 1, headline: "Questions people actually ask" }, controls: [ { key: "count", label: "Questions", type: "number", min: 2, max: 6, quick: true }, { key: "columns", label: "Columns", type: "number", min: 1, max: 2, quick: true }, - { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "heading", label: "Show headline", type: "toggle", quick: true }, { key: "expanded", label: "Expanded", type: "number", min: 0, max: 6 }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -887,7 +931,7 @@ export const faqDef: ComponentDef = { let top = pad if (bool(p, "heading")) { const head = headBlock(pad, top, w - pad * 2, { - heading: "Questions people actually ask", + heading: str(p, "headline", "Questions people actually ask"), align: cols === 1 ? "left" : "center", size: clamp(w * 0.032, 16, 24), }) @@ -920,8 +964,11 @@ export const faqDef: ComponentDef = { // -- stats band ------------------------------------------------------------- +// no commas in these — they double as the fallback for a comma-separated field const STAT_VALUES = ["12k", "99.9%", "4.9", "1.2M", "0"] -const STAT_LABELS = ["boxes drawn", "uptime, honestly", "average rating", "lines wobbled", "meetings needed"] +const STAT_LABELS = ["boxes drawn", "uptime (honestly)", "average rating", "lines wobbled", "meetings needed"] +const STAT_VALUES_S = STAT_VALUES.join(", ") +const STAT_LABELS_S = STAT_LABELS.join(", ") export const statsBandDef: ComponentDef = { kind: "stats-band", @@ -930,11 +977,13 @@ export const statsBandDef: ComponentDef = { group: "Marketing", keywords: ["metrics", "numbers", "kpi", "counters", "proof"], size: { w: 780, h: 160 }, - defaults: { count: 4, dividers: true, boxed: false }, + defaults: { count: 4, dividers: true, boxed: false, values: STAT_VALUES_S, labels: STAT_LABELS_S }, controls: [ { key: "count", label: "Stats", type: "number", min: 2, max: 5, quick: true }, { key: "dividers", label: "Dividers", type: "toggle", quick: true }, { key: "boxed", label: "Boxed", type: "toggle", quick: true }, + { key: "values", label: "Values (comma-sep)", type: "text" }, + { key: "labels", label: "Labels (comma-sep)", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -944,10 +993,14 @@ export const statsBandDef: ComponentDef = { const colW = (w - pad * 2) / n const cy = h / 2 const vs = clamp(Math.min(colW * 0.24, h * 0.3), 18, 40) + const vals = list(p, "values", STAT_VALUES_S) + const labs = list(p, "labels", STAT_LABELS_S) for (let i = 0; i < n; i++) { const cx = pad + colW * i + colW / 2 - prims.push(text(cx, cy + vs * 0.2, STAT_VALUES[i % STAT_VALUES.length], vs, { align: "center", bold: true })) - prims.push(text(cx, cy + vs * 0.2 + 22, truncate(STAT_LABELS[i % STAT_LABELS.length], 12, colW - 16), 12, { align: "center", color: "muted" })) + const v = vals[i % vals.length] ?? "" + const l = labs[i % labs.length] ?? "" + prims.push(text(cx, cy + vs * 0.2, truncate(v, vs, colW - 12), vs, { align: "center", bold: true })) + prims.push(text(cx, cy + vs * 0.2 + 22, truncate(l, 12, colW - 16), 12, { align: "center", color: "muted" })) if (bool(p, "dividers") && i > 0) { prims.push(line(pad + colW * i, cy - h * 0.22, pad + colW * i, cy + h * 0.22, { stroke: "faint" })) } @@ -970,12 +1023,14 @@ export const newsletterDef: ComponentDef = { finePrint: true, headline: "One email a month. Maybe.", subline: "Drawing tips, product notes, zero growth hacks.", + cta: "Subscribe", }, controls: [ { key: "align", label: "Align", type: "select", options: ["center", "left"], quick: true }, { key: "finePrint", label: "Fine print", type: "toggle", quick: true }, { key: "headline", label: "Headline", type: "text" }, { key: "subline", label: "Subhead", type: "text" }, + { key: "cta", label: "Button label", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -993,7 +1048,7 @@ export const newsletterDef: ComponentDef = { }) stack.push(...head.prims) let sy = head.bottom + 16 - const label = "Subscribe" + const label = str(p, "cta", "Subscribe") const bw = Math.min(btnW(label), colW * 0.4) const formW = Math.min(colW, 440) const fx = centered ? (colW - formW) / 2 : 0 @@ -1210,11 +1265,12 @@ export const teamGridDef: ComponentDef = { group: "Marketing", keywords: ["people", "about", "staff", "members", "crew"], size: { w: 800, h: 320 }, - defaults: { count: 4, heading: true, cards: false }, + defaults: { count: 4, heading: true, cards: false, headline: "Small team, big erasers" }, controls: [ { key: "count", label: "People", type: "number", min: 2, max: 8, quick: true }, - { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "heading", label: "Show headline", type: "toggle", quick: true }, { key: "cards", label: "Cards", type: "toggle", quick: true }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1224,7 +1280,7 @@ export const teamGridDef: ComponentDef = { if (bool(p, "heading")) { const head = headBlock(pad, top, w - pad * 2, { eyebrow: "the humans", - heading: "Small team, big erasers", + heading: str(p, "headline", "Small team, big erasers"), align: "center", size: clamp(w * 0.032, 16, 24), }) @@ -1261,6 +1317,7 @@ export const teamGridDef: ComponentDef = { const AWARD_ICONS = ["trophy", "medal", "crown", "seal-check", "star"] const AWARD_TITLES = ["Best of 2026", "Editors' pick", "Top rated", "Verified nice", "Reader favourite"] +const AWARD_SOURCES = ["some magazine", "a design blog", "an awards site", "a newsletter", "some podcast"] export const awardsDef: ComponentDef = { kind: "awards", @@ -1269,11 +1326,12 @@ export const awardsDef: ComponentDef = { group: "Marketing", keywords: ["badges", "recognition", "trophy", "press", "proof"], size: { w: 760, h: 190 }, - defaults: { count: 4, heading: true, circles: true }, + defaults: { count: 4, heading: true, circles: true, headline: "Somebody gave us these" }, controls: [ { key: "count", label: "Awards", type: "number", min: 2, max: 5, quick: true }, - { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "heading", label: "Show headline", type: "toggle", quick: true }, { key: "circles", label: "Circles", type: "toggle", quick: true }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1281,8 +1339,11 @@ export const awardsDef: ComponentDef = { const pad = clamp(w * 0.04, 12, 32) let top = pad if (bool(p, "heading")) { - prims.push(text(w / 2, top + 14, truncate("Somebody gave us these", 15, w - pad * 2), 15, { align: "center", bold: true })) - top += 30 + const head = str(p, "headline", "Somebody gave us these").trim() + if (head) { + prims.push(text(w / 2, top + 14, truncate(head, 15, w - pad * 2), 15, { align: "center", bold: true })) + top += 30 + } } const bodyH = h - top - pad if (bodyH < 50) return prims @@ -1296,7 +1357,7 @@ export const awardsDef: ComponentDef = { const ty = top + d + 26 if (ty < top + bodyH + 10) { prims.push(text(cx, ty, truncate(AWARD_TITLES[i % AWARD_TITLES.length], 13, colW - 12), 13, { align: "center", bold: true })) - prims.push(text(cx, ty + 17, "some magazine", 11, { align: "center", color: "muted" })) + prims.push(text(cx, ty + 17, truncate(AWARD_SOURCES[i % AWARD_SOURCES.length], 11, colW - 12), 11, { align: "center", color: "muted" })) } } return prims @@ -1474,13 +1535,21 @@ export const blogPostHeaderDef: ComponentDef = { group: "Content", keywords: ["article", "title", "byline", "author", "cover"], size: { w: 720, h: 380 }, - defaults: { cover: true, tag: true, tagText: "Craft", title: "Why your wireframe should look unfinished", align: "left" }, + defaults: { + cover: true, + tag: true, + tagText: "Craft", + title: "Why your wireframe should look unfinished", + align: "left", + author: "Ana Ruiz", + }, controls: [ { key: "align", label: "Align", type: "select", options: ["left", "center"], quick: true }, { key: "cover", label: "Cover image", type: "toggle", quick: true }, { key: "tag", label: "Tag", type: "toggle", quick: true }, { key: "title", label: "Title", type: "text" }, { key: "tagText", label: "Tag text", type: "text" }, + { key: "author", label: "Author", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1504,11 +1573,20 @@ export const blogPostHeaderDef: ComponentDef = { y = head.bottom + 16 const av = 36 const meta = "Mar 3, 2026 · 6 min read" - const rowW = av + 12 + Math.max(textWidth(PEOPLE[0], 14), textWidth(meta, 12)) - const rx = centered ? w / 2 - rowW / 2 : pad + const author = str(p, "author", PEOPLE[0]).trim() || PEOPLE[0] + const nameMaxW = Math.max(20, colW - av - 12) + const rowW = av + 12 + Math.max(Math.min(textWidth(author, 14), nameMaxW), textWidth(meta, 12)) + const rx = centered ? Math.max(pad, w / 2 - rowW / 2) : pad + const initials = + author + .split(/\s+/) + .map((s) => s[0]) + .join("") + .slice(0, 2) + .toUpperCase() || "AR" if (y + av <= h) { - prims.push(...sub(avatarDef, { content: "initials", initials: "AR" }, rx, y, av, av)) - prims.push(text(rx + av + 12, y + 15, PEOPLE[0], 14, { bold: true })) + prims.push(...sub(avatarDef, { content: "initials", initials }, rx, y, av, av)) + prims.push(text(rx + av + 12, y + 15, truncate(author, 14, nameMaxW), 14, { bold: true })) prims.push(text(rx + av + 12, y + 31, meta, 12, { color: "muted" })) y += av + 20 } @@ -1529,12 +1607,19 @@ export const articleBodyDef: ComponentDef = { group: "Content", keywords: ["prose", "reading column", "text", "pull quote", "post"], size: { w: 640, h: 480 }, - defaults: { quote: true, bullets: true, heading: true, title: "The rectangle problem" }, + defaults: { + quote: true, + bullets: true, + heading: true, + title: "The rectangle problem", + quoteText: "A wireframe that looks finished gets treated like it is finished.", + }, controls: [ { key: "heading", label: "Show heading", type: "toggle", quick: true }, { key: "quote", label: "Pull quote", type: "toggle", quick: true }, { key: "bullets", label: "Bullet list", type: "toggle", quick: true }, { key: "title", label: "Heading", type: "text" }, + { key: "quoteText", label: "Quote", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1559,7 +1644,7 @@ export const articleBodyDef: ComponentDef = { prims.push(...icon("quotes", pad + 22, y + 12, 15, { stroke: "muted" })) const qs = clamp(w * 0.03, 14, 19) const qw = colW - 44 - const q = wrap("A wireframe that looks finished gets treated like it is finished.", qs, qw, 2) + const q = wrap(str(p, "quoteText", "A wireframe that looks finished gets treated like it is finished."), qs, qw, 2) q.forEach((ln, i) => prims.push(text(pad + 42, y + 6 + qs + i * qs * 1.35, ln, qs, { bold: true }))) y += quoteH } @@ -1589,10 +1674,11 @@ export const changelogDef: ComponentDef = { group: "Content", keywords: ["releases", "updates", "versions", "history", "notes"], size: { w: 720, h: 400 }, - defaults: { entries: 3, heading: true }, + defaults: { entries: 3, heading: true, headline: "Changelog" }, controls: [ { key: "entries", label: "Entries", type: "number", min: 1, max: 4, quick: true }, - { key: "heading", label: "Heading", type: "toggle", quick: true }, + { key: "heading", label: "Show headline", type: "toggle", quick: true }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1602,7 +1688,7 @@ export const changelogDef: ComponentDef = { if (bool(p, "heading")) { const head = headBlock(pad, top, w - pad * 2, { eyebrow: "what changed", - heading: "Changelog", + heading: str(p, "headline", "Changelog"), size: clamp(w * 0.032, 16, 24), }) prims.push(...head.prims) @@ -1646,11 +1732,12 @@ export const contactFormBlockDef: ComponentDef = { group: "Content", keywords: ["contact", "message", "support", "email us", "form"], size: { w: 780, h: 420 }, - defaults: { split: true, heading: "Say hello", details: true }, + defaults: { split: true, heading: "Say hello", details: true, cta: "Send it" }, controls: [ { key: "split", label: "Split layout", type: "toggle", quick: true }, { key: "details", label: "Contact details", type: "toggle", quick: true }, { key: "heading", label: "Heading", type: "text" }, + { key: "cta", label: "Button label", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1732,7 +1819,7 @@ export const contactFormBlockDef: ComponentDef = { y += areaH + 12 } if (y + btnH <= h - vpad + 6) { - const label = "Send it" + const label = str(p, "cta", "Send it") const bw = Math.min(btnW(label), formW) prims.push(...sub(buttonDef, { label, variant: "filled" }, formX + formW - bw, y, bw, btnH)) } @@ -1749,12 +1836,21 @@ export const breadcrumbHeaderDef: ComponentDef = { group: "Content", keywords: ["breadcrumb", "title", "actions", "toolbar", "page head"], size: { w: 760, h: 120 }, - defaults: { title: "Sketchbook", actions: 2, trail: "Home, Library, Sketchbook", divider: true }, + defaults: { + title: "Sketchbook", + actions: 2, + trail: "Home, Library, Sketchbook", + divider: true, + cta: "New sketch", + cta2: "Share", + }, controls: [ { key: "title", label: "Title", type: "text" }, - { key: "trail", label: "Breadcrumb", type: "text" }, + { key: "trail", label: "Breadcrumb (comma-sep)", type: "text" }, { key: "actions", label: "Actions", type: "number", min: 0, max: 2, quick: true }, { key: "divider", label: "Divider", type: "toggle", quick: true }, + { key: "cta", label: "Button label", type: "text" }, + { key: "cta2", label: "Second button label", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1766,13 +1862,19 @@ export const breadcrumbHeaderDef: ComponentDef = { const bh = clamp(h * 0.3, 30, 38) let right = w - pad if (nAct > 0) { - const l1 = "New sketch" - const w1 = btnW(l1, 14) + const l1 = str(p, "cta", "New sketch") + const l2 = str(p, "cta2", "Share") + // keep at least ~40% of the strip for the title, shrinking both buttons to fit + const budget = Math.max(80, (w - pad * 2) * 0.6) + const raw1 = btnW(l1, 14) + const raw2 = nAct > 1 ? btnW(l2, 14) : 0 + const need = raw1 + (nAct > 1 ? raw2 + 10 : 0) + const k = need > budget ? budget / need : 1 + const w1 = raw1 * k + const w2 = raw2 * k prims.push(...sub(buttonDef, { label: l1, variant: "filled", size: "sm" }, right - w1, y, w1, bh)) right -= w1 + 10 if (nAct > 1) { - const l2 = "Share" - const w2 = btnW(l2, 14) prims.push(...sub(buttonDef, { label: l2, variant: "outline", size: "sm" }, right - w2, y, w2, bh)) right -= w2 + 10 } diff --git a/lib/library/defs-display.ts b/lib/library/defs-display.ts index 4d3e673..8cac102 100644 --- a/lib/library/defs-display.ts +++ b/lib/library/defs-display.ts @@ -21,10 +21,11 @@ export const cardDef: ComponentDef = { group: "Display", keywords: ["panel", "box", "container"], size: { w: 260, h: 240 }, - defaults: { title: "Card title", image: true, header: true, footer: true, actions: false, cta: "Go", secondary: true }, + defaults: { title: "Card title", image: true, header: true, footer: true, actions: false, cta: "Go", cta2: "Nope", secondary: true }, controls: [ { key: "title", label: "Title", type: "text" }, { key: "cta", label: "Button", type: "text" }, + { key: "cta2", label: "Second label", type: "text" }, { key: "image", label: "Image", type: "toggle", quick: true }, { key: "header", label: "Header", type: "toggle", quick: true }, { key: "footer", label: "Footer", type: "toggle", quick: true }, @@ -55,15 +56,19 @@ export const cardDef: ComponentDef = { prims.push(line(0, fy, w, fy, { stroke: "faint" })) const secondary = bool(p, "secondary") const label = str(p, "cta", "Go") - // leave room for "Nope" when it's there, and never let the button eat the card + // leave room for the quiet link when it's there, and never let the button eat the card const cap = Math.max(40, w - 16 - (secondary ? 52 : 20)) const bw = Math.min(Math.max(80, textWidth(label, 13) + 28), cap) const bx = w - 16 - bw prims.push(rect(bx, fy + 9, bw, 28, { fill: "shade", fillColor: "ink" })) prims.push(text(bx + bw / 2, fy + 27, truncate(label, 13, bw - 20), 13, { align: "center" })) - // on a very narrow card the button wins and the quiet link steps aside - if (secondary && bx > 16 + textWidth("Nope", 13) + 8) { - prims.push(text(16, fy + 27, "Nope", 13, { color: "muted" })) + // an emptied label draws nothing at all, rather than a blank run that + // only shows up as a stray text node once you break the card apart + const sec = str(p, "cta2", "Nope").trim() + // the quiet link gets whatever the button leaves; below a few characters' worth it steps aside + const secW = bx - 24 + if (secondary && sec && secW >= 24) { + prims.push(text(16, fy + 27, truncate(sec, 13, secW), 13, { color: "muted" })) } } return prims @@ -217,7 +222,7 @@ export const tabsDef: ComponentDef = { defaults: { labels: "Overview, Details, Reviews", active: 1 }, controls: [ { key: "labels", label: "Tabs (comma-sep)", type: "text" }, - { key: "active", label: "Active tab", type: "number", min: 1, max: 6, quick: true }, + { key: "active", label: "Active", type: "number", min: 1, max: 6, quick: true }, ], render(p, w, h) { const labels = list(p, "labels", "Overview, Details, Reviews") diff --git a/lib/library/defs-more.ts b/lib/library/defs-more.ts index 90aec92..f7a9810 100644 --- a/lib/library/defs-more.ts +++ b/lib/library/defs-more.ts @@ -111,14 +111,14 @@ export const buttonGroupDef: ComponentDef = { group: "Buttons", keywords: ["segmented", "control", "toggle", "switcher"], size: { w: 240, h: 36 }, - defaults: { labels: "Day, Week, Month, Year, All, Custom", count: 3, active: 2 }, + defaults: { labels: "Day, Week, Month", active: 2 }, controls: [ { key: "labels", label: "Labels (comma-sep)", type: "text" }, - { key: "count", label: "Segments", type: "number", min: 2, max: 6, quick: true }, { key: "active", label: "Active", type: "number", min: 1, max: 6, quick: true }, ], render(p, w, h) { - const labels = list(p, "labels", "Day, Week, Month, Year, All, Custom") + const labels = list(p, "labels", "Day, Week, Month") + // Segments follow the labels now; nodes saved with the old Segments control keep their count. const n = clamp(Math.round(num(p, "count", labels.length)), 2, 6) const active = clamp(Math.round(num(p, "active", 1)), 1, n) - 1 const segW = w / n @@ -1484,8 +1484,8 @@ export const spinnerDef: ComponentDef = { defaults: { style: "ring", showLabel: false, label: "Loading…" }, controls: [ { key: "style", label: "Style", type: "select", options: ["ring", "dots"], quick: true }, - { key: "showLabel", label: "Label", type: "toggle", quick: true }, - { key: "label", label: "Text", type: "text" }, + { key: "showLabel", label: "Show label", type: "toggle", quick: true }, + { key: "label", label: "Label", type: "text" }, ], render(p, w, h) { const showLabel = bool(p, "showLabel") @@ -1616,8 +1616,8 @@ export const cardProfileDef: ComponentDef = { { key: "name", label: "Name", type: "text" }, { key: "role", label: "Role", type: "text" }, { key: "layout", label: "Layout", type: "select", options: ["centered", "row"], quick: true }, - { key: "action", label: "Action", type: "toggle", quick: true }, - { key: "cta", label: "Button", type: "text" }, + { key: "action", label: "Button", type: "toggle", quick: true }, + { key: "cta", label: "Button label", type: "text" }, ], render(p, w, h) { const pad = clamp(w * 0.07, 12, 18) @@ -1731,7 +1731,7 @@ export const cardPricingDef: ComponentDef = { { key: "period", label: "Period", type: "text" }, { key: "features", label: "Features", type: "number", min: 1, max: 6, quick: true }, { key: "popular", label: "Popular", type: "toggle", quick: true }, - { key: "cta", label: "Button", type: "text" }, + { key: "cta", label: "Button label", type: "text" }, ], render(p, w, h) { const popular = bool(p, "popular") @@ -1827,14 +1827,15 @@ export const cardProductDef: ComponentDef = { group: "Display", keywords: ["shop", "commerce", "buy", "price", "cart", "card"], size: { w: 220, h: 270 }, - defaults: { title: "Squig Tote", price: "$28", badge: true, rating: true, cta: "icon", ctaLabel: "Add to cart" }, + defaults: { title: "Squig Tote", price: "$28", badge: true, badgeLabel: "Sale", rating: true, cta: "icon", ctaLabel: "Add to cart" }, controls: [ { key: "title", label: "Title", type: "text" }, { key: "price", label: "Price", type: "text" }, { key: "badge", label: "Badge", type: "toggle", quick: true }, + { key: "badgeLabel", label: "Badge text", type: "text" }, { key: "rating", label: "Rating", type: "toggle", quick: true }, { key: "cta", label: "Action", type: "select", options: ["icon", "button"], quick: true }, - { key: "ctaLabel", label: "Button", type: "text" }, + { key: "ctaLabel", label: "Button label", type: "text" }, ], render(p, w, h) { const pad = 14 @@ -1843,10 +1844,12 @@ export const cardProductDef: ComponentDef = { prims.push(rect(0, 0, w, imgH, { fill: "shade", fillColor: "faint" })) prims.push(...icon("image", w / 2, imgH / 2, clamp(Math.min(w, imgH) * 0.24, 18, 46), { stroke: "muted" })) if (bool(p, "badge") && imgH > 40) { - const bw = 46 + const bl = str(p, "badgeLabel", "Sale") + const maxBw = Math.max(24, w - pad * 2) + const bw = clamp(advance(bl, 10) + 20, Math.min(46, maxBw), maxBw) const bh = 20 prims.push(pill(pad, pad, bw, bh, PAPER_FILL), pill(pad, pad, bw, bh)) - prims.push(text(pad + bw / 2, pad + bh / 2 + 4, "Sale", 10, { align: "center" })) + prims.push(text(pad + bw / 2, pad + bh / 2 + 4, truncate(bl, 10, bw - 10), 10, { align: "center" })) } let y = imgH + pad + 12 prims.push(text(pad, y, truncate(str(p, "title", ""), 15, w - pad * 2), 15, { bold: true })) diff --git a/lib/library/defs-templates.ts b/lib/library/defs-templates.ts index fbcc417..de4e567 100644 --- a/lib/library/defs-templates.ts +++ b/lib/library/defs-templates.ts @@ -15,6 +15,17 @@ const str = (p: Props, k: string, fallback = ""): string => String(p[k] ?? fallb // from props that predate the key must land on the old hardcoded behaviour const bool = (p: Props, k: string, fallback = false): boolean => (p[k] === undefined ? fallback : Boolean(p[k])) const num = (p: Props, k: string, fallback = 0): number => Number(p[k] ?? fallback) +// comma-separated list: blanks dropped, and an emptied field falls back to the +// default list so clearing the box restores the block instead of blanking it +const list = (p: Props, k: string, fallback: string): string[] => { + const out = str(p, k, fallback) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + return out.length ? out : fallback.split(",").map((s) => s.trim()) +} +/** safe indexed pick from a cycling pool — a short list repeats instead of drawing blanks */ +const pick = (pool: string[], i: number): string => pool[((i % pool.length) + pool.length) % pool.length] function sub(def: ComponentDef, props: Props, x: number, y: number, w: number, h: number): Prim[] { return place(def.render({ ...def.defaults, ...props }, w, h), x, y) @@ -314,10 +325,17 @@ export const pricingDef: ComponentDef = { group: "Screens", keywords: ["plans", "tiers", "billing", "money"], size: { w: 680, h: 400 }, - defaults: { plans: 3, highlight: 2 }, + defaults: { + plans: 3, + highlight: 2, + names: "Doodle, Sketch, Masterpiece, Gallery", + prices: "$0, $12, $49, $199", + }, controls: [ { key: "plans", label: "Plans", type: "number", min: 2, max: 4, quick: true }, { key: "highlight", label: "Highlighted", type: "number", min: 0, max: 4, quick: true }, + { key: "names", label: "Plan names (comma-sep)", type: "text" }, + { key: "prices", label: "Prices (comma-sep)", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -325,8 +343,8 @@ export const pricingDef: ComponentDef = { const hi = num(p, "highlight", 2) const gap = 18 const cardW = (w - gap * (n - 1)) / n - const names = ["Doodle", "Sketch", "Masterpiece", "Gallery"] - const prices = ["$0", "$12", "$49", "$199"] + const names = list(p, "names", "Doodle, Sketch, Masterpiece, Gallery") + const prices = list(p, "prices", "$0, $12, $49, $199") for (let i = 0; i < n; i++) { const x = i * (cardW + gap) const isHi = i + 1 === hi @@ -338,9 +356,9 @@ export const pricingDef: ComponentDef = { prims.push(text(x + cardW / 2, y0 + 18, "most popular", 12, { align: "center" })) } let y = y0 + (isHi ? 50 : 34) - prims.push(text(x + cardW / 2, y, names[i], 17, { align: "center", bold: true })) + prims.push(text(x + cardW / 2, y, truncate(pick(names, i), 17, cardW - 24), 17, { align: "center", bold: true })) y += 34 - prims.push(text(x + cardW / 2, y, prices[i], 26, { align: "center", bold: true })) + prims.push(text(x + cardW / 2, y, truncate(pick(prices, i), 26, cardW - 24), 26, { align: "center", bold: true })) prims.push(text(x + cardW / 2, y + 16, "/month-ish", 11, { align: "center", color: "muted" })) y += 36 const feats = Math.min(4, Math.floor((h - y - 70) / 26)) @@ -412,7 +430,7 @@ export const emptyStateDef: ComponentDef = { defaults: { title: "Nothing here yet", cta: true, icon: "image" }, controls: [ { key: "title", label: "Title", type: "text" }, - { key: "icon", label: "Icon", type: "select", options: ["image", "file", "search", "star"], quick: true }, + { key: "icon", label: "Icon", type: "select", options: ["image", "folder", "file", "magnifying-glass", "star", "sparkle", "bell"], quick: true }, { key: "cta", label: "Button", type: "toggle", quick: true }, ], render(p, w, h) { @@ -425,7 +443,7 @@ export const emptyStateDef: ComponentDef = { const cy = h * 0.34 const ring = 88 * k prims.push(ellipse(w / 2 - ring / 2, cy - ring / 2, ring, ring, { fill: "shade", fillColor: "faint" })) - prims.push(...icon(str(p, "icon", "image") as "image", w / 2, cy, 40 * k, { stroke: "muted" })) + prims.push(...icon(str(p, "icon", "image"), w / 2, cy, 40 * k, { stroke: "muted" })) prims.push(text(w / 2, cy + g(76), str(p, "title", "Nothing here yet"), 19, { align: "center", bold: true })) prims.push(line(w / 2 - w * 0.28, cy + g(96), w / 2 + w * 0.28, cy + g(96), { stroke: "muted", strokeWidth: 1.2 })) const by = cy + g(116)