tsquare 0.4.2 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Open states and tooltips
6
+
7
+ ```tsquare
8
+ select "Country" value="Mexico" open options=[Canada, Mexico, United States]
9
+ input "Check-in" type=date value="Oct 14, 2026" open
10
+ button ghost leadingIcon=more-horizontal open menu=[{label=Rename icon=pencil}, Duplicate, Delete]
11
+ button secondary "Draft" tooltip="Saves without publishing"
12
+ ```
13
+
14
+ - **Open states:** a select's options list, a date input's calendar and a button's menu now draw over the screen, covering what's below, as they would in the app.
15
+ - Each opens below its element, or above when there isn't room, and stays inside its screen.
16
+ - The date picker opens on the value's month with that day selected.
17
+ - **Menus on list rows and the navbar too:** `listitem "Budget.xlsx" open menu=[Rename, Move, Delete]` opens right-aligned under the row; a row with nothing at its end shows … for it. The navbar's menu opens under a ⋮, added after its actions unless the last one already is a ⋯/⋮ icon.
18
+ - **Tooltips:** `tooltip="…"` works on any element except a board or screen. It sits above the element (below it near the top of the screen), with its arrow pointing at the element.
19
+ - **Errors** when an open state has nothing to show: `open` without `options` or `menu`, or `open` on an input that isn't `type=date`.
20
+ - **How it works:** a board with any of these renders twice. The first pass measures where elements landed, and the second draws the overlays on top. Boards without them render once, exactly as before.
21
+
3
22
  ## 0.4.2
4
23
 
5
24
  - **Toast corners:** `position` also takes `topLeft`, `topRight`, `bottomLeft` and `bottomRight`, besides `top` and `bottom` (centered, the default). Desktop apps often show toasts in a corner.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Anchored overlays: things drawn over the board at an element's position (an open
3
+ * select's options, an open date picker, a button's menu, tooltips).
4
+ *
5
+ * Satori lays out and draws in one go without reporting positions, so a board that
6
+ * has any of these renders twice:
7
+ * 1. Measuring pass: each anchored element (and its screen) gets a marker, a box in a
8
+ * unique color filling it. Satori's SVG uses absolute coordinates, so reading the
9
+ * markers' rects back gives every box on the board.
10
+ * 2. Final pass: the board as usual, plus a top layer on the Board with each overlay
11
+ * placed from those boxes, clipped to its screen, so it covers the content below.
12
+ * Boards without anchored elements render once, exactly as before.
13
+ *
14
+ * Precision: a marker fills its box inside the box's border, so bordered elements measure up to
15
+ * 1.5px in from their outer edge. That's invisible in the output, so overlays don't correct for it.
16
+ */
17
+ import { type ReactNode } from "react";
18
+ import type { Spec } from "@json-render/core";
19
+ import type { Palette } from "./colors.js";
20
+ export interface Rect {
21
+ x: number;
22
+ y: number;
23
+ w: number;
24
+ h: number;
25
+ }
26
+ export type AnchorKind = "tooltip" | "options" | "calendar" | "menu";
27
+ /** `end`: the menu opens from the element's right end (a list row's …, a navbar's ⋮). */
28
+ export interface Anchor {
29
+ id: string;
30
+ kind: AnchorKind;
31
+ screen: string;
32
+ field: boolean;
33
+ end?: boolean;
34
+ }
35
+ /** Every anchored overlay on the board, in drawing order (document order; tooltips last). */
36
+ export declare function findAnchors(spec: Spec): Anchor[];
37
+ /**
38
+ * A copy of the spec whose anchored elements and screens carry marker colors in hidden props:
39
+ * `__mark` on the outer box (menus, tooltips, screens), `__markField` on a field (options, calendar).
40
+ * Box keys are the element id, or `<id>#field`.
41
+ */
42
+ export declare function tagForMeasuring(spec: Spec, anchors: Anchor[]): {
43
+ tagged: Spec;
44
+ colors: Map<string, string>;
45
+ };
46
+ /** The boxes of the markers in a measuring-pass SVG, by the ids they were tagged with. */
47
+ export declare function readMarkers(svg: string, colors: Map<string, string>): Record<string, Rect>;
48
+ /**
49
+ * Where an overlay of `size` goes next to `anchor`, inside `screen`: below (or above, for
50
+ * tooltips) when it fits, otherwise the side with more room; horizontally aligned as asked,
51
+ * then kept inside the screen. Coordinates are relative to the screen.
52
+ */
53
+ export declare function place(anchor: Rect, size: {
54
+ w: number;
55
+ h: number;
56
+ }, screen: Rect, opts: {
57
+ prefer: "below" | "above";
58
+ align: "start" | "center" | "end";
59
+ gap: number;
60
+ }): {
61
+ x: number;
62
+ y: number;
63
+ bottom: number;
64
+ side: "above" | "below";
65
+ };
66
+ /** "Oct 14, 2026", "October 14 2026", "14 Oct 2026", "2026-10-14" → the month for the calendar and the day. */
67
+ export declare function parseDate(value: unknown): {
68
+ month: string;
69
+ day?: number;
70
+ } | null;
71
+ /**
72
+ * The top layer: each overlay inside a box covering its screen (clipped to it), placed from the
73
+ * measured boxes. `Calendar` is the board's own calendar component, reused for date pickers.
74
+ */
75
+ export declare function overlayLayer(spec: Spec, anchors: Anchor[], boxes: Record<string, Rect>, c: Palette, Calendar: (props: any) => ReactNode): ReactNode;
package/dist/catalog.d.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  import { z } from "zod";
2
+ /**
3
+ * Props every element except Board and Screen accepts, on top of its own. Kept out of each
4
+ * component's schema so the prompt describes them once.
5
+ */
6
+ export declare const UNIVERSAL_PROPS: {
7
+ readonly tooltip: z.ZodString;
8
+ };
9
+ /** Not the canvas (Board, Screen) or its annotations (Note): a tooltip there would be meaningless. */
10
+ export declare const takesUniversalProps: (type: string) => boolean;
11
+ /** Why a universal prop isn't allowed here, and what to do instead. */
12
+ export declare function universalPropMessage(type: string, key: string): string;
2
13
  export declare const componentDefinitions: {
3
14
  Board: {
4
15
  props: z.ZodObject<{
@@ -319,6 +330,11 @@ export declare const componentDefinitions: {
319
330
  leadingIcon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
320
331
  trailingIcon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
321
332
  fullWidth: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
333
+ menu: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
334
+ label: z.ZodString;
335
+ icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
336
+ }, z.core.$strict>]>>>>;
337
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
322
338
  }, z.core.$strip>;
323
339
  slots: never[];
324
340
  description: string;
@@ -342,6 +358,7 @@ export declare const componentDefinitions: {
342
358
  text: "text";
343
359
  }>>>;
344
360
  digits: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
361
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
345
362
  multiline: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
346
363
  helper: z.ZodOptional<z.ZodNullable<z.ZodString>>;
347
364
  error: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
@@ -396,6 +413,8 @@ export declare const componentDefinitions: {
396
413
  label: z.ZodOptional<z.ZodNullable<z.ZodString>>;
397
414
  value: z.ZodOptional<z.ZodNullable<z.ZodString>>;
398
415
  placeholder: z.ZodOptional<z.ZodNullable<z.ZodString>>;
416
+ options: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
417
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
399
418
  grow: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
400
419
  width: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>>;
401
420
  }, z.core.$strip>;
@@ -466,6 +485,11 @@ export declare const componentDefinitions: {
466
485
  center: "center";
467
486
  left: "left";
468
487
  }>>>;
488
+ menu: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
489
+ label: z.ZodString;
490
+ icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
491
+ }, z.core.$strict>]>>>>;
492
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
469
493
  }, z.core.$strip>;
