tsquare 0.5.0 → 0.6.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.
@@ -204,12 +204,13 @@ var wireframeSchema = defineSchema(
204
204
  }),
205
205
  {
206
206
  defaultRules: [
207
- "The root element MUST be a Board. A Board's children are Screens (and optional Notes placed beside them).",
207
+ "The root element MUST be a Board. A Board's children are Screens (and optional Notes placed beside them, and Flow elements after them).",
208
208
  "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.",
209
209
  "Screens lay out their children top to bottom. Use Stack (direction row/column) and Grid inside a Screen to arrange content.",
210
210
  "Modal, Drawer and Toast 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.",
211
211
  "NavBar goes first in a Screen's children and TabBar goes last (before any overlay).",
212
- "Any element except Board, Screen and Note can take a tooltip prop (text) to show a tooltip next to it. Select (with options), Input with type date, and Button, ListItem or NavBar (with menu) take open to show their list, calendar or menu over the screen.",
212
+ "To show how screens connect, give elements or screens an id prop and add Flow elements as Board children (after the Screens), with from and to set to those ids.",
213
+ "Any element except Board, Screen, Note and Flow can take a tooltip prop (text) to show a tooltip next to it. Select (with options), Input with type date, and Button, ListItem or NavBar (with menu) take open to show their list, calendar or menu over the screen.",
213
214
  "This is a low-fidelity wireframe: prefer placeholders (Image boxes, Text with lines) over invented copy, unless the copy matters to the design.",
214
215
  'Every element MUST include a "children" array. Leaf elements use an empty array: "children": [].',
215
216
  "Every id listed in a children array MUST exist as its own element."
@@ -224,11 +225,14 @@ var justify = z.enum(["start", "center", "end", "between", "around"]);
224
225
  var iconName = z.string().refine(isIcon, { error: (iss) => unknownIconMessage(String(iss.input)) }).describe("Lucide icon name in kebab-case, e.g. menu, search, arrow-left, settings, bell, user");
225
226
  var day = z.number().int().min(1).max(31);
226
227
  var percent = z.number().min(0).max(100);
228
+ var flowEnd = z.enum(["none", "arrow", "dot", "circle", "bar"]);
227
229
  var menuItem = z.union([z.string(), z.strictObject({ label: z.string(), icon: o(iconName) })]);
228
230
  var UNIVERSAL_PROPS = {
229
231
  tooltip: z.string().describe("Show a tooltip with this text next to the element")
230
232
  };
231
- var takesUniversalProps = (type) => type !== "Board" && type !== "Screen" && type !== "Note";
233
+ var ID_PATTERN = /^[A-Za-z][\w-]*$/;
234
+ var takesId = (type) => type !== "Board" && type !== "Note" && type !== "Flow";
235
+ var takesUniversalProps = (type) => type !== "Board" && type !== "Screen" && type !== "Note" && type !== "Flow";
232
236
  function universalPropMessage(type, key) {
233
237
  const base = `${key}s go on elements inside a screen`;
234
238
  return type === "Note" ? `${base}; a note is already an annotation, so put the text in the note` : `${base}, not on the ${type.toLowerCase()} itself`;
@@ -252,7 +256,7 @@ var componentDefinitions = {
252
256
  )
253
257
  }),
254
258
  slots: ["default"],
255
- description: "Root canvas (artboard). Holds Screens side by side, plus optional Notes. Must be the root element.",
259
+ description: "Root canvas (artboard). Holds Screens side by side, plus optional Notes and Flow lines. Must be the root element.",
256
260
  example: { title: "Checkout flow", layout: "row", gap: 64 }
257
261
  },
