tsquare 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +135 -0
- package/bin/tsquare.js +7 -0
- package/package.json +68 -0
- package/skill/tsquare/SKILL.md +87 -0
- package/skill/tsquare/reference.md +196 -0
- package/src/catalog.ts +329 -0
- package/src/cli.ts +75 -0
- package/src/colors.ts +82 -0
- package/src/compile.ts +64 -0
- package/src/components.tsx +1057 -0
- package/src/icons.ts +54 -0
- package/src/index.ts +11 -0
- package/src/layout.ts +63 -0
- package/src/print.ts +73 -0
- package/src/prompt-example.ts +43 -0
- package/src/prompt.ts +121 -0
- package/src/render.ts +178 -0
- package/src/schema.ts +43 -0
- package/src/suggest.ts +34 -0
- package/src/text.ts +333 -0
package/src/icons.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { icons } from "lucide";
|
|
2
|
+
import { editDistance } from "./suggest";
|
|
3
|
+
|
|
4
|
+
// Lucide exports icons in PascalCase (BarChart2); wireframes use kebab-case (bar-chart-2).
|
|
5
|
+
const pascal = (s: string) => s.replace(/(^|[-_ ])(\w)/g, (_, __, c) => c.toUpperCase());
|
|
6
|
+
const kebab = (s: string) =>
|
|
7
|
+
s
|
|
8
|
+
.replace(/([a-z])([A-Z0-9])/g, "$1-$2")
|
|
9
|
+
.replace(/([0-9])([A-Z])/g, "$1-$2") // Grid3X3 → grid-3-x-3, but Grid3x2 → grid-3x-2
|
|
10
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
|
|
11
|
+
.toLowerCase();
|
|
12
|
+
|
|
13
|
+
const table = icons as Record<string, any>;
|
|
14
|
+
|
|
15
|
+
/** Kebab-case names for every Lucide icon (including Lucide's aliases, e.g. home and house). */
|
|
16
|
+
export const iconNames: string[] = Object.keys(table).map(kebab);
|
|
17
|
+
|
|
18
|
+
/** The Lucide icon node for a kebab-case name, or undefined if there's no such icon. */
|
|
19
|
+
export function iconNode(name: string): any {
|
|
20
|
+
return table[pascal(name)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const isIcon = (name: string) => iconNode(name) !== undefined;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Close matches for an unknown name: near spellings ("serach" → search) and names that
|
|
27
|
+
* share a word with it ("cart" → shopping-cart, "call" → phone-call).
|
|
28
|
+
* There are no synonyms: "notification" won't find bell.
|
|
29
|
+
*/
|
|
30
|
+
export function suggestIcons(name: string, limit = 3): string[] {
|
|
31
|
+
const q = name.toLowerCase().replace(/[_ ]/g, "-");
|
|
32
|
+
const words = q.split("-").filter((w) => w.length > 2);
|
|
33
|
+
const maxEdits = Math.max(1, Math.floor(q.length / 3));
|
|
34
|
+
const scored: [string, number][] = [];
|
|
35
|
+
for (const n of iconNames) {
|
|
36
|
+
const parts = n.split("-");
|
|
37
|
+
const shared = words.filter((w) => parts.includes(w)).length;
|
|
38
|
+
// Lower is closer. A shared word scores 0.6–1 (more shared words, fewer extra words rank
|
|
39
|
+
// higher). A typo scores by edits relative to length, so one wrong letter in a long name
|
|
40
|
+
// (chevron-rigth → 0.23) beats a shared word, but in a short one (cart → car, 0.75) it doesn't.
|
|
41
|
+
const wordScore = shared ? 1 - 0.4 * (shared / words.length) + 0.05 * (parts.length - shared) : Infinity;
|
|
42
|
+
const d = editDistance(q, n);
|
|
43
|
+
const score = Math.min(wordScore, d <= maxEdits ? (3 * d) / q.length : Infinity);
|
|
44
|
+
if (score < Infinity) scored.push([n, score]);
|
|
45
|
+
}
|
|
46
|
+
return scored.sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([n]) => n);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function unknownIconMessage(name: string) {
|
|
50
|
+
const close = suggestIcons(name);
|
|
51
|
+
return close.length
|
|
52
|
+
? `unknown icon "${name}" (did you mean ${close.join(", ")}?)`
|
|
53
|
+
: `unknown icon "${name}" (use a Lucide name in kebab-case, e.g. search, user, settings)`;
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Main API: wireframe text in, SVG/PNG out.
|
|
2
|
+
export { renderWireframe, compileWireframe, formatIssues, WireframeError } from "./compile";
|
|
3
|
+
export { wireframePrompt, repairPrompt } from "./prompt";
|
|
4
|
+
export { printWireframeText as formatWireframe } from "./print";
|
|
5
|
+
|
|
6
|
+
// Lower level: the compiled spec and the pieces used to render it.
|
|
7
|
+
export { renderWireframeSvg, renderWireframePng, checkSpec, boardSize } from "./render";
|
|
8
|
+
export { parseWireframeText } from "./text";
|
|
9
|
+
export { catalog, componentDefinitions } from "./catalog";
|
|
10
|
+
export { registry } from "./components";
|
|
11
|
+
export { theme, DEVICES } from "./layout";
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Sizes and colors shared by the components and the board-size calculation. */
|
|
2
|
+
|
|
3
|
+
export const theme = {
|
|
4
|
+
font: "Inter",
|
|
5
|
+
board: "#e6e8eb",
|
|
6
|
+
paper: "#ffffff",
|
|
7
|
+
ink: "#1f2328",
|
|
8
|
+
text: "#3d434a",
|
|
9
|
+
muted: "#868d96",
|
|
10
|
+
line: "#d0d4d9",
|
|
11
|
+
lineStrong: "#9ca3ab",
|
|
12
|
+
fill: "#f0f2f4",
|
|
13
|
+
fill2: "#e1e4e8",
|
|
14
|
+
primary: "#2b3035",
|
|
15
|
+
onPrimary: "#ffffff",
|
|
16
|
+
scrim: "rgba(31, 35, 40, 0.38)",
|
|
17
|
+
notes: {
|
|
18
|
+
yellow: "#fdf1a8",
|
|
19
|
+
blue: "#cfe6fb",
|
|
20
|
+
pink: "#fbd3e0",
|
|
21
|
+
green: "#d4f1d0",
|
|
22
|
+
},
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export const DEVICES = {
|
|
26
|
+
phone: { width: 390, height: 844, radius: 36, padding: 16 },
|
|
27
|
+
tablet: { width: 820, height: 1180, radius: 24, padding: 24 },
|
|
28
|
+
desktop: { width: 1280, height: 800, radius: 10, padding: 24 },
|
|
29
|
+
custom: { width: 800, height: 600, radius: 8, padding: 20 },
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export type Device = keyof typeof DEVICES;
|
|
33
|
+
|
|
34
|
+
/** Height of the screen name label plus the space under it. */
|
|
35
|
+
export const LABEL_H = 40;
|
|
36
|
+
/** Height of the board title block. */
|
|
37
|
+
export const TITLE_H = 64;
|
|
38
|
+
export const BOARD_PADDING = 56;
|
|
39
|
+
export const BOARD_GAP = 64;
|
|
40
|
+
export const NOTE_WIDTH = 220;
|
|
41
|
+
|
|
42
|
+
export function screenSize(props: Record<string, any> = {}) {
|
|
43
|
+
const device: Device = props.device ?? "phone";
|
|
44
|
+
const base = DEVICES[device] ?? DEVICES.phone;
|
|
45
|
+
const custom = device === "custom";
|
|
46
|
+
return {
|
|
47
|
+
device,
|
|
48
|
+
width: (custom ? props.width : null) ?? base.width,
|
|
49
|
+
height: (custom ? props.height : null) ?? base.height,
|
|
50
|
+
radius: base.radius,
|
|
51
|
+
padding: props.padding ?? base.padding,
|
|
52
|
+
chrome: props.chrome ?? (device === "phone" || device === "desktop"),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Rough height of a Note so the board is tall enough to hold it. */
|
|
57
|
+
export function estimateNoteHeight(text: string, width: number) {
|
|
58
|
+
const charsPerLine = Math.max(8, Math.floor((width - 28) / 7.4));
|
|
59
|
+
const lines = text
|
|
60
|
+
.split("\n")
|
|
61
|
+
.reduce((n, para) => n + Math.max(1, Math.ceil(para.length / charsPerLine)), 0);
|
|
62
|
+
return 28 + lines * 21;
|
|
63
|
+
}
|
package/src/print.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Spec } from "@json-render/core";
|
|
2
|
+
import { componentDefinitions } from "./catalog";
|
|
3
|
+
import { PRIMARY_PROP } from "./text";
|
|
4
|
+
|
|
5
|
+
const BARE_WORD = /^[A-Za-z][\w-]*$/;
|
|
6
|
+
/** Props whose values read naturally as bare words (phone, primary, row, sm, left…). */
|
|
7
|
+
const BARE_PROPS = new Set(["device", "variant", "direction", "size", "side", "type", "layout"]);
|
|
8
|
+
|
|
9
|
+
function unwrap(t: any): any {
|
|
10
|
+
let cur = t;
|
|
11
|
+
while (cur && ["optional", "nullable", "default"].includes(cur.def?.type)) cur = cur.def.innerType;
|
|
12
|
+
return cur;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fmt(v: unknown): string {
|
|
16
|
+
if (typeof v === "string") return BARE_WORD.test(v) && !["true", "false", "null"].includes(v) ? v : JSON.stringify(v);
|
|
17
|
+
if (typeof v === "number" || typeof v === "boolean" || v === null) return String(v);
|
|
18
|
+
if (Array.isArray(v)) return `[${v.map(fmt).join(", ")}]`;
|
|
19
|
+
if (typeof v === "object") {
|
|
20
|
+
return `{${Object.entries(v as object).map(([k, x]) => `${k}=${fmt(x)}`).join(" ")}}`;
|
|
21
|
+
}
|
|
22
|
+
return JSON.stringify(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Enum values that can be written bare for a component (unambiguous only). */
|
|
26
|
+
function bareEnums(type: string) {
|
|
27
|
+
const shape = (componentDefinitions as any)[type].props.shape;
|
|
28
|
+
const seen = new Map<string, string>();
|
|
29
|
+
const dup = new Set<string>();
|
|
30
|
+
for (const [prop, s] of Object.entries(shape)) {
|
|
31
|
+
const inner = unwrap(s);
|
|
32
|
+
if (inner?.def?.type === "enum") {
|
|
33
|
+
for (const v of inner.options) (seen.has(v) ? dup.add(v) : seen.set(v, prop));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const d of dup) seen.delete(d);
|
|
37
|
+
return seen;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function booleans(type: string) {
|
|
41
|
+
const shape = (componentDefinitions as any)[type].props.shape;
|
|
42
|
+
return new Set(Object.entries(shape).filter(([, s]) => unwrap(s)?.def?.type === "boolean").map(([k]) => k));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function printWireframeText(spec: Spec, indentUnit = " "): string {
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
const walk = (id: string, depth: number) => {
|
|
48
|
+
const el = spec.elements[id];
|
|
49
|
+
const props = { ...(el.props ?? {}) } as Record<string, unknown>;
|
|
50
|
+
const parts: string[] = [el.type.toLowerCase()];
|
|
51
|
+
const enums = bareEnums(el.type);
|
|
52
|
+
const bools = booleans(el.type);
|
|
53
|
+
const primary = PRIMARY_PROP[el.type];
|
|
54
|
+
|
|
55
|
+
// bare enum words first (device, variant, direction…), then the quoted text, then the rest
|
|
56
|
+
for (const [k, v] of Object.entries(props)) {
|
|
57
|
+
if (typeof v === "string" && BARE_PROPS.has(k) && enums.get(v) === k) { parts.push(v); delete props[k]; }
|
|
58
|
+
}
|
|
59
|
+
if (primary && typeof props[primary] === "string") {
|
|
60
|
+
parts.push(JSON.stringify(props[primary]));
|
|
61
|
+
delete props[primary];
|
|
62
|
+
}
|
|
63
|
+
for (const [k, v] of Object.entries(props)) {
|
|
64
|
+
if (v === undefined || v === null) continue;
|
|
65
|
+
if (v === true && bools.has(k)) parts.push(k);
|
|
66
|
+
else parts.push(`${k}=${fmt(v)}`);
|
|
67
|
+
}
|
|
68
|
+
out.push(indentUnit.repeat(depth) + parts.join(" "));
|
|
69
|
+
for (const c of el.children ?? []) walk(c, depth + 1);
|
|
70
|
+
};
|
|
71
|
+
walk(spec.root, 0);
|
|
72
|
+
return out.join("\n") + "\n";
|
|
73
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Spec } from "@json-render/core";
|
|
2
|
+
|
|
3
|
+
/** The worked example in the model prompt (also used by the format eval). */
|
|
4
|
+
export const exampleSpec: Spec = {
|
|
5
|
+
root: "board",
|
|
6
|
+
elements: {
|
|
7
|
+
board: { type: "Board", props: { title: "Recipe app" }, children: ["browse", "detail", "note"] },
|
|
8
|
+
|
|
9
|
+
browse: { type: "Screen", props: { name: "Browse", device: "phone" }, children: ["b-nav", "b-search", "b-tabs", "b-grid", "b-tabbar"] },
|
|
10
|
+
"b-nav": { type: "NavBar", props: { title: "Recipes", leading: "menu", actions: ["bell"] }, children: [] },
|
|
11
|
+
"b-search": { type: "Input", props: { type: "search", placeholder: "Search recipes" }, children: [] },
|
|
12
|
+
"b-tabs": { type: "Tabs", props: { items: ["All", "Quick", "Vegetarian"], active: 0 }, children: [] },
|
|
13
|
+
"b-grid": { type: "Grid", props: { columns: 2, gap: 12 }, children: ["card-1", "card-2"] },
|
|
14
|
+
"card-1": { type: "Card", props: { padding: 10 }, children: ["c1-img", "c1-title", "c1-meta"] },
|
|
15
|
+
"c1-img": { type: "Image", props: { height: 110 }, children: [] },
|
|
16
|
+
"c1-title": { type: "Text", props: { text: "Tomato soup", bold: true }, children: [] },
|
|
17
|
+
"c1-meta": { type: "Text", props: { text: "25 min", size: "sm", muted: true }, children: [] },
|
|
18
|
+
"card-2": { type: "Card", props: { padding: 10 }, children: ["c2-img", "c2-title"] },
|
|
19
|
+
"c2-img": { type: "Image", props: { height: 110 }, children: [] },
|
|
20
|
+
"c2-title": { type: "Text", props: { lines: 2 }, children: [] },
|
|
21
|
+
"b-tabbar": {
|
|
22
|
+
type: "TabBar",
|
|
23
|
+
props: { items: [{ label: "Browse", icon: "book-open" }, { label: "Saved", icon: "heart" }, { label: "Profile", icon: "user" }], active: 0 },
|
|
24
|
+
children: [],
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
detail: { type: "Screen", props: { name: "Recipe", device: "phone" }, children: ["d-nav", "d-img", "d-title", "d-tags", "d-body", "d-cook", "d-sheet"] },
|
|
28
|
+
"d-nav": { type: "NavBar", props: { leading: "back", actions: ["share", "more-horizontal"] }, children: [] },
|
|
29
|
+
"d-img": { type: "Image", props: { height: 200, label: "Photo" }, children: [] },
|
|
30
|
+
"d-title": { type: "Heading", props: { text: "Tomato soup", level: 1 }, children: [] },
|
|
31
|
+
"d-tags": { type: "Stack", props: { direction: "row", gap: 8 }, children: ["tag-1", "tag-2"] },
|
|
32
|
+
"tag-1": { type: "Badge", props: { label: "25 min", variant: "outline" }, children: [] },
|
|
33
|
+
"tag-2": { type: "Badge", props: { label: "Vegan", variant: "outline" }, children: [] },
|
|
34
|
+
"d-body": { type: "Text", props: { lines: 4 }, children: [] },
|
|
35
|
+
"d-cook": { type: "Button", props: { label: "Start cooking", variant: "primary", fullWidth: true }, children: [] },
|
|
36
|
+
"d-sheet": { type: "Drawer", props: { side: "bottom", title: "Options", size: 260 }, children: ["opt-1", "opt-2", "opt-3"] },
|
|
37
|
+
"opt-1": { type: "Toggle", props: { label: "Metric units", on: true }, children: [] },
|
|
38
|
+
"opt-2": { type: "Select", props: { label: "Servings", value: "4" }, children: [] },
|
|
39
|
+
"opt-3": { type: "Checkbox", props: { label: "Add to shopping list" }, children: [] },
|
|
40
|
+
|
|
41
|
+
note: { type: "Note", props: { text: "Detail screen shown with the options sheet open." }, children: [] },
|
|
42
|
+
},
|
|
43
|
+
};
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt for models that write wireframe text. Built from the catalog,
|
|
3
|
+
* so it always matches the components and props the renderer accepts.
|
|
4
|
+
*/
|
|
5
|
+
import { componentDefinitions } from "./catalog";
|
|
6
|
+
import { printWireframeText } from "./print";
|
|
7
|
+
import { exampleSpec } from "./prompt-example";
|
|
8
|
+
import { PRIMARY_PROP } from "./text";
|
|
9
|
+
|
|
10
|
+
function unwrap(t: any): any {
|
|
11
|
+
let cur = t;
|
|
12
|
+
while (cur && ["optional", "nullable", "default"].includes(cur.def?.type)) cur = cur.def.innerType;
|
|
13
|
+
return cur;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function describeType(t: any): string {
|
|
17
|
+
const s = unwrap(t);
|
|
18
|
+
switch (s?.def?.type) {
|
|
19
|
+
case "string": return "string";
|
|
20
|
+
case "number": return "number";
|
|
21
|
+
case "boolean": return "boolean";
|
|
22
|
+
case "enum": return (s.options as string[]).map((o) => `"${o}"`).join(" | ");
|
|
23
|
+
case "literal": return s.def.values.map((v: unknown) => JSON.stringify(v)).join(" | ");
|
|
24
|
+
case "union": return s.def.options.map(describeType).join(" | ");
|
|
25
|
+
case "array": return `array of ${describeType(s.def.element)}`;
|
|
26
|
+
case "object":
|
|
27
|
+
return `{ ${Object.entries(s.shape).map(([k, v]) => `${k}: ${describeType(v)}`).join(", ")} }`;
|
|
28
|
+
default: return "any";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PropDoc {
|
|
33
|
+
name: string;
|
|
34
|
+
type: string;
|
|
35
|
+
required: boolean;
|
|
36
|
+
/** This prop is filled by a quoted string, e.g. the label of `button "Save"`. */
|
|
37
|
+
main: boolean;
|
|
38
|
+
description?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every component with its description and props, straight from the catalog. */
|
|
42
|
+
export function componentDocs() {
|
|
43
|
+
return Object.entries(componentDefinitions).map(([name, def]) => ({
|
|
44
|
+
name,
|
|
45
|
+
description: def.description,
|
|
46
|
+
props: Object.entries((def.props as any).shape).map(([prop, schema]: [string, any]): PropDoc => ({
|
|
47
|
+
name: prop,
|
|
48
|
+
type: describeType(schema),
|
|
49
|
+
required: !["optional", "nullable"].includes(schema.def?.type),
|
|
50
|
+
main: PRIMARY_PROP[name] === prop,
|
|
51
|
+
description: schema.description ?? unwrap(schema)?.description,
|
|
52
|
+
})),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function componentReference() {
|
|
57
|
+
const lines: string[] = [];
|
|
58
|
+
for (const c of componentDocs()) {
|
|
59
|
+
lines.push(`### ${c.name}`, c.description);
|
|
60
|
+
for (const p of c.props) {
|
|
61
|
+
lines.push(`- ${p.name}${p.required ? " (required)" : ""}: ${p.type}${p.main ? " (main text)" : ""}${p.description ? ` — ${p.description}` : ""}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push("");
|
|
64
|
+
}
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const RULES = `## Rules
|
|
69
|
+
1. The top element is a Board. The Board's children are Screens, plus optional Notes beside them.
|
|
70
|
+
2. Each Screen is one view of the product. Use several Screens to show several views or states.
|
|
71
|
+
3. A Screen lays out its children top to bottom. Use Stack (direction row or column) and Grid to arrange content.
|
|
72
|
+
4. NavBar is pinned to the top of its Screen and TabBar to the bottom.
|
|
73
|
+
5. Modal and Drawer are overlays. They must be direct children of a Screen.
|
|
74
|
+
6. This is a low-fidelity wireframe. Prefer placeholders (Image boxes, Text with lines) over invented copy unless the copy matters.
|
|
75
|
+
7. Only use the components and props listed below. All props are optional unless marked required.
|
|
76
|
+
8. Wireframes are grayscale. The only UI colors: Board accent (one color for primary buttons, checked controls, toggles, active tabs and ghost buttons), Badge tone (success, warning, danger) and Input error. Only add an accent if the request asks for color or a brand.`;
|
|
77
|
+
|
|
78
|
+
export const TEXT_FORMAT = `## Output format: wireframe text
|
|
79
|
+
One element per line. Indent children two spaces under their parent. The first line is the board.
|
|
80
|
+
|
|
81
|
+
A line is the component name in lowercase, followed by arguments separated by spaces:
|
|
82
|
+
- "a quoted string" sets the component's main text prop (marked "main text" below)
|
|
83
|
+
- a bare word that is one of the component's option values sets that option: phone, desktop, primary, ghost, row, sm, left, bottom, password, …
|
|
84
|
+
- a bare prop name sets a boolean prop to true: checked, fullWidth, grow, muted. \`off\` and \`unchecked\` set on/checked to false
|
|
85
|
+
- key=value sets any prop. Values: "string", number, true/false, bare word, [list, of, values], {key=value key=value}
|
|
86
|
+
- # starts a comment
|
|
87
|
+
|
|
88
|
+
Lists: items are separated by commas, and an item can contain spaces without quotes: [All notes, Pinned, Shared]. To put a comma inside an item, quote the item: ["$1,200", "Smith, J"]. Inside quotes, write \\" for a quote character. Numbers in a list of text are fine: [2023, 2024].
|
|
89
|
+
|
|
90
|
+
If a bare word could mean more than one prop, write it as key=value.`;
|
|
91
|
+
|
|
92
|
+
export const EXAMPLE_REQUEST =
|
|
93
|
+
"Two phone screens for a recipe app: a browse screen with search, category tabs, a grid of recipe cards and a tab bar; and a recipe detail screen with its options sheet open.";
|
|
94
|
+
|
|
95
|
+
/** The system prompt for writing wireframe text. */
|
|
96
|
+
export function wireframePrompt() {
|
|
97
|
+
return [
|
|
98
|
+
"You write low-fidelity UI wireframes as specs that a renderer turns into images.",
|
|
99
|
+
"Reply with only the spec in a single code block, with no explanation.",
|
|
100
|
+
"",
|
|
101
|
+
TEXT_FORMAT,
|
|
102
|
+
"",
|
|
103
|
+
RULES,
|
|
104
|
+
"",
|
|
105
|
+
"## Example",
|
|
106
|
+
`Request: ${EXAMPLE_REQUEST}`,
|
|
107
|
+
"",
|
|
108
|
+
"```tsquare",
|
|
109
|
+
printWireframeText(exampleSpec).trimEnd(),
|
|
110
|
+
"```",
|
|
111
|
+
"",
|
|
112
|
+
"## Components",
|
|
113
|
+
"",
|
|
114
|
+
componentReference(),
|
|
115
|
+
].join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Follow-up message asking a model to fix the problems in its last reply. */
|
|
119
|
+
export function repairPrompt(issues: string) {
|
|
120
|
+
return `The wireframe has these problems:\n${issues}\n\nReply with the corrected wireframe in a single code block.`;
|
|
121
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import type { Spec } from "@json-render/core";
|
|
4
|
+
import { renderToSvg } from "@json-render/image/render";
|
|
5
|
+
import { catalog, componentDefinitions } from "./catalog";
|
|
6
|
+
import { withPalette } from "./components";
|
|
7
|
+
import { paletteFor } from "./colors";
|
|
8
|
+
import { unknownComponentMessage } from "./suggest";
|
|
9
|
+
import {
|
|
10
|
+
BOARD_GAP,
|
|
11
|
+
BOARD_PADDING,
|
|
12
|
+
LABEL_H,
|
|
13
|
+
NOTE_WIDTH,
|
|
14
|
+
TITLE_H,
|
|
15
|
+
estimateNoteHeight,
|
|
16
|
+
screenSize,
|
|
17
|
+
} from "./layout";
|
|
18
|
+
|
|
19
|
+
// Inter from @fontsource/inter, pinned to an exact version: a font change shifts
|
|
20
|
+
// text metrics and line wraps, so treat a bump like a renderer change.
|
|
21
|
+
const fontFile = (name: string) =>
|
|
22
|
+
createRequire(import.meta.url).resolve(`@fontsource/inter/files/${name}`);
|
|
23
|
+
|
|
24
|
+
let fontCache: Promise<any[]> | null = null;
|
|
25
|
+
function loadFonts() {
|
|
26
|
+
fontCache ??= Promise.all([
|
|
27
|
+
readFile(fontFile("inter-latin-400-normal.woff")),
|
|
28
|
+
readFile(fontFile("inter-latin-600-normal.woff")),
|
|
29
|
+
]).then(([regular, semibold]) => [
|
|
30
|
+
{ name: "Inter", data: regular, weight: 400, style: "normal" },
|
|
31
|
+
{ name: "Inter", data: semibold, weight: 600, style: "normal" },
|
|
32
|
+
]);
|
|
33
|
+
return fontCache;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SpecError extends Error {
|
|
37
|
+
constructor(public issues: string[]) {
|
|
38
|
+
super(`Invalid wireframe spec:\n - ${issues.join("\n - ")}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Catalog validation plus the structural rules the renderer depends on. */
|
|
43
|
+
export function checkSpec(spec: Spec): string[] {
|
|
44
|
+
const issues: string[] = [];
|
|
45
|
+
const result = catalog.validate(spec);
|
|
46
|
+
if (!result.success) {
|
|
47
|
+
const err: any = (result as any).error;
|
|
48
|
+
const zodIssues = err?.issues ?? [];
|
|
49
|
+
if (zodIssues.length) {
|
|
50
|
+
for (const i of zodIssues) issues.push(`${(i.path ?? []).join(".")}: ${i.message}`);
|
|
51
|
+
} else {
|
|
52
|
+
issues.push(String(err ?? "catalog validation failed"));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const root = spec.elements?.[spec.root];
|
|
57
|
+
if (!root) {
|
|
58
|
+
issues.push(`root "${spec.root}" is not in elements`);
|
|
59
|
+
return issues;
|
|
60
|
+
}
|
|
61
|
+
if (root.type !== "Board") issues.push(`root must be a Board (got ${root.type})`);
|
|
62
|
+
|
|
63
|
+
for (const [id, el] of Object.entries(spec.elements)) {
|
|
64
|
+
// catalog.validate checks the spec's shape but not each element's props,
|
|
65
|
+
// so validate props against the component's Zod schema here.
|
|
66
|
+
const def = (componentDefinitions as Record<string, { props: any }>)[el.type];
|
|
67
|
+
if (!def) {
|
|
68
|
+
issues.push(`${id}: ${unknownComponentMessage(el.type, Object.keys(componentDefinitions))}`);
|
|
69
|
+
} else {
|
|
70
|
+
const parsed = def.props.safeParse(el.props ?? {});
|
|
71
|
+
if (!parsed.success) {
|
|
72
|
+
for (const i of parsed.error.issues) {
|
|
73
|
+
issues.push(`${id}.props${i.path.length ? "." + i.path.join(".") : ""}: ${i.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Zod drops unknown keys silently; report them so the author (or model) hears about it.
|
|
77
|
+
for (const key of Object.keys(el.props ?? {})) {
|
|
78
|
+
if (!(key in def.props.shape)) issues.push(`${id}.props: ${el.type} has no prop "${key}"`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const child of el.children ?? []) {
|
|
82
|
+
if (!spec.elements[child]) issues.push(`${id}: child "${child}" does not exist`);
|
|
83
|
+
}
|
|
84
|
+
if (el.type === "Screen" && !(root.children ?? []).includes(id)) {
|
|
85
|
+
issues.push(`${id}: Screen must be a direct child of the Board`);
|
|
86
|
+
}
|
|
87
|
+
if (el.type === "Modal" || el.type === "Drawer") {
|
|
88
|
+
const parent = Object.entries(spec.elements).find(([, p]) => p.children?.includes(id));
|
|
89
|
+
if (parent && parent[1].type !== "Screen") {
|
|
90
|
+
issues.push(`${id}: ${el.type} must be a direct child of a Screen (found in ${parent[1].type} "${parent[0]}")`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// The renderer would silently drop extra cells. Usually the cause is an unquoted
|
|
94
|
+
// cell with a space, which the text syntax splits into two.
|
|
95
|
+
if (el.type === "Table") {
|
|
96
|
+
const { columns, data } = (el.props ?? {}) as { columns?: unknown; data?: unknown };
|
|
97
|
+
if (Array.isArray(columns) && Array.isArray(data)) {
|
|
98
|
+
const bad = data
|
|
99
|
+
.map((row, i) => ({ row: i + 1, cells: Array.isArray(row) ? row.length : 1 }))
|
|
100
|
+
.filter((r) => r.cells !== columns.length);
|
|
101
|
+
if (bad.length) {
|
|
102
|
+
const cells = (n: number) => `${n} cell${n === 1 ? "" : "s"}`;
|
|
103
|
+
const rows = bad.length === 1
|
|
104
|
+
? `data row ${bad[0].row} has ${cells(bad[0].cells)}`
|
|
105
|
+
: `data rows ${bad.map((r) => `${r.row} (${cells(r.cells)})`).join(", ")} don't match`;
|
|
106
|
+
const hints = [
|
|
107
|
+
bad.some((r) => r.cells > columns.length) && `quote cells that contain a comma, e.g. ["$1,200", Paid]`,
|
|
108
|
+
bad.some((r) => r.cells < columns.length) && `use "" for an empty cell`,
|
|
109
|
+
].filter(Boolean);
|
|
110
|
+
issues.push(`${id}: Table has ${columns.length} columns, but ${rows} (${hints.join("; ")})`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return issues;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Work out the canvas size from the Board's screens (Satori needs it up front). */
|
|
119
|
+
export function boardSize(spec: Spec) {
|
|
120
|
+
const board = spec.elements[spec.root];
|
|
121
|
+
const p = (board.props ?? {}) as Record<string, any>;
|
|
122
|
+
const gap = p.gap ?? BOARD_GAP;
|
|
123
|
+
const pad = p.padding ?? BOARD_PADDING;
|
|
124
|
+
const kids = (board.children ?? []).map((id) => spec.elements[id]).filter(Boolean);
|
|
125
|
+
|
|
126
|
+
const boxes = kids.map((el) => {
|
|
127
|
+
const props = (el.props ?? {}) as Record<string, any>;
|
|
128
|
+
if (el.type === "Screen") {
|
|
129
|
+
const s = screenSize(props);
|
|
130
|
+
return { w: s.width, h: s.height + LABEL_H };
|
|
131
|
+
}
|
|
132
|
+
if (el.type === "Note") {
|
|
133
|
+
const w = props.width ?? NOTE_WIDTH;
|
|
134
|
+
return { w, h: LABEL_H + estimateNoteHeight(props.text ?? "", w) };
|
|
135
|
+
}
|
|
136
|
+
return { w: 0, h: 0 };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const perRow = p.layout === "grid" ? Math.max(1, p.columns ?? 3) : Math.max(1, boxes.length);
|
|
140
|
+
let width = 0;
|
|
141
|
+
let height = 0;
|
|
142
|
+
for (let i = 0; i < boxes.length; i += perRow) {
|
|
143
|
+
const row = boxes.slice(i, i + perRow);
|
|
144
|
+
width = Math.max(width, row.reduce((sum, b) => sum + b.w, 0) + gap * (row.length - 1));
|
|
145
|
+
height += Math.max(...row.map((b) => b.h)) + (i > 0 ? gap : 0);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
width: Math.ceil(width + pad * 2),
|
|
149
|
+
height: Math.ceil(height + pad * 2 + (p.title ? TITLE_H : 0)),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface RenderWireframeOptions {
|
|
154
|
+
/** Skip validation (e.g. while a spec is still streaming in). */
|
|
155
|
+
skipValidation?: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function renderWireframeSvg(spec: Spec, opts: RenderWireframeOptions = {}) {
|
|
159
|
+
if (!opts.skipValidation) {
|
|
160
|
+
const issues = checkSpec(spec);
|
|
161
|
+
if (issues.length) throw new SpecError(issues);
|
|
162
|
+
}
|
|
163
|
+
const { width, height } = boardSize(spec);
|
|
164
|
+
return renderToSvg(spec, {
|
|
165
|
+
registry: withPalette(paletteFor((spec.elements[spec.root]?.props as any)?.accent)) as any,
|
|
166
|
+
includeStandard: false,
|
|
167
|
+
fonts: await loadFonts(),
|
|
168
|
+
width,
|
|
169
|
+
height,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function renderWireframePng(spec: Spec, opts: RenderWireframeOptions & { scale?: number } = {}) {
|
|
174
|
+
const svg = await renderWireframeSvg(spec, opts);
|
|
175
|
+
const { Resvg } = await import("@resvg/resvg-js");
|
|
176
|
+
const resvg = new Resvg(svg, { fitTo: { mode: "zoom", value: opts.scale ?? 1 } });
|
|
177
|
+
return resvg.render().asPng();
|
|
178
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineSchema } from "@json-render/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wireframe spec schema. Same flat element shape as the other json-render
|
|
5
|
+
* renderers (root + elements map), with wireframe-specific rules for the
|
|
6
|
+
* generated LLM prompt.
|
|
7
|
+
*/
|
|
8
|
+
export const wireframeSchema = defineSchema(
|
|
9
|
+
(s) => ({
|
|
10
|
+
spec: s.object({
|
|
11
|
+
root: s.string(),
|
|
12
|
+
elements: s.record(
|
|
13
|
+
s.object({
|
|
14
|
+
type: s.ref("catalog.components"),
|
|
15
|
+
props: s.propsOf("catalog.components"),
|
|
16
|
+
children: s.array(s.string()),
|
|
17
|
+
visible: { ...s.any(), ...s.optional() },
|
|
18
|
+
repeat: { ...s.any(), ...s.optional() },
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
21
|
+
}),
|
|
22
|
+
catalog: s.object({
|
|
23
|
+
components: s.map({
|
|
24
|
+
props: s.zod(),
|
|
25
|
+
slots: s.array(s.string()),
|
|
26
|
+
description: s.string(),
|
|
27
|
+
example: s.any(),
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
}),
|
|
31
|
+
{
|
|
32
|
+
defaultRules: [
|
|
33
|
+
"The root element MUST be a Board. A Board's children are Screens (and optional Notes placed beside them).",
|
|
34
|
+
"Every Screen MUST be a direct child of the Board. Each Screen is one view of the product; use several Screens to show several views or states.",
|
|
35
|
+
"Screens lay out their children top to bottom. Use Stack (direction row/column) and Grid inside a Screen to arrange content.",
|
|
36
|
+
"Modal and Drawer are overlays. They MUST be direct children of a Screen, and SHOULD be listed last in the Screen's children so they draw on top.",
|
|
37
|
+
"NavBar goes first in a Screen's children and TabBar goes last (before any overlay).",
|
|
38
|
+
"This is a low-fidelity wireframe: prefer placeholders (Image boxes, Text with lines) over invented copy, unless the copy matters to the design.",
|
|
39
|
+
"Every element MUST include a \"children\" array. Leaf elements use an empty array: \"children\": [].",
|
|
40
|
+
"Every id listed in a children array MUST exist as its own element.",
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
);
|
package/src/suggest.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Close-match suggestions for error messages, so a model (or person) can fix a typo in one step.
|
|
2
|
+
|
|
3
|
+
/** Edit distance counting a swap of neighbouring letters as one edit ("serach" → search). */
|
|
4
|
+
export function editDistance(a: string, b: string) {
|
|
5
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
6
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
7
|
+
for (let i = 1; i <= a.length; i++) {
|
|
8
|
+
for (let j = 1; j <= b.length; j++) {
|
|
9
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
10
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
11
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return d[a.length][b.length];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Up to `limit` candidates within a small edit distance of `word`, closest first. */
|
|
18
|
+
export function closeMatches(word: string, candidates: string[], limit = 3): string[] {
|
|
19
|
+
const q = word.toLowerCase();
|
|
20
|
+
const maxEdits = Math.max(1, Math.floor(q.length / 3));
|
|
21
|
+
return candidates
|
|
22
|
+
.map((c) => [c, editDistance(q, c.toLowerCase())] as const)
|
|
23
|
+
.filter(([, d]) => d <= maxEdits)
|
|
24
|
+
.sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
|
|
25
|
+
.slice(0, limit)
|
|
26
|
+
.map(([c]) => c);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function unknownComponentMessage(name: string, components: string[]) {
|
|
30
|
+
const close = closeMatches(name, components);
|
|
31
|
+
return close.length
|
|
32
|
+
? `unknown component "${name}" (did you mean ${close.join(", ")}?)`
|
|
33
|
+
: `unknown component "${name}" (components: ${components.join(", ")})`;
|
|
34
|
+
}
|