470
494
  slots: never[];
471
495
  description: string;
@@ -550,6 +574,11 @@ export declare const componentDefinitions: {
550
574
  }>>>;
551
575
  trailingText: z.ZodOptional<z.ZodNullable<z.ZodString>>;
552
576
  trailingIcon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
577
+ menu: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
578
+ label: z.ZodString;
579
+ icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
580
+ }, z.core.$strict>]>>>>;
581
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
553
582
  }, z.core.$strip>;
554
583
  slots: never[];
555
584
  description: string;
@@ -976,6 +1005,11 @@ export declare const catalog: import("@json-render/core").Catalog<{
976
1005
  leadingIcon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
977
1006
  trailingIcon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
978
1007
  fullWidth: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
1008
+ menu: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
1009
+ label: z.ZodString;
1010
+ icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1011
+ }, z.core.$strict>]>>>>;
1012
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
979
1013
  }, z.core.$strip>;
980
1014
  slots: never[];
981
1015
  description: string;
@@ -999,6 +1033,7 @@ export declare const catalog: import("@json-render/core").Catalog<{
999
1033
  text: "text";
1000
1034
  }>>>;
1001
1035
  digits: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
1036
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
1002
1037
  multiline: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
1003
1038
  helper: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1004
1039
  error: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
@@ -1053,6 +1088,8 @@ export declare const catalog: import("@json-render/core").Catalog<{
1053
1088
  label: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1054
1089
  value: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1055
1090
  placeholder: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1091
+ options: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
1092
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
1056
1093
  grow: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
1057
1094
  width: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>>;
1058
1095
  }, z.core.$strip>;
@@ -1123,6 +1160,11 @@ export declare const catalog: import("@json-render/core").Catalog<{
1123
1160
  center: "center";
1124
1161
  left: "left";
1125
1162
  }>>>;
1163
+ menu: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
1164
+ label: z.ZodString;
1165
+ icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1166
+ }, z.core.$strict>]>>>>;
1167
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
1126
1168
  }, z.core.$strip>;
1127
1169
  slots: never[];
1128
1170
  description: string;
@@ -1207,6 +1249,11 @@ export declare const catalog: import("@json-render/core").Catalog<{
1207
1249
  }>>>;
1208
1250
  trailingText: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1209
1251
  trailingIcon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1252
+ menu: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
1253
+ label: z.ZodString;
1254
+ icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1255
+ }, z.core.$strict>]>>>>;
1256
+ open: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
1210
1257
  }, z.core.$strip>;
1211
1258
  slots: never[];
1212
1259
  description: string;
@@ -209,6 +209,7 @@ var wireframeSchema = defineSchema(
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
213
  "This is a low-fidelity wireframe: prefer placeholders (Image boxes, Text with lines) over invented copy, unless the copy matters to the design.",
213
214
  'Every element MUST include a "children" array. Leaf elements use an empty array: "children": [].',
214
215
  "Every id listed in a children array MUST exist as its own element."
@@ -223,6 +224,15 @@ var justify = z.enum(["start", "center", "end", "between", "around"]);
223
224
  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");
224
225
  var day = z.number().int().min(1).max(31);
225
226
  var percent = z.number().min(0).max(100);
227
+ var menuItem = z.union([z.string(), z.strictObject({ label: z.string(), icon: o(iconName) })]);
228
+ var UNIVERSAL_PROPS = {
229
+ tooltip: z.string().describe("Show a tooltip with this text next to the element")
230
+ };
231
+ var takesUniversalProps = (type) => type !== "Board" && type !== "Screen" && type !== "Note";
232
+ function universalPropMessage(type, key) {
233
+ const base = `${key}s go on elements inside a screen`;
234
+ return type === "Note" ? `${base}; a note is already an annotation, so put the text in the note` : `${base}, not on the ${type.toLowerCase()} itself`;
235
+ }
226
236
  var bulletItem = z.union([
227
237
  z.string(),
228
238
  // strict: a misspelled key is an error, not silently dropped
@@ -426,10 +436,12 @@ var componentDefinitions = {
426
436
  size: o(z.enum(["sm", "md", "lg"])),
427
437
  leadingIcon: o(iconName).describe("Lucide icon before the label, e.g. chevron-left for Back"),
428
438
  trailingIcon: o(iconName).describe("Lucide icon after the label, e.g. chevron-right for Next"),
429
- fullWidth: o(z.boolean())
439
+ fullWidth: o(z.boolean()),
440
+ menu: o(z.array(menuItem)).describe("Dropdown menu items, shown below the button when open"),
441
+ open: o(z.boolean()).describe("Show the menu")
430
442
  }),
431
443
  slots: [],
432
- description: "Button. Primary is filled, secondary is outlined, ghost is text only.",
444
+ description: "Button. Primary is filled, secondary is outlined, ghost is text only. With menu and open, a dropdown menu.",
433
445
  example: { label: "Continue", variant: "primary", fullWidth: true }
434
446
  },
435
447
  Input: {
@@ -439,6 +451,7 @@ var componentDefinitions = {
439
451
  value: o(z.string()),
440
452
  type: o(z.enum(["text", "password", "search", "email", "date", "code"])).describe("date shows a calendar icon; code draws one box per digit"),
441
453
  digits: o(z.number().int().min(2).max(10)).describe("Boxes for type=code, default 6"),
454
+ open: o(z.boolean()).describe("type=date: show the calendar below the field"),
442
455
  multiline: o(z.number().int().min(1)).describe("Number of rows; 2 or more makes a textarea"),
443
456
  helper: o(z.string()),
444
457
  error: o(z.boolean()).describe("Validation error: red border and red helper text"),
@@ -472,11 +485,13 @@ var componentDefinitions = {
472
485
  label: o(z.string()),
473
486
  value: o(z.string()),
474
487
  placeholder: o(z.string()),
488
+ options: o(z.array(z.string())).describe("The choices, shown below the field when open"),
489
+ open: o(z.boolean()).describe("Show the options list"),
475
490
  grow: o(z.boolean()).describe("Fill the remaining space in a row"),
476
491
  width: o(z.union([z.number(), z.string()])).describe("Fixed width, e.g. 320. Default fills the space.")
477
492
  }),
478
493
  slots: [],
479
- description: "Dropdown field (closed state).",
494
+ description: "Dropdown field. With options and open, the list shows below it.",
480
495
  example: { label: "Country", value: "United States" }
481
496
  },
482
497
  Slider: {
@@ -518,7 +533,9 @@ var componentDefinitions = {
518
533
  title: o(z.string()),
519
534
  leading: o(z.enum(["none", "menu", "back", "close", "logo"])),
520
535
  actions: o(z.array(iconName)).describe("Icon names shown on the right"),
521
- align: o(z.enum(["left", "center"]))
536
+ align: o(z.enum(["left", "center"])),
537
+ menu: o(z.array(menuItem)).describe("A menu under the last action (adds \u22EE if needed), shown when open"),
538
+ open: o(z.boolean()).describe("Show the menu")
522
539
  }),
523
540
  slots: [],
524
541
  description: "Top app bar. Place first in a Screen.",
@@ -565,7 +582,9 @@ var componentDefinitions = {
565
582
  leadingIcon: o(iconName).describe("Lucide icon on the left; setting it implies leading=icon"),
566
583
  trailing: o(z.enum(["none", "chevron", "toggle", "text", "badge", "icon"])),
567
584
  trailingText: o(z.string()).describe("Text for trailing=text or badge; setting it implies trailing=text"),
568
- trailingIcon: o(iconName).describe("Lucide icon on the right; setting it implies trailing=icon")
585
+ trailingIcon: o(iconName).describe("Lucide icon on the right; setting it implies trailing=icon"),
586
+ menu: o(z.array(menuItem)).describe("A menu for the row (shows \u2026 on the right if nothing else is there), shown when open"),
587
+ open: o(z.boolean()).describe("Show the menu")
569
588
  }),