258
262
  Screen: {
@@ -279,6 +283,23 @@ var componentDefinitions = {
279
283
  description: "Sticky-note annotation. Put it on the Board beside screens, or inside a Screen next to what it explains.",
280
284
  example: { text: "Empty state TBD", color: "yellow" }
281
285
  },
286
+ Flow: {
287
+ props: z.object({
288
+ from: z.string().describe("The id where the arrow starts (written before ->)"),
289
+ to: z.string().describe("The id it points at (written after ->)"),
290
+ label: o(z.string()),
291
+ start: o(flowEnd).describe("Default none"),
292
+ end: o(flowEnd).describe("Default arrow"),
293
+ line: o(z.enum(["rounded", "hard", "curved", "straight"])).describe("Default rounded: right angles with rounded corners"),
294
+ dashed: o(z.boolean()),
295
+ color: o(z.string().refine((v) => v === "accent" || isAccent(v), { error: (iss) => `${accentMessage(String(iss.input))}, or accent for the board's` })).describe(
296
+ `Default gray. ${Object.keys(ACCENTS).join(", ")}, a hex color, or accent for the board's`
297
+ )
298
+ }),
299
+ slots: [],
300
+ description: 'An arrow between two elements or screens, named with #id. Written at the board level after the screens: flow signin -> home "Tap Sign in".',
301
+ example: { from: "signin", to: "home", label: "Tap Sign in" }
302
+ },
282
303
  // ── Layout ────────────────────────────────────────────────────────────
283
304
  Stack: {
284
305
  props: z.object({
@@ -736,7 +757,7 @@ function IconGlyph({ name, size = 20, color = theme.ink, strokeWidth = 2 }) {
736
757
  function Board({ element, children }) {
737
758
  const p = element.props;
738
759
  const gap = p.gap ?? BOARD_GAP;
739
- const items = Children.toArray(children);
760
+ const items = Children.toArray(children).filter((c) => !(isValidElement(c) && c.props.element?.type === "Flow"));
740
761
  const perRow = p.layout === "grid" ? Math.max(1, p.columns ?? 3) : Math.max(1, items.length);
741
762
  const rows = [];
742
763
  for (let i = 0; i < items.length; i += perRow) rows.push(items.slice(i, i + perRow));
@@ -1037,9 +1058,9 @@ function Bullets({ element }) {
1037
1058
  const { label, icon, muted } = typeof item === "string" ? { label: item, icon: null, muted: false } : item;
1038
1059
  const color = muted ? theme.muted : theme.text;
1039
1060
  const glyph = icon ?? p.icon;
1040
- const marker = glyph ? /* @__PURE__ */ jsx(IconGlyph, { name: glyph, size: 18, color }) : p.numbered ? /* @__PURE__ */ jsx(Box, { style: { minWidth: 18, color, fontWeight: 600 }, children: `${i + 1}.` }) : /* @__PURE__ */ jsx(Box, { style: { width: 18, height: 22, alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ jsx(Box, { style: { width: 6, height: 6, borderRadius: 3, backgroundColor: color } }) });
1061
+ const marker2 = glyph ? /* @__PURE__ */ jsx(IconGlyph, { name: glyph, size: 18, color }) : p.numbered ? /* @__PURE__ */ jsx(Box, { style: { minWidth: 18, color, fontWeight: 600 }, children: `${i + 1}.` }) : /* @__PURE__ */ jsx(Box, { style: { width: 18, height: 22, alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ jsx(Box, { style: { width: 6, height: 6, borderRadius: 3, backgroundColor: color } }) });
1041
1062
  return /* @__PURE__ */ jsxs(Box, { style: { gap: 10, alignItems: glyph ? "center" : "flex-start", fontSize: 15, lineHeight: 1.45, color }, children: [
1042
- marker,
1063
+ marker2,
1043
1064
  /* @__PURE__ */ jsx(Box, { style: { flexShrink: 1 }, children: label })
1044
1065
  ] }, i);
1045
1066
  }) });
@@ -1758,8 +1779,12 @@ function Toast({ element, aboveTabBar }) {
1758
1779
  p.action ? /* @__PURE__ */ jsx(Box, { style: { fontWeight: 700, marginLeft: 8, flexShrink: 0 }, children: p.action }) : null
1759
1780
  ] }) });
1760
1781
  }
