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 b70dbf3..d370466 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,41 @@ 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" }, + { 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 cy = h / 2 - const knob = 16 - const trackW = w - (bool(p, "showValue") ? 36 : 0) - const kx = v * (trackW - knob) + knob / 2 + 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 + 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 + // 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 }, } @@ -340,12 +376,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..5ff07ce 100644 --- a/lib/library/defs-blocks-app.ts +++ b/lib/library/defs-blocks-app.ts @@ -24,8 +24,20 @@ 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 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) @@ -83,6 +95,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 +136,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 +173,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 +219,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 +263,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,20 +352,25 @@ export const notificationListDef: ComponentDef = { category: "blocks", group: "App", keywords: ["alerts", "bell", "updates", "unread", "inbox"], - size: { w: 420, h: 360 }, - defaults: { rows: 4, header: true, avatars: true }, + size: { w: 420, h: 400 }, + 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 } @@ -372,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 @@ -394,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) { @@ -405,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 @@ -463,7 +486,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"] @@ -589,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" }, @@ -600,8 +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 counts = ["6", "3", "9", "1"] + 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 @@ -612,10 +635,10 @@ 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(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 const cardW = colW - 20 for (let i = 0; i < nCards; i++) { if (y + cardH > bodyY + bodyH - 8) break @@ -626,8 +649,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 +671,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) { @@ -744,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 }, @@ -754,17 +784,21 @@ 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)) + // 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)) 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 +821,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 +843,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"] @@ -906,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)] @@ -919,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)) @@ -959,11 +998,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 +1024,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 +1046,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 +1059,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 +1075,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 +1103,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 +1121,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" })) @@ -1108,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)) @@ -1128,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 @@ -1139,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 } @@ -1213,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 }, @@ -1263,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)) } @@ -1280,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) @@ -1297,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 @@ -1321,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" })) } @@ -1338,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 }, @@ -1348,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 @@ -1397,7 +1479,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 +1491,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,26 +1519,32 @@ export const aiThinkingDef: ComponentDef = { category: "blocks", group: "AI", keywords: ["reasoning", "steps", "spinner", "loading", "chain"], - size: { w: 400, h: 180 }, - defaults: { state: "steps", steps: 3 }, + size: { w: 400, h: 200 }, + 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 @@ -1471,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" })) @@ -1497,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) { @@ -1511,34 +1603,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 }, } @@ -1554,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 }, ], @@ -1562,23 +1660,26 @@ 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 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 + 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 +1695,33 @@ 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" })) } + // 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) + 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,19 +1739,23 @@ export const checkoutDef: ComponentDef = { group: "Commerce", keywords: ["payment", "pay", "card", "billing", "order"], size: { w: 720, h: 520 }, - defaults: { summary: true, express: true }, + 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 }, ], 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) 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) { @@ -1669,12 +1791,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 +1811,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 +1843,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 +1852,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 @@ -1773,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[] = [] @@ -1813,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 @@ -1829,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)] @@ -1841,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"] @@ -1849,19 +1977,36 @@ 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)) + 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 - prims.push(rect(pad, ry, cw, bh, i === 0 ? { strokeWidth: 1.6 } : { stroke: "muted" })) + // 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 === 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) - 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 (i === 0 && w > 380) prims.push(...sub(badgeDef, { label: "Default", variant: "outline" }, pad + cw - 96, cy - 11, 66, 22)) + 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 === 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) { @@ -1887,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) { @@ -1902,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 @@ -1918,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 }, @@ -1939,18 +2091,25 @@ 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, 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 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)) + 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, { 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 const ctaH = Math.min(130, h * 0.16) @@ -1961,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 } @@ -1976,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 } @@ -2019,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 @@ -2047,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", @@ -2094,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" })) @@ -2114,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)) @@ -2144,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)] @@ -2192,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 } @@ -2224,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" }, @@ -2238,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)) @@ -2252,7 +2417,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..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,9 +192,12 @@ 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.", + cta: "Start scribbling", + cta2: "See examples", }, controls: [ { key: "layout", label: "Layout", type: "select", options: ["centered", "split-image", "split-form"], quick: true }, @@ -195,6 +205,9 @@ 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" }, + { key: "eyebrowText", label: "Eyebrow text", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -204,8 +217,9 @@ 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") + 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)) @@ -217,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 }) @@ -245,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, { @@ -313,14 +327,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 +348,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 +385,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 +402,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 +416,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 +469,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: "Show headline", type: "toggle" }, + { key: "headline", label: "Headline", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -455,7 +487,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 +511,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 @@ -507,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[] = [] @@ -546,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 } @@ -564,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 @@ -610,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[] = [] @@ -624,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), }) @@ -695,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[] = [] @@ -708,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 @@ -745,23 +805,36 @@ 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: "Highlighted", 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: "highlight", label: "Highlight tier", type: "number", min: 0, max: 4, 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[] = [] 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")) { 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), }) @@ -779,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 @@ -793,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) @@ -839,21 +913,25 @@ 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, 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[] = [] 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")) { 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), }) @@ -871,7 +949,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) { @@ -886,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", @@ -896,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[] = [] @@ -910,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" })) } @@ -931,11 +1018,19 @@ 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.", + 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[] = [] @@ -946,14 +1041,14 @@ 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, }) 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 @@ -984,7 +1079,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 +1099,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 +1133,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 +1192,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 +1212,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 +1226,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 +1244,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--) { @@ -1167,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[] = [] @@ -1181,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), }) @@ -1218,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", @@ -1226,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[] = [] @@ -1238,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 @@ -1253,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 @@ -1276,31 +1380,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 +1422,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 +1436,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 +1450,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 +1535,21 @@ 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", + 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[] = [] @@ -1441,9 +1557,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) @@ -1456,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 } @@ -1481,11 +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 }, + 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: "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" }, + { key: "quoteText", label: "Quote", type: "text" }, ], render(p, w, h) { const prims: Prim[] = [] @@ -1494,7 +1628,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") @@ -1510,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 } @@ -1540,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[] = [] @@ -1553,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) @@ -1584,6 +1719,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", @@ -1591,15 +1732,17 @@ 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: "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" }, + { key: "cta", label: "Button label", 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 +1750,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 +1764,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 +1780,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) { @@ -1667,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)) } @@ -1684,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[] = [] @@ -1701,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 } @@ -1731,6 +1898,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 +1907,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..8cac102 100644 --- a/lib/library/defs-display.ts +++ b/lib/library/defs-display.ts @@ -21,13 +21,16 @@ 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", 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 }, { 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 +48,28 @@ 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 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" })) + // 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 }, @@ -95,20 +112,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 +159,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 +181,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))) @@ -197,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") @@ -241,12 +266,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 +296,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 +317,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 +335,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 +358,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 +399,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 +443,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 +494,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 +538,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 +570,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..f7a9810 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", 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") + // 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 @@ -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 } @@ -1418,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") @@ -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 })) } @@ -1531,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) @@ -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,9 +1728,10 @@ 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" }, + { key: "cta", label: "Button label", type: "text" }, ], render(p, w, h) { const popular = bool(p, "popular") @@ -1739,13 +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" }, + 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 label", type: "text" }, ], render(p, w, h) { const pad = 14 @@ -1754,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 })) @@ -1780,7 +1872,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 +1891,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 +2037,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 +2074,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 +2096,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 +2119,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 +2150,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 +2162,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 +2340,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 +2357,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 +2377,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 +2423,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 +2479,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 +2489,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 +2501,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 +2527,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 +2537,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 +2554,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 +2676,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 +2718,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..de4e567 100644 --- a/lib/library/defs-templates.ts +++ b/lib/library/defs-templates.ts @@ -4,20 +4,41 @@ // --------------------------------------------------------------------------- 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) +// 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) } +// 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 +47,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 +59,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 +112,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 +168,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 +183,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 +265,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 +286,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 +303,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 }, @@ -223,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[] = [] @@ -234,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 @@ -247,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)) @@ -317,22 +426,31 @@ 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" }, - { 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) { 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"), 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`)