570
589
  slots: [],
571
590
  description: "Row in a List. Omit title for a placeholder bar.",
@@ -648,6 +667,38 @@ function Box({ style, children }) {
648
667
  for (const [k, v] of Object.entries(style ?? {})) if (v !== void 0 && v !== null) clean[k] = v;
649
668
  return /* @__PURE__ */ jsx("div", { style: clean, children });
650
669
  }
670
+ function Marker({ color }) {
671
+ return /* @__PURE__ */ jsx("div", { style: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: color } });
672
+ }
673
+ function markRoot(root, color) {
674
+ let node = root;
675
+ while (isValidElement(node) && typeof node.type === "function" && node.type !== Box) node = node.type(node.props);
676
+ if (!isValidElement(node)) return root;
677
+ const kids = Children.toArray(node.props.children);
678
+ return cloneElement(node, {}, ...kids, /* @__PURE__ */ jsx(Marker, { color }, "__marker"));
679
+ }
680
+ function withMarkers(reg) {
681
+ return Object.fromEntries(
682
+ Object.entries(reg).map(([name, C]) => [
683
+ name,
684
+ (props) => {
685
+ const root = C(props);
686
+ const color = props.element?.props?.__mark;
687
+ return color && name !== "Screen" ? markRoot(root, color) : root;
688
+ }
689
+ ])
690
+ );
691
+ }
692
+ function withTopLayer(reg, layer) {
693
+ return {
694
+ ...reg,
695
+ Board: (props) => {
696
+ const root = reg.Board(props);
697
+ if (!isValidElement(root)) return root;
698
+ return cloneElement(root, {}, ...Children.toArray(root.props.children), layer);
699
+ }
700
+ };
701
+ }
651
702
  function withDir(children, dir, stretch = false, aligned = false) {
652
703
  return Children.toArray(children).map((c) => isValidElement(c) ? cloneElement(c, { dir, stretch, aligned }) : c);
653
704
  }
@@ -803,7 +854,8 @@ function Screen({ element, children }) {
803
854
  }
804
855
  ),
805
856
  bottom,
806
- overlays.map((o2) => isValidElement(o2) ? cloneElement(o2, { aboveTabBar: bottom.length > 0 }) : o2)
857
+ overlays.map((o2) => isValidElement(o2) ? cloneElement(o2, { aboveTabBar: bottom.length > 0 }) : o2),
858
+ p.__mark ? /* @__PURE__ */ jsx(Marker, { color: p.__mark }) : null
807
859
  ] })
808
860
  ]
809
861
  }