1782
+ function Flow() {
1783
+ return null;
1784
+ }
1761
1785
  var registry = {
1762
1786
  Board,
1787
+ Flow,
1763
1788
  Screen,
1764
1789
  Note,
1765
1790
  Stack,
@@ -1824,6 +1849,7 @@ var PRIMARY_PROP = {
1824
1849
  Modal: "title",
1825
1850
  Drawer: "title",
1826
1851
  Accordion: "title",
1852
+ Flow: "label",
1827
1853
  Toast: "text"
1828
1854
  };
1829
1855
  function unwrap(t) {
@@ -2079,8 +2105,19 @@ function parseWireframeText(source) {
2079
2105
  return;
2080
2106
  }
2081
2107
  const props = {};
2108
+ let rest = m[2];
2109
+ if (info.name === "Flow") {
2110
+ const ends = rest.match(/^\s*#?([A-Za-z][\w-]*)\s*->\s*#?([A-Za-z][\w-]*)(?=\s|$)(.*)$/);
2111
+ if (!ends) {
2112
+ issues.push({ line: lineNo, message: `a flow line looks like: flow <from> -> <to> "label", where from and to are ids written #name after an element or screen` });
2113
+ return;
2114
+ }
2115
+ props.from = ends[1];
2116
+ props.to = ends[2];
2117
+ rest = ends[3];
2118
+ }
2082
2119
  try {
2083
- const c = new Cursor(tokenize(m[2]), m[2]);
2120
+ const c = new Cursor(tokenize(rest), rest);
2084
2121
  while (!c.done()) {
2085
2122
  c.skipCommas();
2086
2123
  if (c.done()) break;
@@ -2120,6 +2157,11 @@ function parseWireframeText(source) {
2120
2157
  props[ANTONYMS[t.value]] = false;
2121
2158
  } else if (t.kind === "word" && WORD_PRIMARY.has(info.name) && !(PRIMARY_PROP[info.name] in props)) {
2122
2159
  props[PRIMARY_PROP[info.name]] = t.value;
2160
+ } else if (t.kind === "word" && t.value.startsWith("#") && ID_PATTERN.test(t.value.slice(1)) && takesId(info.name)) {
2161
+ if (props.id != null) issues.push({ line: lineNo, message: `two ids (#${props.id} and ${t.value}); an element has one` });
2162
+ else props.id = t.value.slice(1);
2163
+ } else if (t.kind === "word" && t.value.startsWith("#") && ID_PATTERN.test(t.value.slice(1))) {
2164
+ issues.push({ line: lineNo, message: `${info.name} can't have an id (${t.value}); ids name elements and screens for flow arrows` });
2123
2165
  } else if (t.kind === "word" && t.value.startsWith("#")) {
2124
2166
  issues.push({ line: lineNo, message: `"${t.value}": a comment must be on its own line, starting with #; to show # as text, quote it` });
2125
2167
  break;
@@ -2422,6 +2464,191 @@ function overlayLayer(spec, anchors, boxes, c, Calendar2) {
2422
2464
  }) }, "__overlays");
2423
2465
  }
2424
2466
 
2467
+ // src/flows.tsx
2468
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
2469
+ var DIR = { left: { x: -1, y: 0 }, right: { x: 1, y: 0 }, top: { x: 0, y: -1 }, bottom: { x: 0, y: 1 } };
2470
+ var port = (b, s) => s === "left" ? { x: b.x, y: b.y + b.h / 2 } : s === "right" ? { x: b.x + b.w, y: b.y + b.h / 2 } : s === "top" ? { x: b.x + b.w / 2, y: b.y } : { x: b.x + b.w / 2, y: b.y + b.h };
2471
+ var add = (p, d, k) => ({ x: p.x + d.x * k, y: p.y + d.y * k });
2472
+ var LANE_GAP = 16;
2473
+ var flowMargin = (flows) => 40 + LANE_GAP * Math.max(0, flows - 1);
2474
+ var same = (a, b) => a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
2475
+ var overlapsY = (a, b) => a.y < b.y + b.h && b.y < a.y + a.h;
2476
+ function route(ends, screens, gap, lane = 0, track = 0, nudge = 0) {
2477
+ const { from: a, to: b, fromScreen: sa, toScreen: sb } = ends;
2478
+ if (same(sa, sb)) {
2479
+ if (b.x >= a.x + a.w - 1) {
2480
+ const s3 = port(a, "right"), e3 = port(b, "left"), mx = (s3.x + e3.x) / 2;
2481
+ return { from: "right", to: "left", points: [s3, { x: mx, y: s3.y }, { x: mx, y: e3.y }, e3] };
2482
+ }
2483
+ if (b.x + b.w <= a.x + 1) {
2484
+ const s3 = port(a, "left"), e3 = port(b, "right"), mx = (s3.x + e3.x) / 2;
2485
+ return { from: "left", to: "right", points: [s3, { x: mx, y: s3.y }, { x: mx, y: e3.y }, e3] };
2486
+ }
2487
+ const down = b.y >= a.y + a.h / 2;
2488
+ const s2 = port(a, down ? "bottom" : "top"), e2 = port(b, down ? "top" : "bottom"), my = (s2.y + e2.y) / 2;
2489
+ return { from: down ? "bottom" : "top", to: down ? "top" : "bottom", points: [s2, { x: s2.x, y: my }, { x: e2.x, y: my }, e2] };
2490
+ }
2491
+ const right = sb.x >= sa.x + sa.w, left = sb.x + sb.w <= sa.x;
2492
+ const from = right ? "right" : "left", to = right ? "left" : "right";
2493
+ const s = port(a, from), e = port(b, to);
2494
+ const level = (y, r) => Math.max(r.y + 24, Math.min(r.y + r.h - 24, y));
2495
+ if (same(b, sb) && !same(a, sa)) e.y = level(s.y + nudge, sb);
2496
+ if (same(a, sa) && !same(b, sb)) s.y = level(e.y + nudge, sa);
2497
+ const shift = Math.max(-(gap / 2 - 8), Math.min(gap / 2 - 8, TRACKS[track % TRACKS.length]));
2498
+ const lo = right ? sa : sb, hi = right ? sb : sa;
2499
+ const between = screens.some((r) => !same(r, sa) && !same(r, sb) && overlapsY(r, sa) && r.x >= lo.x + lo.w && r.x + r.w <= hi.x);
2500
+ if ((right || left) && !between && overlapsY(sa, sb)) {
2501
+ const mx = (lo.x + lo.w + hi.x) / 2 + shift;
2502
+ return { from, to, points: [s, { x: mx, y: s.y }, { x: mx, y: e.y }, e] };
2503
+ }
2504
+ const below = Math.max(...screens.map((r) => r.y + r.h)) + 24 + lane * LANE_GAP;
2505
+ const gx1 = right ? sa.x + sa.w + gap / 2 + shift : sa.x - gap / 2 + shift;
2506
+ const gx2 = right ? sb.x - gap / 2 + shift : sb.x + sb.w + gap / 2 + shift;
2507
+ return { from, to, lane: below, points: [s, { x: gx1, y: s.y }, { x: gx1, y: below }, { x: gx2, y: below }, { x: gx2, y: e.y }, e] };
2508
+ }
2509
+ var runs = (r) => r.points.slice(1).flatMap((p, i) => {
2510
+ const q = r.points[i];
2511
+ return Math.abs(p.y - q.y) < 0.5 && Math.abs(p.x - q.x) > 0.5 ? [{ y: p.y, x1: Math.min(p.x, q.x), x2: Math.max(p.x, q.x) }] : [];
2512
+ });
2513
+ var TRACKS = [0, -10, 10, -20, 20, -5, 5, -15, 15];
2514
+ function simplify(points) {
2515
+ const out = [];
2516
+ for (const p of points) {
2517
+ const last = out[out.length - 1];
2518
+ if (last && Math.abs(last.x - p.x) < 0.5 && Math.abs(last.y - p.y) < 0.5) continue;
2519
+ if (out.length >= 2) {
2520
+ const a = out[out.length - 2], b = out[out.length - 1];
2521
+ if (Math.abs(a.x - b.x) < 0.5 && Math.abs(b.x - p.x) < 0.5 || Math.abs(a.y - b.y) < 0.5 && Math.abs(b.y - p.y) < 0.5) out.pop();
2522
+ }
2523
+ out.push(p);
2524
+ }
2525
+ return out;
2526
+ }
2527
+ function polylinePath(points, radius) {
2528
+ const pts = simplify(points);
2529
+ let d = `M${pts[0].x} ${pts[0].y}`;
2530
+ for (let i = 1; i < pts.length; i++) {
2531
+ const p = pts[i];
2532
+ if (radius > 0 && i < pts.length - 1) {
2533
+ const prev = pts[i - 1], next = pts[i + 1];
2534
+ const r = Math.min(radius, Math.hypot(p.x - prev.x, p.y - prev.y) / 2, Math.hypot(next.x - p.x, next.y - p.y) / 2);
2535
+ const into = { x: Math.sign(p.x - prev.x), y: Math.sign(p.y - prev.y) }, out = { x: Math.sign(next.x - p.x), y: Math.sign(next.y - p.y) };
2536
+ d += ` L${p.x - into.x * r} ${p.y - into.y * r} Q${p.x} ${p.y} ${p.x + out.x * r} ${p.y + out.y * r}`;
2537
+ } else d += ` L${p.x} ${p.y}`;
2538
+ }
2539
+ return d;
2540
+ }
2541
+ function along(points, t) {
2542
+ const pts = simplify(points);
2543
+ const lengths = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i].x, p.y - pts[i].y));
2544
+ let left = lengths.reduce((a, b) => a + b, 0) * t;
2545
+ for (let i = 0; i < lengths.length; i++) {
2546
+ if (left <= lengths[i]) {
2547
+ const f = lengths[i] ? left / lengths[i] : 0;
2548
+ return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * f, y: pts[i].y + (pts[i + 1].y - pts[i].y) * f };
2549
+ }
2550
+ left -= lengths[i];
2551
+ }
2552
+ return pts[pts.length - 1];
2553
+ }
2554
+ var INSET = { none: 0, arrow: 9, dot: 4, circle: 9, bar: 0 };
2555
+ function marker(kind, p, out, color, key) {
2556
+ if (kind === "none") return null;
2557
+ const perp = { x: -out.y, y: out.x };
2558
+ if (kind === "arrow") {
2559
+ const base = add(p, out, 10);
2560
+ const pts = [p, add(base, perp, 5.5), add(base, perp, -5.5)].map((q) => `${q.x},${q.y}`).join(" ");
2561
+ return /* @__PURE__ */ jsx3("polygon", { points: pts, fill: color }, key);
2562
+ }
2563
+ if (kind === "dot") {
2564
+ const c = add(p, out, 4);
2565
+ return /* @__PURE__ */ jsx3("circle", { cx: c.x, cy: c.y, r: 4, fill: color }, key);
2566
+ }
2567
+ if (kind === "circle") {
2568
+ const c = add(p, out, 5);
2569
+ return /* @__PURE__ */ jsx3("circle", { cx: c.x, cy: c.y, r: 4.5, fill: theme.paper, stroke: color, strokeWidth: 2 }, key);
2570
+ }
2571
+ const a = add(p, perp, 7), b = add(p, perp, -7);
2572
+ return /* @__PURE__ */ jsx3("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: color, strokeWidth: 2.5, strokeLinecap: "round" }, key);
2573
+ }
2574
+ var GRAY2 = "#5f6770";
2575
+ function linePath(r, pts, line) {
2576
+ const s0 = pts[0], e0 = pts[pts.length - 1];
2577
+ if (r.lane == null && line === "straight") {
2578
+ return { d: `M${s0.x} ${s0.y} L${e0.x} ${e0.y}`, at: (t) => ({ x: s0.x + (e0.x - s0.x) * t, y: s0.y + (e0.y - s0.y) * t }) };
2579
+ }
2580
+ if (r.lane == null && line === "curved") {
2581
+ const k = Math.max(40, Math.hypot(e0.x - s0.x, e0.y - s0.y) * 0.4);
2582
+ const c1 = add(s0, DIR[r.from], k), c2 = add(e0, DIR[r.to], k);
2583
+ const bez = (a, b, c, e, t) => (1 - t) ** 3 * a + 3 * (1 - t) ** 2 * t * b + 3 * (1 - t) * t ** 2 * c + t ** 3 * e;
2584
+ return { d: `M${s0.x} ${s0.y} C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${e0.x} ${e0.y}`, at: (t) => ({ x: bez(s0.x, c1.x, c2.x, e0.x, t), y: bez(s0.y, c1.y, c2.y, e0.y, t) }) };
2585
+ }
2586
+ return { d: polylinePath(pts, line === "curved" ? 40 : line === "rounded" ? 14 : 0), at: (t) => along(pts, t) };
2587
+ }
2588
+ var flowLabelWidth = (label) => Math.min(220, Math.ceil(label.length * 7.2) + 20);
2589
+ function flowColor(color, palette) {
2590
+ if (color == null) return GRAY2;
2591
+ if (color === "accent") return palette.accent;
2592
+ const s = String(color);
2593
+ return ACCENTS[s] ?? s;
2594
+ }
2595
+ function flowLayer(spec, flows, screens, gap, palette, size) {
2596
+ const shapes = [];
2597
+ const labels = [];
2598
+ const placed = [];
2599
+ const drawn = [];
2600
+ const tips = flows.flatMap(({ ends: ends2 }) => {
2601
+ const r = route(ends2, screens, gap);
2602
+ return [...same(ends2.from, ends2.fromScreen) ? [] : [r.points[0]], ...same(ends2.to, ends2.toScreen) ? [] : [r.points[r.points.length - 1]]];
2603
+ });
2604
+ let lanes = 0, tracks = 0;
2605
+ const routes = flows.map(({ ends: ends2 }) => {
2606
+ let r = route(ends2, screens, gap, lanes, tracks);
2607
+ const clash = (r2) => runs(r2).some((h) => drawn.some((g) => Math.abs(g.y - h.y) < 8 && g.x1 < h.x2 && h.x1 < g.x2)) || [r2.points[0], r2.points[r2.points.length - 1]].some((p) => tips.some((q) => q !== p && Math.hypot(p.x - q.x, p.y - q.y) > 0.5 && Math.hypot(p.x - q.x, p.y - q.y) < 24));
2608
+ for (let k = 1; k <= 4 && clash(r); k++) r = route(ends2, screens, gap, lanes, tracks, 48 * k);
2609
+ drawn.push(...runs(r));
2610
+ tips.push(r.points[0], r.points[r.points.length - 1]);
2611
+ if (r.lane != null) lanes++;
2612
+ if (!same(ends2.fromScreen, ends2.toScreen)) tracks++;
2613
+ return r;
2614
+ });
2615
+ const ends = routes.flatMap((r) => [r.points[0], r.points[r.points.length - 1]]);
2616
+ flows.forEach(({ key }, i) => {
2617
+ const r = routes[i];
2618
+ const p = spec.elements[key].props ?? {};
2619
+ const color = flowColor(p.color, palette);
2620
+ const start = p.start ?? "none", end = p.end ?? "arrow";
2621
+ const pts = [...r.points];
2622
+ pts[0] = add(pts[0], DIR[r.from], INSET[start]);
2623
+ pts[pts.length - 1] = add(pts[pts.length - 1], DIR[r.to], INSET[end]);
2624
+ const { d, at } = linePath(r, pts, p.line ?? "rounded");
2625
+ shapes.push(
2626
+ /* @__PURE__ */ jsx3("path", { d, fill: "none", stroke: color, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", ...p.dashed ? { strokeDasharray: "7 6" } : {} }, `${key}-line`),
2627
+ marker(start, r.points[0], DIR[r.from], color, `${key}-start`),
2628
+ marker(end, r.points[r.points.length - 1], DIR[r.to], color, `${key}-end`)
2629
+ );
2630
+ if (p.label) {
2631
+ const w = flowLabelWidth(String(p.label));
2632
+ const covers = (c) => placed.filter((q) => c.x - w / 2 < q.x + q.w && q.x < c.x + w / 2 && c.y - 14 < q.y + q.h && q.y < c.y + 14).length + ends.filter((q) => Math.abs(q.x - c.x) < w / 2 + 12 && Math.abs(q.y - c.y) < 24).length;
2633
+ let mid = at(0.5), best = covers(mid);
2634
+ for (const t of [0.4, 0.6, 0.3, 0.7, 0.2, 0.8, 0.12, 0.88]) {
2635
+ if (!best) break;
2636
+ const c = at(t), n = covers(c);
2637
+ if (n < best) [mid, best] = [c, n];
2638
+ }
2639
+ const top = mid.y - 12;
2640
+ placed.push({ x: mid.x - w / 2, y: top, w, h: 24 });
2641
+ labels.push(
2642
+ /* @__PURE__ */ jsx3("div", { style: { position: "absolute", left: mid.x - w / 2, top, width: w, height: 24, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: theme.paper, border: `1.5px solid ${color}`, color, fontSize: 12, fontWeight: 600 }, children: p.label }, `${key}-label`)
2643
+ );
2644
+ }
2645
+ });
2646
+ return /* @__PURE__ */ jsxs3("div", { style: { position: "absolute", top: 0, left: 0, width: size.width, height: size.height, display: "flex" }, children: [
2647
+ /* @__PURE__ */ jsx3("svg", { width: size.width, height: size.height, viewBox: `0 0 ${size.width} ${size.height}`, style: { position: "absolute", top: 0, left: 0 }, children: shapes }),
2648
+ labels
2649
+ ] }, "__flows");
2650
+ }
2651
+
2425
2652
  // src/render.ts
2426
2653
  var fontFile = (name) => createRequire(import.meta.url).resolve(`@fontsource/inter/files/${name}`);
2427
2654
  var fontCache = null;
@@ -2485,6 +2712,11 @@ function checkSpec(spec) {
2485
2712
  }
2486
2713
  }
2487
2714
  for (const key of Object.keys(el.props ?? {})) {
2715
+ if (key === "id" && takesId(el.type)) {
2716
+ const v = el.props.id;
2717
+ if (typeof v !== "string" || !ID_PATTERN.test(v)) issues.push(`${id}: the id "${v}" should be a word: letters, digits, - and _, starting with a letter`);
2718
+ continue;
2719
+ }
2488
2720
  if (key in UNIVERSAL_PROPS) {
2489
2721
  if (!takesUniversalProps(el.type)) issues.push(`${id}: ${universalPropMessage(el.type, key)}`);
2490
2722
  else {
@@ -2565,14 +2797,50 @@ function checkSpec(spec) {
2565
2797
  }
2566
2798
  }
2567
2799
  }
2800
+ const owners = /* @__PURE__ */ new Map();
2801
+ for (const [key, el] of Object.entries(spec.elements)) {
2802
+ const ref = el.props?.id;
2803
+ if (typeof ref !== "string" || !takesId(el.type)) continue;
2804
+ const other = owners.get(ref.toLowerCase());
2805
+ if (other) issues.push(`${key}: the id #${ref} is also used by ${spec.elements[other.key].type} "${other.key}"${other.id === ref ? "" : ` (as #${other.id}; ids ignore case)`}; ids must be unique`);
2806
+ else owners.set(ref.toLowerCase(), { key, id: ref });
2807
+ }
2808
+ const written = [...owners.values()].map((o2) => o2.id);
2809
+ const parentOf = /* @__PURE__ */ new Map();
2810
+ for (const [k, el] of Object.entries(spec.elements)) for (const c of el.children ?? []) parentOf.set(c, k);
2811
+ for (const [key, el] of Object.entries(spec.elements)) {
2812
+ if (el.type !== "Flow") continue;
2813
+ if (!(root.children ?? []).includes(key)) issues.push(`${key}: flow lines go at the board level, after the screens (indented like a screen)`);
2814
+ const p = el.props ?? {};
2815
+ for (const end of ["from", "to"]) {
2816
+ if (typeof p[end] !== "string") continue;
2817
+ const owner = owners.get(p[end].toLowerCase());
2818
+ if (owner) {
2819
+ for (let k = parentOf.get(owner.key); k; k = parentOf.get(k)) {
2820
+ const el2 = spec.elements[k];
2821
+ if (el2?.type === "Accordion" && !el2.props?.open) {
2822
+ issues.push(`${key}: #${owner.id} is inside Accordion "${k}", which is closed, so it isn't drawn; add open to the accordion, or give the accordion the id instead`);
2823
+ break;
2824
+ }
2825
+ }
2826
+ continue;
2827
+ }
2828
+ const close = closeMatches(p[end], written);
2829
+ issues.push(
2830
+ `${key}: no element has the id #${p[end]}` + (close.length ? ` (did you mean ${close.map((c) => "#" + c).join(", ")}?)` : owners.size ? ` (ids on this board: ${written.map((c) => "#" + c).join(", ")})` : "; name an element by writing #name after it")
2831
+ );
2832
+ }
2833
+ if (typeof p.from === "string" && typeof p.to === "string" && p.from.toLowerCase() === p.to.toLowerCase()) issues.push(`${key}: a flow needs two different ends (both are #${p.from})`);
2834
+ }
2568
2835
  return issues;
2569
2836
  }
2570
- function boardLayout(spec) {
2837
+ function boardLayout(spec, opts = {}) {
2571
2838
  const board = spec.elements[spec.root];
2572
2839
  const p = board.props ?? {};
2573
- const gap = p.gap ?? BOARD_GAP;
2574
2840
  const pad = p.padding ?? BOARD_PADDING;
2575
- const kids = (board.children ?? []).map((id) => spec.elements[id]).filter(Boolean);
2841
+ const laidOut = (board.children ?? []).filter((id) => spec.elements[id]?.type === "Screen" || spec.elements[id]?.type === "Note");
2842
+ const kids = laidOut.map((id) => spec.elements[id]);
2843
+ const gap = p.gap ?? (opts.flows !== false ? Math.max(BOARD_GAP, flowLabelGap(spec, laidOut, p.layout === "grid" ? Math.max(1, p.columns ?? 3) : laidOut.length)) : BOARD_GAP);
2576
2844
  const boxes = kids.map((el) => {
2577
2845
  const props = el.props ?? {};
2578
2846
  if (el.type === "Screen") {
@@ -2600,10 +2868,33 @@ function boardLayout(spec) {
2600
2868
  width = Math.max(width, row.reduce((sum, b) => sum + (b?.w ?? 0), 0) + gap * (row.length - 1));
2601
2869
  y += Math.max(...row.map((b) => b?.h ?? 0));
2602
2870
  }
2603
- return { width: Math.ceil(width + pad * 2), height: Math.ceil(y + pad), items };
2871
+ const flowCount = (board.children ?? []).filter((id) => spec.elements[id]?.type === "Flow").length;
2872
+ const flowRoom = opts.flows !== false && flowCount ? flowMargin(flowCount) : 0;
2873
+ return { width: Math.ceil(width + pad * 2), height: Math.ceil(y + pad + flowRoom), items, gap };
2874
+ }
2875
+ function flowLabelGap(spec, laidOut, perRow) {
2876
+ const els = spec.elements;
2877
+ const parent = /* @__PURE__ */ new Map();
2878
+ for (const [key, el] of Object.entries(els)) for (const c of el.children ?? []) parent.set(c, key);
2879
+ const ids = /* @__PURE__ */ new Map();
2880
+ for (const [key, el] of Object.entries(els)) if (typeof el.props?.id === "string") ids.set(el.props.id.toLowerCase(), key);
2881
+ const slot = (id) => {
2882
+ let k = ids.get(String(id).toLowerCase());
2883
+ while (k && els[k]?.type !== "Screen") k = parent.get(k);
2884
+ return k ? laidOut.indexOf(k) : -1;
2885
+ };
2886
+ let widest = 0;
2887
+ for (const key of els[spec.root].children ?? []) {
2888
+ const f = els[key];
2889
+ if (f?.type !== "Flow" || !f.props?.label) continue;
2890
+ const a = slot(f.props.from), b = slot(f.props.to);
2891
+ if (a < 0 || b < 0 || Math.abs(a - b) !== 1 || Math.floor(a / perRow) !== Math.floor(b / perRow)) continue;
2892
+ widest = Math.max(widest, flowLabelWidth(String(f.props.label)));
2893
+ }
2894
+ return widest ? widest + 32 : 0;
2604
2895
  }
2605
- function boardSize(spec) {
2606
- const { width, height } = boardLayout(spec);
2896
+ function boardSize(spec, opts = {}) {
2897
+ const { width, height } = boardLayout(spec, opts);
2607
2898
  return { width, height };
2608
2899
  }
2609
2900
  async function renderWireframeSvg(spec, opts = {}) {
@@ -2612,20 +2903,64 @@ async function renderWireframeSvg(spec, opts = {}) {
2612
2903
  const issues = checkSpec(spec);
2613
2904
  if (issues.length) throw new SpecError(issues);
2614
2905
  }
2615
- const { width, height } = boardSize(spec);
2906
+ const flowsOn = opts.flows !== false;
2907
+ const { width, height, gap } = boardLayout(spec, { flows: opts.flows });
2908
+ const board = spec.elements[spec.root];
2909
+ if ((board.props ?? {}).gap == null && gap !== BOARD_GAP)
2910
+ spec = { ...spec, elements: { ...spec.elements, [spec.root]: { ...board, props: { ...board.props, gap } } } };
2616
2911
  const palette = paletteFor(spec.elements[spec.root]?.props?.accent);
2617
2912
  const registry2 = withPalette(palette);
2618
2913
  const draw = async (s, reg) => renderToSvg(s, { registry: reg, includeStandard: false, fonts: await loadFonts(), width, height });
2619
- const { anchors, boxes } = await measureAnchors(spec);
2620
- if (!anchors.length) return draw(spec, registry2);
2621
- return draw(spec, withTopLayer(registry2, overlayLayer(spec, anchors, boxes, palette, registry2.Calendar)));
2914
+ const flows = flowsOn ? flowEnds(spec) : null;
2915
+ const { anchors, boxes } = await measureAnchors(spec, flows?.measure ?? []);
2916
+ if (!anchors.length && !flows?.list.length) return draw(spec, registry2);
2917
+ const layers = [];
2918
+ if (flows?.list.length) {
2919
+ const resolved = flows.list.map(({ key, from, to }) => ({ key, ends: { from: flows.box(from, boxes) ?? flows.screenBox(from), to: flows.box(to, boxes) ?? flows.screenBox(to), fromScreen: flows.screenBox(from), toScreen: flows.screenBox(to) } })).filter((f) => !!(f.ends.from && f.ends.to && f.ends.fromScreen && f.ends.toScreen));
2920
+ layers.push(flowLayer(spec, resolved, flows.screens, flows.gap, palette, { width, height }));
2921
+ }
2922
+ if (anchors.length) layers.push(overlayLayer(spec, anchors, boxes, palette, registry2.Calendar));
2923
+ return draw(spec, withTopLayer(registry2, layers));
2924
+ }
2925
+ function flowEnds(spec) {
2926
+ const els = spec.elements;
2927
+ const byId = /* @__PURE__ */ new Map();
2928
+ for (const [key, el] of Object.entries(els)) if (typeof el.props?.id === "string") byId.set(el.props.id.toLowerCase(), key);
2929
+ const parent = /* @__PURE__ */ new Map();
2930
+ for (const [key, el] of Object.entries(els)) for (const c of el.children ?? []) parent.set(c, key);
2931
+ const screenOf = (key) => {
2932
+ let k = key;
2933
+ while (k && els[k]?.type !== "Screen") k = parent.get(k);
2934
+ return k;
2935
+ };
2936
+ const { items } = boardLayout(spec);
2937
+ const laidOut = (els[spec.root].children ?? []).filter((k) => els[k]?.type === "Screen" || els[k]?.type === "Note");
2938
+ const screenRects = /* @__PURE__ */ new Map();
2939
+ laidOut.forEach((k, i) => {
2940
+ const it = items[i];
2941
+ if (it && els[k].type === "Screen") screenRects.set(k, { x: it.x, y: it.y + LABEL_H, w: it.width, h: it.height - LABEL_H });
2942
+ });
2943
+ const list = (els[spec.root].children ?? []).filter((k) => els[k]?.type === "Flow").map((key) => {
2944
+ const p = els[key].props ?? {};
2945
+ return { key, from: byId.get(String(p.from).toLowerCase()), to: byId.get(String(p.to).toLowerCase()) };
2946
+ }).filter((f) => !!f.from && !!f.to);
2947
+ const measure = [...new Set(list.flatMap((f) => [f.from, f.to]))].filter((k) => els[k].type !== "Screen");
2948
+ return {
2949
+ list,
2950
+ measure,
2951
+ screens: [...screenRects.values()],
2952
+ screenBox: (key) => screenRects.get(screenOf(key) ?? ""),
2953
+ gap: (els[spec.root].props ?? {}).gap ?? BOARD_GAP,
2954
+ box: (key, boxes) => els[key].type === "Screen" ? screenRects.get(key) : boxes[key]
2955
+ };
2622
2956
  }
2623
- async function measureAnchors(spec) {
2957
+ async function measureAnchors(spec, extra = []) {
2624
2958
  const anchors = findAnchors(spec);
2625
- if (!anchors.length) return { anchors, boxes: {} };
2959
+ const extras = extra.map((id) => ({ id, screen: id, kind: "tooltip", field: false }));
2960
+ if (!anchors.length && !extras.length) return { anchors, boxes: {} };
2626
2961
  const { width, height } = boardSize(spec);
2627
2962
  const registry2 = withPalette(paletteFor());
2628
- const { tagged, colors } = tagForMeasuring(spec, anchors);
2963
+ const { tagged, colors } = tagForMeasuring(spec, [...anchors, ...extras]);
2629
2964
  const svg = await renderToSvg(tagged, { registry: withMarkers(registry2), includeStandard: false, fonts: await loadFonts(), width, height });
2630
2965
  return { anchors, boxes: readMarkers(svg, colors) };
2631
2966
  }
@@ -2676,7 +3011,7 @@ ${formatIssues(issues)}`);
2676
3011
  async function renderWireframe(source, opts = {}) {
2677
3012
  const { spec, issues } = compileWireframe(source);
2678
3013
  if (!spec || issues.length) throw new WireframeError(issues);
2679
- return opts.format === "png" ? renderWireframePng(spec, { skipValidation: true, scale: opts.scale }) : renderWireframeSvg(spec, { skipValidation: true });
3014
+ return opts.format === "png" ? renderWireframePng(spec, { skipValidation: true, scale: opts.scale, flows: opts.flows }) : renderWireframeSvg(spec, { skipValidation: true, flows: opts.flows });
2680
3015
  }
2681
3016
 
2682
3017
  // src/print.ts
@@ -2719,6 +3054,13 @@ function printWireframeText(spec, indentUnit = " ") {
2719
3054
  const el = spec.elements[id];
2720
3055
  const props = { ...el.props ?? {} };
2721
3056
  const parts = [el.type.toLowerCase()];
3057
+ if (el.type === "Flow") {
3058
+ parts.push(`${props.from} -> ${props.to}`);
3059
+ delete props.from;
3060
+ delete props.to;
3061
+ }
3062
+ const elementId = typeof props.id === "string" ? props.id : null;
3063
+ delete props.id;
2722
3064
  const enums = bareEnums(el.type);
2723
3065
  const bools = booleans(el.type);
2724
3066
  const primary = PRIMARY_PROP[el.type];
@@ -2737,6 +3079,7 @@ function printWireframeText(spec, indentUnit = " ") {
2737
3079
  if (v === true && bools.has(k)) parts.push(k);
2738
3080
  else parts.push(`${k}=${fmt(v)}`);
2739
3081
  }
3082
+ if (elementId) parts.push(`#${elementId}`);
2740
3083
  out.push(indentUnit.repeat(depth) + parts.join(" "));
2741
3084
  for (const c of el.children ?? []) walk(c, depth + 1);
2742
3085
  };
@@ -2839,15 +3182,16 @@ function componentReference() {
2839
3182
  return lines.join("\n");
2840
3183
  }
2841
3184
  var RULES = `## Rules
2842
- 1. The top element is a Board. The Board's children are Screens, plus optional Notes beside them.
3185
+ 1. The top element is a Board. The Board's children are Screens, plus optional Notes beside them and Flow lines after them (rule 10).
2843
3186
  2. Each Screen is one view of the product. Use several Screens to show several views or states.
2844
3187
  3. A Screen lays out its children top to bottom. Use Stack (direction row or column) and Grid to arrange content.
2845
3188
  4. NavBar is pinned to the top of its Screen and TabBar to the bottom.
2846
3189
  5. Modal, Drawer and Toast are overlays. They must be direct children of a Screen.
2847
3190
  6. This is a low-fidelity wireframe. Prefer placeholders (Image boxes, Text with lines) over invented copy unless the copy matters.
2848
3191
  7. Only use the components and props listed below. All props are optional unless marked required.
2849
- 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.
2850
- 9. Any element except Board, Screen and Note can take tooltip="text" to show a tooltip next to it. Select (with options), Input type=date, and Button, ListItem or NavBar (with menu) take open to show their list, calendar or menu over the screen.`;
3192
+ 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), Input error and Flow color. Only add an accent if the request asks for color or a brand.
3193
+ 9. Any element except Board, Screen, Note and Flow can take tooltip="text" to show a tooltip next to it. Select (with options), Input type=date, and Button, ListItem or NavBar (with menu) take open to show their list, calendar or menu over the screen.
3194
+ 10. To show how screens connect, name elements or screens by writing #name after them (button primary "Sign in" #signin, screen phone "Home" #home), then add board-level lines after the screens: flow signin -> home "Tap Sign in".`;
2851
3195
  var TEXT_FORMAT = `## Output format: wireframe text
2852
3196
  One element per line. Indent children two spaces under their parent. The first line is the board.
2853
3197
 
package/dist/cli.js CHANGED
@@ -6,14 +6,14 @@ import {
6
6
  printWireframeText,
7
7
  renderWireframe,
8
8
  wireframePrompt
9
- } from "./chunks/chunk-B6KIVOSH.js";
9
+ } from "./chunks/chunk-HDSKRKDL.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import { readFile, writeFile } from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  var USAGE = `tsquare \u2014 render wireframe text to SVG or PNG
15
15
 
16
- tsquare render <file.tsq|-> [-o out.svg|out.png] [--scale 2]
16
+ tsquare render <file.tsq|-> [-o out.svg|out.png] [--scale 2] [--no-flows]
17
17
  tsquare check <file.tsq|-> report problems by line number
18
18
  tsquare fmt <file.tsq|-> [-w] print in canonical form (-w rewrites the file; drops comments)
19
19
  tsquare prompt print the system prompt for models
@@ -69,7 +69,7 @@ async function main(argv) {
69
69
  }
70
70
  const out = flag("-o") ?? (file === "-" ? "wireframe.svg" : file.replace(/\.(tsq|wf)$/, "") + ".svg");
71
71
  const png = out.endsWith(".png");
72
- const data = await renderWireframe(source, { format: png ? "png" : "svg", scale: Number(flag("--scale") ?? 1) });
72
+ const data = await renderWireframe(source, { format: png ? "png" : "svg", scale: Number(flag("--scale") ?? 1), flows: !rest.includes("--no-flows") });
73
73
  await writeFile(out, data);
74
74
  console.log(`\u2713 wrote ${path.relative(process.cwd(), out)}`);
75
75
  }
package/dist/compile.d.ts CHANGED
@@ -21,4 +21,5 @@ export declare class WireframeError extends Error {
21
21
  export declare function renderWireframe(source: string, opts?: {
22
22
  format?: "svg" | "png";
23
23
  scale?: number;
24
+ flows?: boolean;
24
25
  }): Promise<string | Buffer<ArrayBufferLike>>;
@@ -75,8 +75,11 @@ declare function Drawer({ element, children }: Props): React.JSX.Element;
75
75
  declare function Toast({ element, aboveTabBar }: Props & {
76
76
  aboveTabBar?: boolean;
77
77
  }): React.JSX.Element;
78
+ /** Flow lines draw nothing in place; flows.tsx draws them over the board. */
79
+ declare function Flow(): null;
78
80
  export declare const registry: {
79
81
  Board: typeof Board;
82
+ Flow: typeof Flow;
80
83
  Screen: typeof Screen;
81
84
  Note: typeof Note;
82
85
  Stack: typeof Stack;