@@ -1188,8 +1240,8 @@ function fieldWidth(width, dir) {
1188
1240
  function FieldLabel({ text }) {
1189
1241
  return text ? /* @__PURE__ */ jsx(Box, { style: { fontSize: 13, fontWeight: 600, color: theme.text }, children: text }) : null;
1190
1242
  }
1191
- function Field({ children, height = 42, top = false, error = false }) {
1192
- return /* @__PURE__ */ jsx(
1243
+ function Field({ children, height = 42, top = false, error = false, mark }) {
1244
+ return /* @__PURE__ */ jsxs(
1193
1245
  Box,
1194
1246
  {
1195
1247
  style: {
@@ -1202,7 +1254,10 @@ function Field({ children, height = 42, top = false, error = false }) {
1202
1254
  backgroundColor: theme.paper,
1203
1255
  fontSize: 15
1204
1256
  },
1205
- children
1257
+ children: [
1258
+ children,
1259
+ mark ? /* @__PURE__ */ jsx(Marker, { color: mark }) : null
1260
+ ]
1206
1261
  }
1207
1262
  );
1208
1263
  }
@@ -1241,7 +1296,7 @@ function Input({ element, dir, colors }) {
1241
1296
  const content = isPassword && shown ? "\u2022".repeat(Math.min(shown.length, 12)) : shown;
1242
1297
  return /* @__PURE__ */ jsxs(Box, { style: { flexDirection: "column", gap: 6, ...fieldWidth(p.width, dir), ...p.grow ? { flexGrow: 1 } : {} }, children: [
1243
1298
  /* @__PURE__ */ jsx(FieldLabel, { text: p.label }),
1244
- /* @__PURE__ */ jsxs(Field, { height: rows > 1 ? rows * 22 + 20 : 42, top: rows > 1, error: !!p.error, children: [
1299
+ /* @__PURE__ */ jsxs(Field, { height: rows > 1 ? rows * 22 + 20 : 42, top: rows > 1, error: !!p.error, mark: p.__markField, children: [
1245
1300
  p.type === "search" ? /* @__PURE__ */ jsx(IconGlyph, { name: "search", size: 18, color: theme.muted }) : null,
1246
1301
  /* @__PURE__ */ jsx(Box, { style: { flexGrow: 1, color: content ? theme.ink : theme.muted }, children: content ?? p.placeholder ?? (isPassword ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "") }),
1247
1302
  isPassword ? /* @__PURE__ */ jsx(IconGlyph, { name: "eye-off", size: 18, color: theme.muted }) : null,
@@ -1254,7 +1309,7 @@ function Select({ element, dir }) {
1254
1309
  const p = element.props;
1255
1310
  return /* @__PURE__ */ jsxs(Box, { style: { flexDirection: "column", gap: 6, ...fieldWidth(p.width, dir), ...p.grow ? { flexGrow: 1 } : {} }, children: [
1256
1311
  /* @__PURE__ */ jsx(FieldLabel, { text: p.label }),
1257
- /* @__PURE__ */ jsxs(Field, { children: [
1312
+ /* @__PURE__ */ jsxs(Field, { mark: p.__markField, children: [
1258
1313
  /* @__PURE__ */ jsx(Box, { style: { flexGrow: 1, color: p.value ? theme.ink : theme.muted }, children: p.value ?? p.placeholder ?? "Select\u2026" }),
1259
1314
  /* @__PURE__ */ jsx(IconGlyph, { name: "chevron-down", size: 18, color: theme.text })
1260
1315
  ] })
@@ -1447,7 +1502,9 @@ var LEADING_ICON = { menu: "menu", back: "arrow-left", close: "x" };
1447
1502
  function NavBar({ element }) {
1448
1503
  const p = element.props;
1449
1504
  const leading = p.leading === "logo" ? /* @__PURE__ */ jsx(Box, { style: { width: 30, height: 30, borderRadius: 8, backgroundColor: theme.fill2 } }) : p.leading && p.leading !== "none" ? /* @__PURE__ */ jsx(IconGlyph, { name: LEADING_ICON[p.leading], size: 22 }) : null;
1450
- const actions = /* @__PURE__ */ jsx(Box, { style: { gap: 18, alignItems: "center", justifyContent: "flex-end" }, children: (p.actions ?? []).map((a, i) => /* @__PURE__ */ jsx(IconGlyph, { name: a, size: 22 }, i)) });
1505
+ const icons2 = [...p.actions ?? []];
1506
+ if (p.menu?.length && !/^(more-|ellipsis)/.test(icons2[icons2.length - 1] ?? "")) icons2.push("more-vertical");
1507
+ const actions = /* @__PURE__ */ jsx(Box, { style: { gap: 18, alignItems: "center", justifyContent: "flex-end" }, children: icons2.map((a, i) => /* @__PURE__ */ jsx(IconGlyph, { name: a, size: 22 }, i)) });
1451
1508
  const title = p.title ? /* @__PURE__ */ jsx(Box, { style: { fontSize: 17, fontWeight: 600 }, children: p.title }) : null;
1452
1509
  const centered = p.align === "center";
1453
1510
  return /* @__PURE__ */ jsx(
@@ -1584,7 +1641,7 @@ function ListItem({ element, colors }) {
1584
1641
  case "icon":
1585
1642
  return /* @__PURE__ */ jsx(IconGlyph, { name: p.trailingIcon ?? "more-horizontal", size: 20, color: theme.muted });
1586
1643
  default:
1587
- return null;
1644
+ return p.menu?.length ? /* @__PURE__ */ jsx(IconGlyph, { name: "more-horizontal", size: 20, color: theme.muted }) : null;
1588
1645
  }
1589
1646
  })();
1590
1647
  return /* @__PURE__ */ jsxs(Box, { style: { minHeight: 60, padding: "10px 0", gap: 14, alignItems: "center" }, children: [
@@ -2032,6 +2089,14 @@ function parseWireframeText(source) {
2032
2089
  c.next();
2033
2090
  c.next();
2034
2091
  const key = t.value;
2092
+ if (key in UNIVERSAL_PROPS) {
2093
+ if (takesUniversalProps(info.name)) props[key] = textWhereExpected(UNIVERSAL_PROPS[key], parseValue(c));
2094
+ else {
2095
+ parseValue(c);
2096
+ issues.push({ line: lineNo, message: `${info.name}: ${universalPropMessage(info.name, key)}` });
2097
+ }
2098
+ continue;
2099
+ }
2035
2100
  if (!(key in info.props)) {
2036
2101
  const elsewhere = belongsElsewhere(info.name, key, true);
2037
2102
  issues.push({ line: lineNo, message: `${info.name} has no prop "${key}"` + (elsewhere ? `: ${elsewhere}` : "") + ` (its props: ${Object.keys(info.props).join(", ")})` });
@@ -2209,6 +2274,155 @@ function scanLine(line, from) {
2209
2274
  import { readFile } from "node:fs/promises";
2210
2275
  import { createRequire } from "node:module";
2211
2276
  import { renderToSvg } from "@json-render/image/render";
2277
+
2278
+ // src/anchors.tsx
2279
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2280
+ var propsOf = (els, id) => els[id].props ?? {};
2281
+ function findAnchors(spec) {
2282
+ const out = [];
2283
+ const visit = (id, screen) => {
2284
+ const el = spec.elements[id];
2285
+ if (!el) return;
2286
+ const p = propsOf(spec.elements, id);
2287
+ if (el.type === "Screen") screen = id;
2288
+ if (screen) {
2289
+ if (el.type === "Select" && p.open) out.push({ id, kind: "options", screen, field: true });
2290
+ if (el.type === "Input" && p.open && p.type === "date") out.push({ id, kind: "calendar", screen, field: true });
2291
+ if (el.type === "Button" && p.open && Array.isArray(p.menu) && p.menu.length) out.push({ id, kind: "menu", screen, field: false });
2292
+ if ((el.type === "ListItem" || el.type === "NavBar") && p.open && Array.isArray(p.menu) && p.menu.length) out.push({ id, kind: "menu", screen, field: false, end: true });
2293
+ }
2294
+ for (const child of el.children ?? []) visit(child, screen);
2295
+ };
2296
+ visit(spec.root, "");
2297
+ const tips = [];
2298
+ const visitTips = (id, screen) => {
2299
+ const el = spec.elements[id];
2300
+ if (!el) return;
2301
+ if (el.type === "Screen") screen = id;
2302
+ if (screen && el.type !== "Screen" && typeof propsOf(spec.elements, id).tooltip === "string") tips.push({ id, kind: "tooltip", screen, field: false });
2303
+ for (const child of el.children ?? []) visitTips(child, screen);
2304
+ };
2305
+ visitTips(spec.root, "");
2306
+ return [...out, ...tips];
2307
+ }
2308
+ var MARK = (i) => `#fe${(i >> 8).toString(16).padStart(2, "0")}${(i & 255).toString(16).padStart(2, "0")}`;
2309
+ function tagForMeasuring(spec, anchors) {
2310
+ const tagged = structuredClone(spec);
2311
+ const colors = /* @__PURE__ */ new Map();
2312
+ let n = 1;
2313
+ const tag = (id, field) => {
2314
+ const key = field ? `${id}#field` : id;
2315
+ if (colors.has(key)) return;
2316
+ const color = MARK(n++);
2317
+ colors.set(key, color);
2318
+ const el = tagged.elements[id];
2319
+ el.props = { ...el.props ?? {}, [field ? "__markField" : "__mark"]: color };
2320
+ };
2321
+ for (const a of anchors) {
2322
+ tag(a.screen, false);
2323
+ tag(a.id, a.field);
2324
+ }
2325
+ return { tagged, colors };
2326
+ }
2327
+ function readMarkers(svg, colors) {
2328
+ const rects = [...svg.matchAll(/<rect ([^>]*?)\/?>/g)].map(
2329
+ (m) => Object.fromEntries([...m[1].matchAll(/([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]))
2330
+ );
2331
+ const boxes = {};
2332
+ for (const [id, color] of colors) {
2333
+ const r = rects.find((a) => a.fill === color);
2334
+ if (r) boxes[id] = { x: +r.x, y: +r.y, w: +r.width, h: +r.height };
2335
+ }
2336
+ return boxes;
2337
+ }
2338
+ function place(anchor, size, screen, opts) {
2339
+ const room = { above: anchor.y - screen.y - opts.gap, below: screen.y + screen.h - (anchor.y + anchor.h) - opts.gap };
2340
+ const other = opts.prefer === "below" ? "above" : "below";
2341
+ const side = room[opts.prefer] >= size.h || room[opts.prefer] >= room[other] ? opts.prefer : other;
2342
+ const y = side === "below" ? anchor.y + anchor.h + opts.gap : anchor.y - opts.gap - size.h;
2343
+ const x0 = opts.align === "start" ? anchor.x : opts.align === "end" ? anchor.x + anchor.w - size.w : anchor.x + anchor.w / 2 - size.w / 2;
2344
+ const x = Math.max(screen.x + 8, Math.min(x0, screen.x + screen.w - 8 - size.w));
2345
+ return { x: x - screen.x, y: y - screen.y, bottom: screen.y + screen.h - (anchor.y - opts.gap), side };
2346
+ }
2347
+ var ROW = 36;
2348
+ function ListCard({ items, width, selected, c }) {
2349
+ return /* @__PURE__ */ jsx2("div", { style: { display: "flex", flexDirection: "column", width, padding: 4, backgroundColor: theme.paper, border: `1.5px solid ${theme.lineStrong}`, borderRadius: 8, boxShadow: "0 8px 24px rgba(0,0,0,0.18)" }, children: items.map((item, i) => {
2350
+ const on = selected != null && item.label === selected;
2351
+ return /* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "center", gap: 10, height: ROW - 4, padding: "0 10px", borderRadius: 6, fontSize: 15, color: theme.ink, backgroundColor: on ? theme.fill : "transparent", fontWeight: on ? 600 : 400 }, children: [
2352
+ item.icon ? /* @__PURE__ */ jsx2(IconGlyph, { name: item.icon, size: 16, color: theme.text }) : null,
2353
+ /* @__PURE__ */ jsx2("div", { style: { display: "flex", flexGrow: 1 }, children: item.label }),
2354
+ on ? /* @__PURE__ */ jsx2(IconGlyph, { name: "check", size: 16, color: c.accentText }) : null
2355
+ ] }, i);
2356
+ }) });
2357
+ }
2358
+ function parseDate(value) {
2359
+ const s = String(value ?? "").trim();
2360
+ const MONTHS2 = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
2361
+ const iso = s.match(/^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?/);
2362
+ if (iso) return { month: `${MONTHS2[+iso[2] - 1]} ${iso[1]}`, day: iso[3] ? +iso[3] : void 0 };
2363
+ const name = s.match(/([A-Za-z]{3,})/)?.[1];
2364
+ const index = name ? MONTHS2.findIndex((m) => m.toLowerCase().startsWith(name.toLowerCase().slice(0, 3))) : -1;
2365
+ const year = s.match(/\b(\d{4})\b/)?.[1];
2366
+ if (index < 0 || !year) return null;
2367
+ const day2 = s.replace(year, "").match(/\b(\d{1,2})\b/)?.[1];
2368
+ return { month: `${MONTHS2[index]} ${year}`, day: day2 ? +day2 : void 0 };
2369
+ }
2370
+ function Tooltip({ text, side, width, arrowX }) {
2371
+ const arrow = /* @__PURE__ */ jsx2("svg", { width: "12", height: "6", viewBox: "0 0 12 6", style: { flexShrink: 0, marginLeft: Math.max(6, Math.min(arrowX, width - 6)) - 6 }, children: /* @__PURE__ */ jsx2("path", { d: side === "above" ? "M0 0 L6 6 L12 0 Z" : "M0 6 L6 0 L12 6 Z", fill: theme.ink }) });
2372
+ return /* @__PURE__ */ jsxs2("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-start", width }, children: [
2373
+ side === "below" ? arrow : null,
2374
+ /* @__PURE__ */ jsx2("div", { style: { display: "flex", justifyContent: "center", width, padding: "6px 10px", borderRadius: 6, backgroundColor: theme.ink, color: theme.onPrimary, fontSize: 13, lineHeight: 1.35, textAlign: "center" }, children: text }),
2375
+ side === "above" ? arrow : null
2376
+ ] });
2377
+ }
2378
+ function tooltipSize(text) {
2379
+ const width = Math.min(240, Math.ceil(text.length * 7.4) + 24);
2380
+ const lines = Math.max(1, Math.ceil(text.length * 7.4 / 216));
2381
+ return { w: width, h: lines * 18 + 12 + 6 };
2382
+ }
2383
+ function overlayLayer(spec, anchors, boxes, c, Calendar2) {
2384
+ const byScreen = /* @__PURE__ */ new Map();
2385
+ for (const a of anchors) {
2386
+ const screen = boxes[a.screen];
2387
+ const anchor = boxes[a.field ? `${a.id}#field` : a.id];
2388
+ if (!screen || !anchor) continue;
2389
+ const p = propsOf(spec.elements, a.id);
2390
+ let node = null;
2391
+ let pos = null;
2392
+ if (a.kind === "options") {
2393
+ const options = p.options?.length ? p.options : [p.value ?? "Option 1", "Option 2", "Option 3"];
2394
+ const size = { w: anchor.w, h: options.length * ROW + 8 };
2395
+ pos = place(anchor, size, screen, { prefer: "below", align: "start", gap: 4 });
2396
+ node = /* @__PURE__ */ jsx2(ListCard, { items: options.map((label) => ({ label })), width: size.w, selected: p.value, c });
2397
+ } else if (a.kind === "menu") {
2398
+ const items = p.menu.map((i) => typeof i === "string" ? { label: i } : i);
2399
+ const width = a.end ? 200 : Math.max(180, anchor.w);
2400
+ const inRightHalf = anchor.x + anchor.w / 2 > screen.x + screen.w / 2;
2401
+ pos = place(anchor, { w: width, h: items.length * ROW + 8 }, screen, { prefer: "below", align: a.end || inRightHalf ? "end" : "start", gap: a.end ? -6 : 4 });
2402
+ node = /* @__PURE__ */ jsx2(ListCard, { items, width, c });
2403
+ } else if (a.kind === "calendar") {
2404
+ const date = parseDate(p.value);
2405
+ const { start, days } = monthGrid(date?.month);
2406
+ const weeks = Math.ceil((start + days) / 7);
2407
+ pos = place(anchor, { w: 296, h: 92 + weeks * 38 }, screen, { prefer: "below", align: "start", gap: 4 });
2408
+ node = /* @__PURE__ */ jsx2("div", { style: { display: "flex", boxShadow: "0 8px 24px rgba(0,0,0,0.18)", borderRadius: 12 }, children: Calendar2({ element: { type: "Calendar", props: { month: date?.month ?? "Month", selected: date?.day } }, colors: c }) });
2409
+ } else {
2410
+ const size = tooltipSize(p.tooltip);
2411
+ pos = place(anchor, size, screen, { prefer: "above", align: "center", gap: 4 });
2412
+ node = /* @__PURE__ */ jsx2(Tooltip, { text: p.tooltip, side: pos.side, width: size.w, arrowX: anchor.x + anchor.w / 2 - (screen.x + pos.x) });
2413
+ }
2414
+ const list = byScreen.get(a.screen) ?? [];
2415
+ const vertical = pos.side === "above" ? { bottom: pos.bottom } : { top: pos.y };
2416
+ list.push(/* @__PURE__ */ jsx2("div", { style: { position: "absolute", left: pos.x, ...vertical, display: "flex" }, children: node }, `${a.id}-${a.kind}`));
2417
+ byScreen.set(a.screen, list);
2418
+ }
2419
+ return /* @__PURE__ */ jsx2("div", { style: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", display: "flex" }, children: [...byScreen].map(([screenId, nodes]) => {
2420
+ const s = boxes[screenId];
2421
+ return /* @__PURE__ */ jsx2("div", { style: { position: "absolute", left: s.x, top: s.y, width: s.w, height: s.h, overflow: "hidden", display: "flex" }, children: nodes }, screenId);
2422
+ }) }, "__overlays");
2423
+ }
2424
+
2425
+ // src/render.ts
2212
2426
  var fontFile = (name) => createRequire(import.meta.url).resolve(`@fontsource/inter/files/${name}`);
2213
2427
  var fontCache = null;
2214
2428
  function loadFonts() {
@@ -2271,6 +2485,14 @@ function checkSpec(spec) {
2271
2485
  }
2272
2486
  }
2273
2487
  for (const key of Object.keys(el.props ?? {})) {
2488
+ if (key in UNIVERSAL_PROPS) {
2489
+ if (!takesUniversalProps(el.type)) issues.push(`${id}: ${universalPropMessage(el.type, key)}`);
2490
+ else {
2491
+ const r = UNIVERSAL_PROPS[key].safeParse(el.props[key]);
2492
+ if (!r.success) issues.push(`${id}.props.${key}: ${r.error.issues[0].message}`);
2493
+ }
2494
+ continue;
2495
+ }
2274
2496
  if (!(key in def.props.shape)) {
2275
2497
  const elsewhere = belongsElsewhere(el.type, key, true);
2276
2498
  issues.push(`${id}.props: ${el.type} has no prop "${key}"${elsewhere ? `: ${elsewhere}` : ""}`);
@@ -2323,6 +2545,9 @@ function checkSpec(spec) {
2323
2545
  say(`value has ${String(p.value).length} characters but the code has ${p.digits ?? 6} boxes (digits)`);
2324
2546
  }
2325
2547
  if (el.type === "Input" && p.digits != null && p.type !== "code") say("digits only applies to type=code");
2548
+ if (el.type === "Input" && p.open && p.type !== "date") say("open shows a date picker, so it needs type=date");
2549
+ if ((el.type === "Button" || el.type === "ListItem" || el.type === "NavBar") && p.open && !(Array.isArray(p.menu) && p.menu.length)) say("open shows the menu; add menu=[\u2026] with its items");
2550
+ if (el.type === "Select" && p.open && !(Array.isArray(p.options) && p.options.length)) say("open shows the options list; add options=[\u2026] with the choices");
2326
2551
  }
2327
2552
  if (el.type === "Table") {
2328
2553
  const { columns, data } = el.props ?? {};
@@ -2388,13 +2613,21 @@ async function renderWireframeSvg(spec, opts = {}) {
2388
2613
  if (issues.length) throw new SpecError(issues);
2389
2614
  }
2390
2615
  const { width, height } = boardSize(spec);
2391
- return renderToSvg(spec, {
2392
- registry: withPalette(paletteFor(spec.elements[spec.root]?.props?.accent)),
2393
- includeStandard: false,
2394
- fonts: await loadFonts(),
2395
- width,
2396
- height
2397
- });
2616
+ const palette = paletteFor(spec.elements[spec.root]?.props?.accent);
2617
+ const registry2 = withPalette(palette);
2618
+ 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)));
2622
+ }
2623
+ async function measureAnchors(spec) {
2624
+ const anchors = findAnchors(spec);
2625
+ if (!anchors.length) return { anchors, boxes: {} };
2626
+ const { width, height } = boardSize(spec);
2627
+ const registry2 = withPalette(paletteFor());
2628
+ const { tagged, colors } = tagForMeasuring(spec, anchors);
2629
+ const svg = await renderToSvg(tagged, { registry: withMarkers(registry2), includeStandard: false, fonts: await loadFonts(), width, height });
2630
+ return { anchors, boxes: readMarkers(svg, colors) };
2398
2631
  }
2399
2632
  async function renderWireframePng(spec, opts = {}) {
2400
2633
  const svg = await renderWireframeSvg(spec, opts);
@@ -2613,7 +2846,8 @@ var RULES = `## Rules
2613
2846
  5. Modal, Drawer and Toast are overlays. They must be direct children of a Screen.
2614
2847
  6. This is a low-fidelity wireframe. Prefer placeholders (Image boxes, Text with lines) over invented copy unless the copy matters.
2615
2848
  7. Only use the components and props listed below. All props are optional unless marked required.
2616
- 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.`;
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.`;
2617
2851
  var TEXT_FORMAT = `## Output format: wireframe text
2618
2852
  One element per line. Indent children two spaces under their parent. The first line is the board.
2619
2853
 
@@ -2662,6 +2896,8 @@ export {
2662
2896
  DEVICES,
2663
2897
  ACCENTS,
2664
2898
  iconNames,
2899
+ UNIVERSAL_PROPS,
2900
+ takesUniversalProps,
2665
2901
  componentDefinitions,
2666
2902
  catalog,
2667
2903
  registry,
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  printWireframeText,
7
7
  renderWireframe,
8
8
  wireframePrompt
9
- } from "./chunks/chunk-NREXWHAE.js";
9
+ } from "./chunks/chunk-B6KIVOSH.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import { readFile, writeFile } from "node:fs/promises";
@@ -1,4 +1,4 @@
1
- import React from "react";
1
+ import React, { type ReactNode } from "react";
2
2
  import { type Palette } from "./colors.js";
3
3
  import type { ComponentRenderProps } from "@json-render/image";
4
4
  /**
@@ -16,6 +16,14 @@ type Props<P = Record<string, any>> = ComponentRenderProps<P> & {
16
16
  };
17
17
  /** A registry whose components all receive this board's palette as `colors`. */
18
18
  export declare function withPalette(colors: Palette): typeof registry;
19
+ /**
20
+ * A registry for the measuring pass: an element whose props carry `__mark` (a color) gets a marker on
21
+ * its outer box. Fields and screens place their own (`__markField`, Screen's frame), since an overlay
22
+ * hangs from the field, not the label, and stays inside the device frame.
23
+ */
24
+ export declare function withMarkers(reg: typeof registry): typeof registry;
25
+ /** A registry whose Board also draws `layer` on top of everything (the anchored overlays). */
26
+ export declare function withTopLayer(reg: typeof registry, layer: ReactNode): typeof registry;
19
27
  export declare function IconGlyph({ name, size, color, strokeWidth }: {
20
28
  name?: string | null;
21
29
  size?: number;
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  upgradeSpec,
21
21
  upgradeWireframe,
22
22
  wireframePrompt
23
- } from "./chunks/chunk-NREXWHAE.js";
23
+ } from "./chunks/chunk-B6KIVOSH.js";
24
24
 
25
25
  // src/share.ts
26
26
  import { deflateRawSync, inflateRawSync } from "node:zlib";
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ACCENTS,
3
3
  PRIMARY_PROP,
4
+ UNIVERSAL_PROPS,
4
5
  bareWords,
5
6
  boardLayout,
6
7
  compileWireframe,
@@ -10,8 +11,9 @@ import {
10
11
  iconNames,
11
12
  renderWireframePng,
12
13
  renderWireframeSvg,
14
+ takesUniversalProps,
13
15
  wireframePrompt
14
- } from "../chunks/chunk-NREXWHAE.js";
16
+ } from "../chunks/chunk-B6KIVOSH.js";
15
17
 
16
18
  // src/playground/index.ts
17
19
  import { createServer } from "node:http";
@@ -54,6 +56,7 @@ function languageData() {
54
56
  const components = Object.entries(componentDefinitions).map(([type, def]) => {
55
57
  const shape = def.props.shape;
56
58
  const props = Object.entries(shape).map(([name, schema]) => describe(name, schema));
59
+ if (takesUniversalProps(type)) for (const [name, schema] of Object.entries(UNIVERSAL_PROPS)) props.push(describe(name, schema));
57
60
  if (type === "Icon") props.find((p) => p.name === "name").icon = true;
58
61
  const words = bareWords(type);
59
62
  const off = words.booleans.flatMap((b) => b === "on" ? ["off"] : b === "checked" ? ["unchecked"] : [`no-${b}`]);
@@ -258,7 +261,13 @@ var EXAMPLES = {
258
261
  button secondary "Back" leadingIcon=chevron-left
259
262
  button primary "Next" trailingIcon=chevron-right
260
263
  button primary "Full width" fullWidth
261
- text "With a board accent, primary buttons fill with it and ghost buttons use it for text." sm muted`,
264
+ text "With a board accent, primary buttons fill with it and ghost buttons use it for text." sm muted
265
+ screen custom "menu and open" width=380 height=300
266
+ stack row justify=between align=center
267
+ heading "Notes" level=3
268
+ button ghost leadingIcon=more-horizontal open menu=[{label=Rename icon=pencil}, {label=Duplicate icon=copy}, Share, {label=Delete icon=trash-2}]
269
+ text lines=3
270
+ text "menu lists the items; open shows them over the screen." sm muted`,
262
271
  Input: `board
263
272
  screen custom width=420 height=760
264
273
  input "Email" placeholder="you@example.com" helper="We never share it"
@@ -270,7 +279,10 @@ var EXAMPLES = {
270
279
  input placeholder="grow fills the row" grow
271
280
  button primary "Send"
272
281
  input "Check-in" type=date value="Oct 14, 2026"
273
- input "Verification code" type=code value="4821" helper="Sent to (555) 010-2400"`,
282
+ input "Verification code" type=code value="4821" helper="Sent to (555) 010-2400"
283
+ screen custom "type=date open" width=420 height=440
284
+ input "Check-in" type=date value="Oct 14, 2026" open
285
+ input "Check-out" type=date value="Oct 18, 2026"`,
274
286
  Checkbox: `board
275
287
  screen custom width=300 height=100
276
288
  checkbox "Checked" checked
@@ -295,7 +307,10 @@ var EXAMPLES = {
295
307
  select "Size" placeholder="Choose one" width=160
296
308
  stack row gap=12
297
309
  input "City" placeholder="Monterrey" grow
298
- select "State" value="Nuevo Le\xF3n" grow`,
310
+ select "State" value="Nuevo Le\xF3n" grow
311
+ screen custom "options and open" width=380 height=260
312
+ select "Country" value="Mexico" open options=[Canada, Mexico, United States, Spain]
313
+ input "City" placeholder="Monterrey"`,
299
314
  Slider: `board
300
315
  screen custom width=380 height=200
301
316
  slider "Volume" value=30
@@ -319,7 +334,10 @@ var EXAMPLES = {
319
334
  navbar "leading=menu" leading=menu actions=[search, bell]
320
335
  navbar "leading=back, centered" leading=back align=center actions=[share]
321
336
  navbar "leading=close" leading=close
322
- navbar "leading=logo" leading=logo actions=[user]`,
337
+ navbar "leading=logo" leading=logo actions=[user]
338
+ screen custom "menu and open" width=380 height=240 padding=0 gap=0
339
+ navbar "Inbox" leading=menu actions=[search] open menu=[Mark all as read, {label=Settings icon=settings}, {label=Sign out icon=log-out}]
340
+ text lines=4`,
323
341
  TabBar: `board
324
342
  screen phone "Tab bar sits at the bottom"
325
343
  heading "Home"
@@ -352,7 +370,13 @@ var EXAMPLES = {
352
370
  listitem "leading=image" subtitle="trailing=badge" leading=image trailing=badge trailingText="3"
353
371
  listitem "leading=checkbox" leading=checkbox trailing=toggle
354
372
  listitem "trailingIcon=trash-2" trailingIcon=trash-2
355
- listitem leading=image trailing=chevron`,
373
+ listitem leading=image trailing=chevron
374
+ screen custom "menu and open" width=400 height=330
375
+ list
376
+ listitem "Report.pdf" subtitle="2.4 MB" leadingIcon=file-text
377
+ listitem "Budget.xlsx" subtitle="380 KB" leadingIcon=sheet open menu=[{label=Rename icon=pencil}, {label=Move icon=folder}, {label=Delete icon=trash-2}]
378
+ listitem "Notes.txt" subtitle="12 KB" leadingIcon=file
379
+ listitem "Photo.jpg" subtitle="1.1 MB" leadingIcon=image`,
356
380
  Table: `board
357
381
  screen custom width=520 height=400
358
382
  table columns=[Name, Role, Status] data=[["Ana Ruiz", Admin, Active], ["Ben Cho", Editor, Invited]]
package/dist/prompt.d.ts CHANGED
@@ -13,7 +13,7 @@ export declare function componentDocs(): {
13
13
  props: PropDoc[];
14
14
  }[];
15
15
  export declare function componentReference(): string;
16
- export declare const RULES = "## Rules\n1. The top element is a Board. The Board's children are Screens, plus optional Notes beside them.\n2. Each Screen is one view of the product. Use several Screens to show several views or states.\n3. A Screen lays out its children top to bottom. Use Stack (direction row or column) and Grid to arrange content.\n4. NavBar is pinned to the top of its Screen and TabBar to the bottom.\n5. Modal, Drawer and Toast are overlays. They must be direct children of a Screen.\n6. This is a low-fidelity wireframe. Prefer placeholders (Image boxes, Text with lines) over invented copy unless the copy matters.\n7. Only use the components and props listed below. All props are optional unless marked required.\n8. 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.";
16
+ export declare const RULES = "## Rules\n1. The top element is a Board. The Board's children are Screens, plus optional Notes beside them.\n2. Each Screen is one view of the product. Use several Screens to show several views or states.\n3. A Screen lays out its children top to bottom. Use Stack (direction row or column) and Grid to arrange content.\n4. NavBar is pinned to the top of its Screen and TabBar to the bottom.\n5. Modal, Drawer and Toast are overlays. They must be direct children of a Screen.\n6. This is a low-fidelity wireframe. Prefer placeholders (Image boxes, Text with lines) over invented copy unless the copy matters.\n7. Only use the components and props listed below. All props are optional unless marked required.\n8. 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.\n9. 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.";
17
17
  export declare const TEXT_FORMAT = "## Output format: wireframe text\nOne element per line. Indent children two spaces under their parent. The first line is the board.\n\nA line is the component name in lowercase, followed by arguments separated by spaces:\n- \"a quoted string\" sets the component's main text prop (marked \"main text\" below)\n- a bare word that is one of the component's option values sets that option: phone, desktop, primary, ghost, row, sm, left, bottom, password, \u2026\n- a bare prop name sets that component's yes/no prop to true, only on components that have it (see each component's props below): checked (checkbox, radio), fullWidth (button), muted (text), grow (stack, card, input, list). `off` and `unchecked` set on/checked to false\n- key=value sets any prop. Values: \"string\", number, true/false, bare word, [list, of, values], {key=value key=value}\n\nComments: a line that starts with # is a comment. A comment must be on its own line, never after an element on the same line. Anywhere else, # is ordinary text: [#1001, #1002], \"Order #12345\", accent=#1a73e8.\n\nLists: 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].\n\nIf a bare word could mean more than one prop, write it as key=value.";
18
18
  export declare const EXAMPLE_REQUEST = "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.";
19
19
  /** The system prompt for writing wireframe text. */
package/dist/render.d.ts CHANGED
@@ -35,6 +35,19 @@ export interface RenderWireframeOptions {
35
35
  skipValidation?: boolean;
36
36
  }
37
37
  export declare function renderWireframeSvg(spec: Spec, opts?: RenderWireframeOptions): Promise<string>;
38
+ /**
39
+ * The measuring pass: the board's anchored elements (and their screens) and their boxes on the
40
+ * canvas, keyed by element id (`<id>#field` for a field). No render at all when there are none.
41
+ */
42
+ export declare function measureAnchors(spec: Spec): Promise<{
43
+ anchors: import("./anchors.js").Anchor[];
44
+ boxes: Record<string, {
45
+ x: number;
46
+ y: number;
47
+ w: number;
48
+ h: number;
49
+ }>;
50
+ }>;
38
51
  export declare function renderWireframePng(spec: Spec, opts?: RenderWireframeOptions & {
39
52
  scale?: number;
40
53
  }): Promise<Buffer<ArrayBufferLike>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsquare",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Wireframes in plain text: easy for LLMs to write, fast to render as SVG or PNG.",
5
5
  "keywords": [
6
6
  "wireframe",
@@ -69,7 +69,7 @@
69
69
  "dependencies": {
70
70
  "@fontsource/inter": "5.3.0",
71
71
  "@json-render/core": "^0.21.0",
72
- "@json-render/image": "^0.21.0",
72
+ "@json-render/image": "0.21.0",
73
73
  "@resvg/resvg-js": "^2.6.2",
74
74
  "@types/node": ">=20",
75
75
  "@types/react": "^19.3.0",
@@ -51,6 +51,7 @@ If a bare word could mean more than one prop, write it as key=value.
51
51
  6. This is a low-fidelity wireframe. Prefer placeholders (Image boxes, Text with lines) over invented copy unless the copy matters.
52
52
  7. Only use the components and props listed below. All props are optional unless marked required.
53
53
  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.
54
+ 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.
54
55
 
55
56
  ## Example
56
57
 
@@ -121,13 +121,15 @@ Small pill label for counts or statuses.
121
121
  - tone: "neutral" | "success" | "warning" | "danger" — Status color. Default neutral (the accent for solid badges)
122
122
 
123
123
  ### Button
124
- Button. Primary is filled, secondary is outlined, ghost is text only.
124
+ Button. Primary is filled, secondary is outlined, ghost is text only. With menu and open, a dropdown menu.
125
125
  - label: string (main text)
126
126
  - variant: "primary" | "secondary" | "ghost"
127
127
  - size: "sm" | "md" | "lg"
128
128
  - leadingIcon: string — Lucide icon before the label, e.g. chevron-left for Back
129
129
  - trailingIcon: string — Lucide icon after the label, e.g. chevron-right for Next
130
130
  - fullWidth: boolean
131
+ - menu: array of string | { label: string, icon: string } — Dropdown menu items, shown below the button when open
132
+ - open: boolean — Show the menu
131
133
 
132
134
  ### Input
133
135
  Text field with optional label. Set multiline for a textarea.
@@ -136,6 +138,7 @@ Text field with optional label. Set multiline for a textarea.
136
138
  - value: string
137
139
  - type: "text" | "password" | "search" | "email" | "date" | "code" — date shows a calendar icon; code draws one box per digit
138
140
  - digits: number — Boxes for type=code, default 6
141
+ - open: boolean — type=date: show the calendar below the field
139
142
  - multiline: number — Number of rows; 2 or more makes a textarea
140
143
  - helper: string
141
144
  - error: boolean — Validation error: red border and red helper text
@@ -158,10 +161,12 @@ On/off switch with label on the left.
158
161
  - on: boolean
159
162
 
160
163
  ### Select
161
- Dropdown field (closed state).
164
+ Dropdown field. With options and open, the list shows below it.
162
165
  - label: string (main text)
163
166
  - value: string
164
167
  - placeholder: string
168
+ - options: array of string — The choices, shown below the field when open
169
+ - open: boolean — Show the options list
165
170
  - grow: boolean — Fill the remaining space in a row
166
171
  - width: number | string — Fixed width, e.g. 320. Default fills the space.
167
172
 
@@ -192,6 +197,8 @@ Top app bar. Place first in a Screen.
192
197
  - leading: "none" | "menu" | "back" | "close" | "logo"
193
198
  - actions: array of string — Icon names shown on the right
194
199
  - align: "left" | "center"
200
+ - menu: array of string | { label: string, icon: string } — A menu under the last action (adds ⋮ if needed), shown when open
201
+ - open: boolean — Show the menu
195
202
 
196
203
  ### TabBar
197
204
  Bottom tab bar for mobile. Place last in a Screen (before overlays).
@@ -222,6 +229,8 @@ Row in a List. Omit title for a placeholder bar.
222
229
  - trailing: "none" | "chevron" | "toggle" | "text" | "badge" | "icon"
223
230
  - trailingText: string — Text for trailing=text or badge; setting it implies trailing=text
224
231
  - trailingIcon: string — Lucide icon on the right; setting it implies trailing=icon
232
+ - menu: array of string | { label: string, icon: string } — A menu for the row (shows … on the right if nothing else is there), shown when open
233
+ - open: boolean — Show the menu
225
234
 
226
235
  ### Table
227
236
  Table with headers. Rows are placeholder bars unless `data` is given.