@typecad/ui 1.0.0-alpha.13 → 1.0.0-alpha.14

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/dist/cli.js CHANGED
@@ -7,25 +7,28 @@
7
7
  // npx @typecad/ui --version Show the installed version
8
8
  // ---------------------------------------------------------------------------
9
9
  import { runIntegrationWizard } from "./wizard/integration-wizard.js";
10
- const USAGE = `
11
- @typecad/ui — HTML/CSS-driven graphics for microcontrollers
12
-
13
- Usage:
14
- npx @typecad/ui --config Configure a display (+ touch) for your
15
- cuttlefish project interactively. Writes the
16
- \`display\` section of cuttlefish.config.ts.
17
-
18
- Options:
19
- --config Run the integration wizard
20
- --help, -h Show this help
21
- --version, -v Print the installed @typecad/ui version
22
-
23
- The wizard asks which display module you are using (ILI9341 / ST7796S SPI TFT,
24
- SSD1309 I2C OLED, desktop simulator, or custom), then walks through bus pins,
25
- SPI/I2C speed, rotation, and touch controller wiring with hardware-aware
26
- defaults. It never touches the rest of your cuttlefish.config.ts.
27
-
28
- Docs: https://github.com/justind000/typecode/tree/main/packages/ui
10
+ const USAGE = `
11
+ @typecad/ui — HTML/CSS-driven graphics for microcontrollers
12
+
13
+ Usage:
14
+ npx @typecad/ui --config Configure a display (+ touch) for your
15
+ cuttlefish project interactively. Writes the
16
+ \`display\` section of cuttlefish.config.ts
17
+ and a \`preview\` npm script.
18
+
19
+ Options:
20
+ --config Run the integration wizard
21
+ --help, -h Show this help
22
+ --version, -v Print the installed @typecad/ui version
23
+
24
+ The wizard asks which display module you are using (ILI9341 / ST7796S SPI TFT,
25
+ SSD1309 I2C OLED, desktop simulator, or custom), then walks through bus pins,
26
+ SPI/I2C speed, rotation, and touch controller wiring with hardware-aware
27
+ defaults. It never touches the rest of your cuttlefish.config.ts, and adds a
28
+ \`preview\` script to package.json so the desktop preview renderer starts with
29
+ \`npm run preview\`.
30
+
31
+ Docs: https://github.com/justind000/typecode/tree/main/packages/ui
29
32
  `.trim();
30
33
  function printUsage() {
31
34
  console.log(USAGE);
@@ -609,7 +609,6 @@ export class PreviewUIRuntime {
609
609
  return a.index - b.index;
610
610
  }
611
611
  navigate(screenIdx) {
612
- this.onDiagnostics?.(`NAV -> screen ${screenIdx}`);
613
612
  const next = Math.trunc(Number(screenIdx));
614
613
  if (!Number.isFinite(next) || next < 0 || next >= this.screenCount || next === this.activeScreen)
615
614
  return;
@@ -78,10 +78,16 @@ function borderStyle(style) {
78
78
  return 0;
79
79
  }
80
80
  function borderWidthOf(style) {
81
+ // A none-style border renders nothing and must consume no space either:
82
+ // the draw path subtracts borderWidth*2 from the text max width, so a
83
+ // residual UA width on a borderless kit button wrapped its last glyph
84
+ // ("Primar" / "y"). Style none wins over any declared width.
85
+ if (borderStyle(style) === 0)
86
+ return 0;
81
87
  const px = cssPx(style.borderWidth);
82
88
  if (px > 0)
83
89
  return Math.max(1, Math.min(8, px));
84
- return borderStyle(style) === 0 ? 0 : 1;
90
+ return 1;
85
91
  }
86
92
  /** Per-side border width: the per-side field wins when set, else falls back to
87
93
  * the uniform borderWidth. Used for border-left/top/right/bottom support. */
@@ -1237,10 +1237,16 @@ static inline uint8_t ui_render_screen_bands(int16_t rx, int16_t ry, int16_t rw,
1237
1237
  int16_t origBoxH = __ui_nodes[c].box.h;
1238
1238
  __ui_nodes[c].box.x = static_cast<int16_t>(origBoxX - rx);
1239
1239
  __ui_nodes[c].box.y = static_cast<int16_t>(origBoxY - ry - bandTop);
1240
- // Clamp the face to the scroll viewport (band-local coords). The
1241
- // shifted box/draw geometry stays within the candidate's clip bounds;
1242
- // text wrapping and layout metrics shrink with the box. Fully clipped
1243
- // faces drop out here.
1240
+ // Clamp the face to the scroll viewport (band-local DRAW coords). The
1241
+ // clamp must operate on the node's DRAW position — box.y is
1242
+ // content-local and the ancestor scroll is subtracted later, inside
1243
+ // ui_draw_y_for_node, so comparing box.y against display-space clip
1244
+ // bounds passed scrolled content whose CONTENT coordinate numerically
1245
+ // fell inside the viewport: a button scrolled up past the viewport top
1246
+ // painted over the header, and the smearing stuck (nothing repaints
1247
+ // the header afterwards). Clamp the draw position and shift the box by
1248
+ // the delta; text wrapping and layout metrics shrink with the box.
1249
+ // Fully clipped faces drop out here.
1244
1250
  {
1245
1251
  // int32 math: the unclipped sentinels are ±32767 and the band-local
1246
1252
  // conversion subtracts region/band offsets — int16 arithmetic wraps
@@ -1251,29 +1257,25 @@ static inline uint8_t ui_render_screen_bands(int16_t rx, int16_t ry, int16_t rw,
1251
1257
  int32_t clipBotL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipBottom) - ry - bandTop;
1252
1258
  int32_t clipLeftL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipLeft) - rx;
1253
1259
  int32_t clipRightL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipRight) - rx;
1254
- int16_t boxY0 = __ui_nodes[c].box.y;
1255
- if (boxY0 < clipTopL) {
1256
- __ui_nodes[c].box.y = static_cast<int16_t>(clipTopL);
1257
- __ui_nodes[c].box.h = static_cast<int16_t>(__ui_nodes[c].box.h - (clipTopL - boxY0));
1258
- }
1259
- if (static_cast<int32_t>(__ui_nodes[c].box.y) + __ui_nodes[c].box.h > clipBotL) {
1260
- __ui_nodes[c].box.h = static_cast<int16_t>(clipBotL - __ui_nodes[c].box.y);
1261
- }
1262
- int16_t boxX0 = __ui_nodes[c].box.x;
1263
- if (boxX0 < clipLeftL) {
1264
- __ui_nodes[c].box.x = static_cast<int16_t>(clipLeftL);
1265
- __ui_nodes[c].box.w = static_cast<int16_t>(__ui_nodes[c].box.w - (clipLeftL - boxX0));
1266
- }
1267
- if (static_cast<int32_t>(__ui_nodes[c].box.x) + __ui_nodes[c].box.w > clipRightL) {
1268
- __ui_nodes[c].box.w = static_cast<int16_t>(clipRightL - __ui_nodes[c].box.x);
1269
- }
1270
- if (__ui_nodes[c].box.w <= 0 || __ui_nodes[c].box.h <= 0) {
1260
+ int16_t dispY0 = ui_draw_y_for_node(c);
1261
+ int16_t dispX0 = ui_draw_x_for_node(c);
1262
+ int32_t y0 = dispY0 < clipTopL ? clipTopL : dispY0;
1263
+ int32_t y1 = static_cast<int32_t>(dispY0) + __ui_nodes[c].box.h > clipBotL ? clipBotL : static_cast<int32_t>(dispY0) + __ui_nodes[c].box.h;
1264
+ int32_t x0 = dispX0 < clipLeftL ? clipLeftL : dispX0;
1265
+ int32_t x1 = static_cast<int32_t>(dispX0) + __ui_nodes[c].box.w > clipRightL ? clipRightL : static_cast<int32_t>(dispX0) + __ui_nodes[c].box.w;
1266
+ if (x1 - x0 <= 0 || y1 - y0 <= 0) {
1271
1267
  __ui_nodes[c].box.x = origBoxX;
1272
1268
  __ui_nodes[c].box.y = origBoxY;
1273
1269
  __ui_nodes[c].box.w = origBoxW;
1274
1270
  __ui_nodes[c].box.h = origBoxH;
1275
1271
  continue;
1276
1272
  }
1273
+ // box.y/box.x already carry the band shift; add the clamp delta on
1274
+ // top (y0 - dispY0 is 0 when unclipped, preserving the shift).
1275
+ __ui_nodes[c].box.y = static_cast<int16_t>(__ui_nodes[c].box.y + (y0 - dispY0));
1276
+ __ui_nodes[c].box.h = static_cast<int16_t>(y1 - y0);
1277
+ __ui_nodes[c].box.x = static_cast<int16_t>(__ui_nodes[c].box.x + (x0 - dispX0));
1278
+ __ui_nodes[c].box.w = static_cast<int16_t>(x1 - x0);
1277
1279
  }
1278
1280
  int16_t baseDrawX = ui_base_draw_x_for_node(c);
1279
1281
  int16_t baseDrawY = ui_base_draw_y_for_node(c);
@@ -1 +1 @@
1
- export declare const SHADCN_KIT_CSS = "/* ---------------------------------------------------------------------------\n * shadcn-style component kit \u2014 BUILT IN and always included.\n *\n * These tokens + class recipes are prepended to every build and preview\n * automatically, BEFORE your stylesheets, so anything you write overrides\n * them by normal cascade order. No scaffolding, no imports needed: put the\n * classes on native elements and they work.\n *\n * THEMES: a theme is any CSS file. Pre-packaged ones ship with @typecad/ui \u2014\n * import by bare specifier from a <style> block (or the sidecar .ui.css):\n *\n * @import \"@typecad/ui/themes/blue.css\"; (zinc slate stone gray neutral\n * blue green red)\n *\n * Your own: save a ui.shadcn.com / tweakcn export into the project and\n * @import it by path \u2014 its :root/.dark token blocks override the kit\n * defaults (later definitions win). Both dialects parse \u2014 classic HSL channel triplets and\n * Tailwind-v4 oklch(); alpha in colors is ignored (no blending on bare\n * metal). Dark mode activates with themeClass: 'dark' in the config's\n * display block.\n *\n * Overriding recipes: redefine any class in your own stylesheet; your\n * definition wins.\n *\n * Components (classes over native elements):\n * Button <button class=\"btn btn-primary\">Save</button>\n * variants: -secondary -outline -ghost -destructive\n * sizes: .btn-sm .btn-lg full width: .btn-block\n * Badge <text class=\"badge badge-secondary\">new</text>\n * variants: -default(omit) -destructive -outline\n * Card <view class=\"card\">\n * <view class=\"card-header\">\n * <text class=\"card-title\">Title</text>\n * <text class=\"card-description\">Subtitle</text>\n * </view>\n * <view class=\"card-content\">...</view>\n * <view class=\"card-footer\">...buttons...</view>\n * </view>\n * Input <input class=\"input\" placeholder=\"...\"/>\n * Label <label class=\"form-label\">Name</label>\n * Row (kit) <view class=\"row\"> ... </view> (flex row + gap, for composing)\n * Validation .input-error (destructive border) + .field-error hint;\n * runtime-driven: bind borderColor/visible to a signal\n * Separator <hr class=\"separator\"/> (horizontal) or\n * <hr class=\"vseparator\"/> (vertical; stretches to the row height)\n * Alert <view class=\"alert alert-destructive\">\n * <text class=\"alert-title\">...</text>\n * <text class=\"alert-description\">...</text>\n * </view>\n * Skeleton <view class=\"skeleton\"/> (pulse while loading)\n * Spinner <view class=\"spinner\"><view class=\"spinner-dot\"/></view>\n * (indeterminate loading: a dot orbiting a ring via pure\n * transform keyframes \u2014 translate lerps smoothly, unlike\n * rotate which only renders exact quarter turns)\n * Dialog <dialog id=\"d\"><view class=\"dialog-scrim\" on:click={...}/>\n * <view class=\"dialog-card\"> ... <view class=\"dialog-footer\">\n * </view></dialog>\n * (centered modal: programmatic ui.dialog.open(id), scrim tap\n * closes; same slot machinery as the drawer)\n * Toast <toast id=\"t\" side=\"bottom\" duration=\"2500\" class=\"toast\">\n * ...</toast> (ui.toast(id) shows it; auto-closes after the\n * duration; stacks as authored siblings)\n * Progress <progress class=\"progress\" value=\"40\"/>\n * Avatar <img class=\"avatar\" src=\"face.bmp\"/>\n * Switch <check class=\"switch\"/> (pill container; the\n * 16px indicator is runtime-drawn at the top-left \u2014 best-effort)\n * Tabs <view class=\"tabs-list\"><button class=\"tabs-trigger\">..</button>..\n * <view class=\"tabs-content-area\"><view class=\"tabs-content\">..\n * (panes toggle via ui.bind(x, 'visible', ...) on a signal)\n * Accordion <view class=\"accordion\"><view class=\"accordion-item\">..\n * (content toggles via ui.bind visible; chevron via text bind)\n * Table <table> ... </table> (UA styles apply; see the\n * html-table-approximation build note)\n * ------------------------------------------------------------------------- */\n\n:root {\n --background: #ffffff;\n --foreground: #09090b;\n --card: #ffffff;\n --card-foreground: #09090b;\n --primary: #18181b;\n --primary-foreground: #fafafa;\n --secondary: #f4f4f5;\n --secondary-foreground: #18181b;\n --muted: #f4f4f5;\n --muted-foreground: #71717a;\n --accent: #f4f4f5;\n --accent-foreground: #18181b;\n --destructive: #dc2626;\n --destructive-foreground: #fafafa;\n --destructive-background: #fee2e2;\n --border: #e4e4e7;\n --input: #e4e4e7;\n --radius: 8px;\n --shadow-sm: 0 1px 3px rgb(0 0 0 / 0.1);\n /* Solid scrim for dialogs \u2014 alpha blending needs a canvas underneath, so\n the kit uses an opaque near-black that reads as a dimmed backdrop. */\n --scrim: #101014;\n}\n\n.dark {\n --background: #09090b;\n --foreground: #fafafa;\n --card: #18181b;\n --card-foreground: #fafafa;\n --primary: #fafafa;\n --primary-foreground: #18181b;\n --secondary: #27272a;\n --secondary-foreground: #fafafa;\n --muted: #27272a;\n --muted-foreground: #a1a1aa;\n --accent: #27272a;\n --accent-foreground: #fafafa;\n --destructive: #ef4444;\n --destructive-foreground: #09090b;\n --destructive-background: #451a1a;\n --border: #27272a;\n --input: #3f3f46;\n --radius: 8px;\n}\n\n/* ---- Button ---------------------------------------------------------------- */\n\n.btn {\n border-radius: var(--radius);\n padding: 10px 16px;\n font-weight: bold;\n transition: background 80ms;\n}\n.btn:pressed { transform: translateY(1px); }\n.btn-primary { background: var(--primary); color: var(--primary-foreground); }\n.btn-secondary { background: var(--secondary); color: var(--secondary-foreground); }\n.btn-outline { background: var(--background); color: var(--foreground); border: 1px solid var(--border); }\n.btn-ghost { color: var(--foreground); }\n.btn-destructive { background: var(--destructive); color: var(--destructive-foreground); }\n.btn-sm { padding: 6px 10px; min-height: 32px; font-size: 12px; }\n.btn-lg { padding: 14px 22px; min-height: 52px; font-size: 18px; }\n.btn-block { align-self: stretch; }\n\n/* ---- Badge ------------------------------------------------------------------ */\n\n.badge {\n background: var(--primary);\n color: var(--primary-foreground);\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 12px;\n text-align: center;\n}\n.badge-secondary { background: var(--secondary); color: var(--secondary-foreground); }\n.badge-destructive { background: var(--destructive); color: var(--destructive-foreground); }\n.badge-outline { background: var(--background); color: var(--foreground); border: 1px solid var(--border); }\n\n/* ---- Layout utility -------------------------------------------------------------\n Not a shadcn component \u2014 a kit convenience for composing recipes. */\n\n.row { flex-direction: row; align-items: center; gap: 8px; }\n\n/* ---- Card ------------------------------------------------------------------- */\n\n.card {\n background: var(--card);\n color: var(--card-foreground);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow-sm);\n padding: 16px;\n gap: 8px;\n align-self: stretch;\n}\n.card-header { gap: 4px; }\n.card-title { font-size: 18px; font-weight: bold; }\n.card-description { font-size: 14px; color: var(--muted-foreground); }\n.card-content { gap: 8px; }\n.card-footer { flex-direction: row; gap: 8px; }\n\n/* ---- Tabs ---------------------------------------------------------------- */\n\n/* Segmented trigger row (shadcn TabsList). Active-trigger styling is driven\n at runtime by ui.bind background/color bindings in author code \u2014 runtime\n color swaps are literal hex (compile-time tokens), so pin them to the\n active theme the way native_demo accents do. */\n.tabs-list {\n flex-direction: row;\n gap: 4px;\n align-self: stretch;\n background: var(--muted);\n border-radius: var(--radius);\n padding: 4px;\n}\n.tabs-trigger {\n flex-grow: 1;\n border-radius: calc(var(--radius) - 2px);\n padding: 6px 10px;\n min-height: 32px;\n font-size: 13px;\n text-align: center;\n}\n/* Fixed-height content region: panes are absolutely stacked inside it and\n toggled via ui.bind(x, 'visible', ...) \u2014 layout keeps every pane's box,\n so switching never re-flows. */\n.tabs-content-area {\n position: relative;\n align-self: stretch;\n height: 150px;\n}\n.tabs-content {\n position: absolute;\n left: 0;\n top: 0;\n right: 0;\n bottom: 0;\n gap: 8px;\n}\n\n/* ---- Accordion (single-open) --------------------------------------------- */\n\n/* Stacked collapsible sections. The trigger is a button + chevron row; the\n content pane toggles via ui.bind(x, 'visible', () => signal === i) \u2014 the\n same signal pattern as Tabs. The chevron swaps v/^ via a text binding. */\n.accordion {\n gap: 8px;\n align-self: stretch;\n}\n.accordion-item {\n background: var(--card);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n}\n.accordion-trigger-row {\n flex-direction: row;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n}\n.accordion-trigger {\n flex-grow: 1;\n text-align: left;\n font-weight: bold;\n padding: 0;\n min-height: 20px;\n /* The UA sheet gives every button a 1px border (border: 1px solid, colored\n by the foreground token). A trigger is a plain text row on the card \u2014\n cancel it or each one renders with a bright outline. */\n border: none;\n}\n.accordion-chevron {\n color: var(--muted-foreground);\n font-size: 14px;\n /* Wide enough for the '^' glyph (advance 12px at 14px size) \u2014 a single\n char wider than the box wraps to nothing. */\n width: 16px;\n text-align: center;\n}\n.accordion-content {\n padding: 0 12px 10px 12px;\n gap: 8px;\n}\n\n/* ---- Form controls ----------------------------------------------------------- */\n\n.input {\n background: var(--background);\n color: var(--foreground);\n border: 1px solid var(--input);\n border-radius: var(--radius);\n}\n.form-label { color: var(--foreground); font-size: 14px; }\n\n/* Switch: pill container. The runtime draws a 16px circular knob that\n slides with state \u2014 hollow at the left when off, solid at the right when\n on (a static jump, no travel animation). min-height overrides the UA\n touch target so the pill stays 26px tall. */\n.switch {\n width: 44px;\n height: 26px;\n /* Override the UA's touch-target min-height (42px on small panels) \u2014 the\n pill must stay 26px tall to read as a switch, not a checkbox. */\n min-height: 26px;\n border-radius: 999px;\n border: 1px solid var(--border);\n /* --card, not --muted: on a bare (near-black) page background a --muted\n track reads as a stray gray box; --card keeps the pill affordance while\n staying quiet. Swap back to var(--muted) inside lighter containers. */\n background: var(--card);\n}\n\n/* ---- Checked-state pairs (shadcn data-[state=checked]) ------------------------\n The ON control carries the primary pair: the switch's TRACK turns primary\n with a primary-foreground knob, the checkbox face and the radio's selected\n ring do the same. The select's pair themes the OPTION LIST's selected row\n with the accent pair (shadcn's SelectItem selected state). These bake at\n build time from :checked rules (a separate style bucket, like :pressed);\n the device runtime swaps the pair onto the indicator when the value flips,\n and the preview draws through the same fields. */\n.switch:checked {\n background: var(--primary);\n border-color: var(--primary);\n color: var(--primary-foreground);\n}\ncheck:checked {\n background: var(--primary);\n color: var(--primary-foreground);\n}\nradio:checked {\n background: var(--primary);\n color: var(--primary-foreground);\n}\n.select:checked {\n background: var(--accent);\n color: var(--accent-foreground);\n}\n\n/* ---- Form validation states -------------------------------------------------- */\n\n/* Static hook: always-invalid styling for hardcoded markup. Runtime-driven\n validation (recommended) binds the input's borderColor + a .field-error\n hint's visible/text to a signal \u2014 see the demo's Forms screen. */\n.input-error {\n border-color: var(--destructive);\n}\n.field-error {\n color: var(--destructive);\n font-size: 12px;\n}\n.field-success {\n color: var(--muted-foreground);\n font-size: 12px;\n}\n\n/* ---- Separator ---------------------------------------------------------------- */\n\n.separator {\n height: 1px;\n background: var(--border);\n margin: 8px 0;\n}\n/* Vertical: 1px wide, stretches to the row's cross height (shadcn's\n <Separator orientation=\"vertical\" /> \u2014 flex column + align-self: stretch). */\n.vseparator {\n width: 1px;\n height: 100%;\n align-self: stretch;\n background: var(--border);\n margin: 0 8px;\n}\n\n/* ---- Alert -------------------------------------------------------------------- */\n\n.alert {\n background: var(--background);\n color: var(--foreground);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 12px;\n gap: 4px;\n align-self: stretch;\n}\n.alert-destructive {\n background: var(--destructive-background);\n border: 1px solid var(--destructive);\n color: var(--destructive);\n}\n.alert-title { font-weight: bold; }\n.alert-description { font-size: 14px; color: var(--muted-foreground); }\n\n/* ---- Skeleton (loading placeholder) --------------------------------------------- */\n\n/* Opacity pulse. If the target display's keyframe support skips opacity,\n the block still renders as a static muted placeholder. */\n.skeleton {\n background: var(--muted);\n border-radius: var(--radius);\n height: 16px;\n align-self: stretch;\n animation: ui-skeleton-pulse 1.2s ease-in-out infinite;\n}\n@keyframes ui-skeleton-pulse {\n 0% { opacity: 1; }\n 50% { opacity: 0.55; }\n 100% { opacity: 1; }\n}\n\n/* ---- Spinner ---------------------------------------------------------------- */\n\n/* Indeterminate loading indicator: a dot orbiting inside a ring, pure\n transform keyframes. translate() lerps CONTINUOUSLY between stops (unlike\n rotate(), which only renders exact quarter turns), so the orbit is smooth.\n The dot is absolute + out of flow; the ring is a fixed square so border\n clipping stays symmetric. Sizes: change .spinner's width/height and keep\n the dot inset consistent (orbit travel = inner - dot). */\n.spinner {\n position: relative;\n width: 22px;\n height: 22px;\n border: 2px solid var(--muted);\n border-radius: 999px;\n}\n.spinner-dot {\n position: absolute;\n /* The dot's BASE position is the orbit's TOP-LEFT corner, not the ring\n center: the keyframes translate 0..8px from here, and the path only\n centers when base + travel/2 == ring center (with the 2px border,\n top/left 2px + border lands the 6px dot at 4,4; its center travels\n 7..15 around the 22px ring's center at 11,11). */\n top: 2px;\n left: 2px;\n width: 6px;\n height: 6px;\n background: var(--primary);\n border-radius: 999px;\n animation: ui-spinner-orbit 1000ms linear infinite;\n}\n@keyframes ui-spinner-orbit {\n 0% { transform: translate(0px, 0px); }\n 25% { transform: translate(8px, 0px); }\n 50% { transform: translate(8px, 8px); }\n 75% { transform: translate(0px, 8px); }\n 100% { transform: translate(0px, 0px); }\n}\n/* ---- Dialog ---------------------------------------------------------------- */\n\n/* Centered modal: <dialog> is a centered drawer (side-free) \u2014 programmatic\n open/close via ui.dialog.open(id)/close(id?), hidden while closed by the\n runtime (visibility gate for centered panels; offsets can't hide them).\n The scrim is part of the markup: an absolute full-area view whose tap\n closes the dialog; the card sits above it. z-index keeps both over page\n content. */\n.dialog-scrim {\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n background: var(--scrim);\n z-index: 30;\n}\n.dialog-card {\n position: absolute;\n left: 24px;\n right: 24px;\n top: 64px;\n background: var(--card);\n color: var(--card-foreground);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow-lg);\n padding: 16px;\n gap: 8px;\n z-index: 31;\n}\n.dialog-footer {\n flex-direction: row;\n justify-content: flex-end;\n gap: 8px;\n}\n\n/* ---- Toast ----------------------------------------------------------------- */\n\n/* Transient notification: <toast side=\"bottom\" duration=\"2500\"> slides from\n the bottom edge (author-positioned, flush to the edge so the slide fully\n hides it) and auto-closes after duration ms. Show with ui.toast(id).\n Sibling toasts stack by author layout; each closes on its own timer. */\n.toast {\n position: absolute;\n left: 12px;\n right: 12px;\n bottom: 0;\n background: var(--foreground);\n color: var(--background);\n border-radius: var(--radius);\n padding: 12px;\n gap: 4px;\n z-index: 40;\n}\n.toast-title { font-weight: bold; font-size: 14px; }\n.toast-description { font-size: 12px; color: var(--muted-foreground); }\n\n\n\n/* ---- Progress / Avatar ----------------------------------------------------------- */\n\n.progress {\n height: 12px;\n border-radius: 999px;\n background: var(--secondary);\n border: 1px solid var(--border);\n color: var(--primary);\n}\n/* Avatar: plain image element. The runtime draws images rectangular \u2014 no\n rounded clipping \u2014 so no border/radius here (a border would just draw\n over the image). Wrap in a sized view if you want a frame. */\n";
1
+ export declare const SHADCN_KIT_CSS = "/* ---------------------------------------------------------------------------\n * shadcn-style component kit \u2014 BUILT IN and always included.\n *\n * These tokens + class recipes are prepended to every build and preview\n * automatically, BEFORE your stylesheets, so anything you write overrides\n * them by normal cascade order. No scaffolding, no imports needed: put the\n * classes on native elements and they work.\n *\n * THEMES: a theme is any CSS file. Pre-packaged ones ship with @typecad/ui \u2014\n * import by bare specifier from a <style> block (or the sidecar .ui.css):\n *\n * @import \"@typecad/ui/themes/blue.css\"; (zinc slate stone gray neutral\n * blue green red)\n *\n * Your own: save a ui.shadcn.com / tweakcn export into the project and\n * @import it by path \u2014 its :root/.dark token blocks override the kit\n * defaults (later definitions win). Both dialects parse \u2014 classic HSL channel triplets and\n * Tailwind-v4 oklch(); alpha in colors is ignored (no blending on bare\n * metal). Dark mode activates with themeClass: 'dark' in the config's\n * display block.\n *\n * Overriding recipes: redefine any class in your own stylesheet; your\n * definition wins.\n *\n * Components (classes over native elements):\n * Button <button class=\"btn btn-primary\">Save</button>\n * variants: -secondary -outline -ghost -destructive\n * sizes: .btn-sm .btn-lg full width: .btn-block\n * Badge <text class=\"badge badge-secondary\">new</text>\n * variants: -default(omit) -destructive -outline\n * Card <view class=\"card\">\n * <view class=\"card-header\">\n * <text class=\"card-title\">Title</text>\n * <text class=\"card-description\">Subtitle</text>\n * </view>\n * <view class=\"card-content\">...</view>\n * <view class=\"card-footer\">...buttons...</view>\n * </view>\n * Input <input class=\"input\" placeholder=\"...\"/>\n * Label <label class=\"form-label\">Name</label>\n * Row (kit) <view class=\"row\"> ... </view> (flex row + gap, for composing)\n * Validation .input-error (destructive border) + .field-error hint;\n * runtime-driven: bind borderColor/visible to a signal\n * Separator <hr class=\"separator\"/> (horizontal) or\n * <hr class=\"vseparator\"/> (vertical; stretches to the row height)\n * Alert <view class=\"alert alert-destructive\">\n * <text class=\"alert-title\">...</text>\n * <text class=\"alert-description\">...</text>\n * </view>\n * Skeleton <view class=\"skeleton\"/> (pulse while loading)\n * Spinner <view class=\"spinner\"><view class=\"spinner-dot\"/></view>\n * (indeterminate loading: a dot orbiting a ring via pure\n * transform keyframes \u2014 translate lerps smoothly, unlike\n * rotate which only renders exact quarter turns)\n * Dialog <dialog id=\"d\"><view class=\"dialog-scrim\" on:click={...}/>\n * <view class=\"dialog-card\"> ... <view class=\"dialog-footer\">\n * </view></dialog>\n * (centered modal: programmatic ui.dialog.open(id), scrim tap\n * closes; same slot machinery as the drawer)\n * Toast <toast id=\"t\" side=\"bottom\" duration=\"2500\" class=\"toast\">\n * ...</toast> (ui.toast(id) shows it; auto-closes after the\n * duration; stacks as authored siblings)\n * Progress <progress class=\"progress\" value=\"40\"/>\n * Avatar <img class=\"avatar\" src=\"face.bmp\"/>\n * Switch <check class=\"switch\"/> (pill container; the\n * 16px indicator is runtime-drawn at the top-left \u2014 best-effort)\n * Tabs <view class=\"tabs-list\"><button class=\"tabs-trigger\">..</button>..\n * <view class=\"tabs-content-area\"><view class=\"tabs-content\">..\n * (panes toggle via ui.bind(x, 'visible', ...) on a signal)\n * Accordion <view class=\"accordion\"><view class=\"accordion-item\">..\n * (content toggles via ui.bind visible; chevron via text bind)\n * Table <table> ... </table> (UA styles apply; see the\n * html-table-approximation build note)\n * ------------------------------------------------------------------------- */\n\n:root {\n --background: #ffffff;\n --foreground: #09090b;\n --card: #ffffff;\n --card-foreground: #09090b;\n --primary: #18181b;\n --primary-foreground: #fafafa;\n --secondary: #f4f4f5;\n --secondary-foreground: #18181b;\n --muted: #f4f4f5;\n --muted-foreground: #71717a;\n --accent: #f4f4f5;\n --accent-foreground: #18181b;\n --destructive: #dc2626;\n --destructive-foreground: #fafafa;\n --destructive-background: #fee2e2;\n --border: #e4e4e7;\n --input: #e4e4e7;\n --radius: 8px;\n --shadow-sm: 0 1px 3px rgb(0 0 0 / 0.1);\n /* Solid scrim for dialogs \u2014 alpha blending needs a canvas underneath, so\n the kit uses an opaque near-black that reads as a dimmed backdrop. */\n --scrim: #101014;\n}\n\n.dark {\n --background: #09090b;\n --foreground: #fafafa;\n --card: #18181b;\n --card-foreground: #fafafa;\n --primary: #fafafa;\n --primary-foreground: #18181b;\n --secondary: #27272a;\n --secondary-foreground: #fafafa;\n --muted: #27272a;\n --muted-foreground: #a1a1aa;\n --accent: #27272a;\n --accent-foreground: #fafafa;\n --destructive: #ef4444;\n --destructive-foreground: #09090b;\n --destructive-background: #451a1a;\n --border: #27272a;\n --input: #3f3f46;\n --radius: 8px;\n}\n\n/* ---- Button ---------------------------------------------------------------- */\n\n.btn {\n border-radius: var(--radius);\n padding: 10px 16px;\n font-weight: bold;\n transition: background 80ms;\n /* Reset the UA's browser-default button border (shadcn's button reset):\n ghost must be truly borderless \u2014 variants that want one (outline)\n re-declare it themselves. */\n border: none;\n}\n.btn:pressed { transform: translateY(1px); }\n.btn-primary { background: var(--primary); color: var(--primary-foreground); }\n.btn-secondary { background: var(--secondary); color: var(--secondary-foreground); }\n.btn-outline { background: var(--background); color: var(--foreground); border: 1px solid var(--border); }\n.btn-ghost { color: var(--foreground); }\n.btn-destructive { background: var(--destructive); color: var(--destructive-foreground); }\n.btn-sm { padding: 6px 10px; min-height: 32px; font-size: 12px; }\n.btn-lg { padding: 14px 22px; min-height: 52px; font-size: 18px; }\n.btn-block { align-self: stretch; }\n\n/* ---- Badge ------------------------------------------------------------------ */\n\n.badge {\n background: var(--primary);\n color: var(--primary-foreground);\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 12px;\n text-align: center;\n}\n.badge-secondary { background: var(--secondary); color: var(--secondary-foreground); }\n.badge-destructive { background: var(--destructive); color: var(--destructive-foreground); }\n.badge-outline { background: var(--background); color: var(--foreground); border: 1px solid var(--border); }\n\n/* ---- Layout utility -------------------------------------------------------------\n Not a shadcn component \u2014 a kit convenience for composing recipes. */\n\n.row { flex-direction: row; align-items: center; gap: 8px; }\n\n/* ---- Card ------------------------------------------------------------------- */\n\n.card {\n background: var(--card);\n color: var(--card-foreground);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow-sm);\n padding: 16px;\n gap: 8px;\n align-self: stretch;\n}\n.card-header { gap: 4px; }\n.card-title { font-size: 18px; font-weight: bold; }\n.card-description { font-size: 14px; color: var(--muted-foreground); }\n.card-content { gap: 8px; }\n.card-footer { flex-direction: row; gap: 8px; }\n\n/* ---- Tabs ---------------------------------------------------------------- */\n\n/* Segmented trigger row (shadcn TabsList). Active-trigger styling is driven\n at runtime by ui.bind background/color bindings in author code \u2014 runtime\n color swaps are literal hex (compile-time tokens), so pin them to the\n active theme the way native_demo accents do. */\n.tabs-list {\n flex-direction: row;\n gap: 4px;\n align-self: stretch;\n background: var(--muted);\n border-radius: var(--radius);\n padding: 4px;\n}\n.tabs-trigger {\n flex-grow: 1;\n border-radius: calc(var(--radius) - 2px);\n padding: 6px 10px;\n min-height: 32px;\n font-size: 13px;\n text-align: center;\n}\n/* Fixed-height content region: panes are absolutely stacked inside it and\n toggled via ui.bind(x, 'visible', ...) \u2014 layout keeps every pane's box,\n so switching never re-flows. */\n.tabs-content-area {\n position: relative;\n align-self: stretch;\n height: 150px;\n}\n.tabs-content {\n position: absolute;\n left: 0;\n top: 0;\n right: 0;\n bottom: 0;\n gap: 8px;\n}\n\n/* ---- Accordion (single-open) --------------------------------------------- */\n\n/* Stacked collapsible sections. The trigger is a button + chevron row; the\n content pane toggles via ui.bind(x, 'visible', () => signal === i) \u2014 the\n same signal pattern as Tabs. The chevron swaps v/^ via a text binding. */\n.accordion {\n gap: 8px;\n align-self: stretch;\n}\n.accordion-item {\n background: var(--card);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n}\n.accordion-trigger-row {\n flex-direction: row;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n}\n.accordion-trigger {\n flex-grow: 1;\n text-align: left;\n font-weight: bold;\n padding: 0;\n min-height: 20px;\n /* The UA sheet gives every button a 1px border (border: 1px solid, colored\n by the foreground token). A trigger is a plain text row on the card \u2014\n cancel it or each one renders with a bright outline. */\n border: none;\n}\n.accordion-chevron {\n color: var(--muted-foreground);\n font-size: 14px;\n /* Wide enough for the '^' glyph (advance 12px at 14px size) \u2014 a single\n char wider than the box wraps to nothing. */\n width: 16px;\n text-align: center;\n}\n.accordion-content {\n padding: 0 12px 10px 12px;\n gap: 8px;\n}\n\n/* ---- Form controls ----------------------------------------------------------- */\n\n.input {\n background: var(--background);\n color: var(--foreground);\n border: 1px solid var(--input);\n border-radius: var(--radius);\n}\n.form-label { color: var(--foreground); font-size: 14px; }\n\n/* Switch: pill container. The runtime draws a 16px circular knob that\n slides with state \u2014 hollow at the left when off, solid at the right when\n on (a static jump, no travel animation). min-height overrides the UA\n touch target so the pill stays 26px tall. */\n.switch {\n width: 44px;\n height: 26px;\n /* Override the UA's touch-target min-height (42px on small panels) \u2014 the\n pill must stay 26px tall to read as a switch, not a checkbox. */\n min-height: 26px;\n border-radius: 999px;\n border: 1px solid var(--border);\n /* --card, not --muted: on a bare (near-black) page background a --muted\n track reads as a stray gray box; --card keeps the pill affordance while\n staying quiet. Swap back to var(--muted) inside lighter containers. */\n background: var(--card);\n}\n\n/* ---- Checked-state pairs (shadcn data-[state=checked]) ------------------------\n The ON control carries the primary pair: the switch's TRACK turns primary\n with a primary-foreground knob, the checkbox face and the radio's selected\n ring do the same. The select's pair themes the OPTION LIST's selected row\n with the accent pair (shadcn's SelectItem selected state). These bake at\n build time from :checked rules (a separate style bucket, like :pressed);\n the device runtime swaps the pair onto the indicator when the value flips,\n and the preview draws through the same fields. */\n.switch:checked {\n background: var(--primary);\n border-color: var(--primary);\n color: var(--primary-foreground);\n}\ncheck:checked {\n background: var(--primary);\n color: var(--primary-foreground);\n}\nradio:checked {\n background: var(--primary);\n color: var(--primary-foreground);\n}\n.select:checked {\n background: var(--accent);\n color: var(--accent-foreground);\n}\n\n/* ---- Form validation states -------------------------------------------------- */\n\n/* Static hook: always-invalid styling for hardcoded markup. Runtime-driven\n validation (recommended) binds the input's borderColor + a .field-error\n hint's visible/text to a signal \u2014 see the demo's Forms screen. */\n.input-error {\n border-color: var(--destructive);\n}\n.field-error {\n color: var(--destructive);\n font-size: 12px;\n}\n.field-success {\n color: var(--muted-foreground);\n font-size: 12px;\n}\n\n/* ---- Separator ---------------------------------------------------------------- */\n\n.separator {\n height: 1px;\n background: var(--border);\n margin: 8px 0;\n}\n/* Vertical: 1px wide, stretches to the row's cross height (shadcn's\n <Separator orientation=\"vertical\" /> \u2014 flex column + align-self: stretch). */\n.vseparator {\n width: 1px;\n height: 100%;\n align-self: stretch;\n background: var(--border);\n margin: 0 8px;\n}\n\n/* ---- Alert -------------------------------------------------------------------- */\n\n.alert {\n background: var(--background);\n color: var(--foreground);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 12px;\n gap: 4px;\n align-self: stretch;\n}\n.alert-destructive {\n background: var(--destructive-background);\n border: 1px solid var(--destructive);\n color: var(--destructive);\n}\n.alert-title { font-weight: bold; }\n.alert-description { font-size: 14px; color: var(--muted-foreground); }\n\n/* ---- Skeleton (loading placeholder) --------------------------------------------- */\n\n/* Opacity pulse. If the target display's keyframe support skips opacity,\n the block still renders as a static muted placeholder. */\n.skeleton {\n background: var(--muted);\n border-radius: var(--radius);\n height: 16px;\n align-self: stretch;\n animation: ui-skeleton-pulse 1.2s ease-in-out infinite;\n}\n@keyframes ui-skeleton-pulse {\n 0% { opacity: 1; }\n 50% { opacity: 0.55; }\n 100% { opacity: 1; }\n}\n\n/* ---- Spinner ---------------------------------------------------------------- */\n\n/* Indeterminate loading indicator: a dot orbiting inside a ring, pure\n transform keyframes. translate() lerps CONTINUOUSLY between stops (unlike\n rotate(), which only renders exact quarter turns), so the orbit is smooth.\n The dot is absolute + out of flow; the ring is a fixed square so border\n clipping stays symmetric. Sizes: change .spinner's width/height and keep\n the dot inset consistent (orbit travel = inner - dot). */\n.spinner {\n position: relative;\n width: 22px;\n height: 22px;\n border: 2px solid var(--muted);\n border-radius: 999px;\n}\n.spinner-dot {\n position: absolute;\n /* The dot's BASE position is the orbit's TOP-LEFT corner, not the ring\n center: the keyframes translate 0..8px from here, and the path only\n centers when base + travel/2 == ring center (with the 2px border,\n top/left 2px + border lands the 6px dot at 4,4; its center travels\n 7..15 around the 22px ring's center at 11,11). */\n top: 2px;\n left: 2px;\n width: 6px;\n height: 6px;\n background: var(--primary);\n border-radius: 999px;\n animation: ui-spinner-orbit 1000ms linear infinite;\n}\n@keyframes ui-spinner-orbit {\n 0% { transform: translate(0px, 0px); }\n 25% { transform: translate(8px, 0px); }\n 50% { transform: translate(8px, 8px); }\n 75% { transform: translate(0px, 8px); }\n 100% { transform: translate(0px, 0px); }\n}\n/* ---- Dialog ---------------------------------------------------------------- */\n\n/* Centered modal: <dialog> is a centered drawer (side-free) \u2014 programmatic\n open/close via ui.dialog.open(id)/close(id?), hidden while closed by the\n runtime (visibility gate for centered panels; offsets can't hide them).\n The scrim is part of the markup: an absolute full-area view whose tap\n closes the dialog; the card sits above it. z-index keeps both over page\n content. */\n.dialog-scrim {\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n background: var(--scrim);\n z-index: 30;\n}\n.dialog-card {\n position: absolute;\n left: 24px;\n right: 24px;\n top: 64px;\n background: var(--card);\n color: var(--card-foreground);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow-lg);\n padding: 16px;\n gap: 8px;\n z-index: 31;\n}\n.dialog-footer {\n flex-direction: row;\n justify-content: flex-end;\n gap: 8px;\n}\n\n/* ---- Toast ----------------------------------------------------------------- */\n\n/* Transient notification: <toast side=\"bottom\" duration=\"2500\"> slides from\n the bottom edge (author-positioned, flush to the edge so the slide fully\n hides it) and auto-closes after duration ms. Show with ui.toast(id).\n Sibling toasts stack by author layout; each closes on its own timer. */\n.toast {\n position: absolute;\n left: 12px;\n right: 12px;\n bottom: 0;\n background: var(--foreground);\n color: var(--background);\n border-radius: var(--radius);\n padding: 12px;\n gap: 4px;\n z-index: 40;\n}\n.toast-title { font-weight: bold; font-size: 14px; }\n.toast-description { font-size: 12px; color: var(--muted-foreground); }\n\n\n\n/* ---- Progress / Avatar ----------------------------------------------------------- */\n\n.progress {\n height: 12px;\n border-radius: 999px;\n background: var(--secondary);\n border: 1px solid var(--border);\n color: var(--primary);\n}\n/* Avatar: plain image element. The runtime draws images rectangular \u2014 no\n rounded clipping \u2014 so no border/radius here (a border would just draw\n over the image). Wrap in a sized view if you want a frame. */\n";
@@ -135,6 +135,10 @@ export const SHADCN_KIT_CSS = `/* ----------------------------------------------
135
135
  padding: 10px 16px;
136
136
  font-weight: bold;
137
137
  transition: background 80ms;
138
+ /* Reset the UA's browser-default button border (shadcn's button reset):
139
+ ghost must be truly borderless — variants that want one (outline)
140
+ re-declare it themselves. */
141
+ border: none;
138
142
  }
139
143
  .btn:pressed { transform: translateY(1px); }
140
144
  .btn-primary { background: var(--primary); color: var(--primary-foreground); }
@@ -2,6 +2,8 @@ export { DISPLAY_CATALOG, TOUCH_CATALOG, findPinConflicts, } from "./display-cat
2
2
  export type { DisplayCatalogEntry, TouchCatalogEntry, WizardBus, WizardTouchKind, } from "./display-catalog.js";
3
3
  export { findCuttlefishConfig, findSyntaxError, readConfigSection, readEntryPath, renderDisplayBody, renderDisplayProperty, upsertDisplaySection, } from "./config-writer.js";
4
4
  export type { ConfigRecord, RenderDisplayOptions, UpsertDisplayResult, } from "./config-writer.js";
5
+ export { findPackageJson, previewScriptCommand, readPackageScript, upsertPackageScript, } from "./package-writer.js";
6
+ export type { UpsertScriptResult } from "./package-writer.js";
5
7
  export { renderStarterUi } from "./starter-ui.js";
6
8
  export { runIntegrationWizard } from "./integration-wizard.js";
7
9
  export type { WizardRunResult, WizardStreams } from "./integration-wizard.js";
@@ -6,5 +6,6 @@
6
6
  // ---------------------------------------------------------------------------
7
7
  export { DISPLAY_CATALOG, TOUCH_CATALOG, findPinConflicts, } from "./display-catalog.js";
8
8
  export { findCuttlefishConfig, findSyntaxError, readConfigSection, readEntryPath, renderDisplayBody, renderDisplayProperty, upsertDisplaySection, } from "./config-writer.js";
9
+ export { findPackageJson, previewScriptCommand, readPackageScript, upsertPackageScript, } from "./package-writer.js";
9
10
  export { renderStarterUi } from "./starter-ui.js";
10
11
  export { runIntegrationWizard } from "./integration-wizard.js";
@@ -2,6 +2,9 @@ export interface WizardRunResult {
2
2
  exitCode: number;
3
3
  /** Absolute path of the config that was written, when a write happened. */
4
4
  configPath?: string;
5
+ /** Absolute path of the package.json holding the preview script, when the
6
+ * script was added or already present. */
7
+ packageJsonPath?: string;
5
8
  }
6
9
  /** Optional stream overrides so tests can drive the interactive flow. */
7
10
  export interface WizardStreams {
@@ -5,8 +5,10 @@
5
5
  // into their project's cuttlefish.config.ts: pick a display, answer bus/pin/
6
6
  // speed questions with hardware-aware defaults, then splice the resulting
7
7
  // `display` section into the config without touching any other section.
8
- // Offers to create a starter .ui entry file when none exists and prints the
9
- // next build/flash/library steps at the end.
8
+ // Also adds a `preview` npm script to package.json so the desktop preview
9
+ // renderer starts via `npm run preview`. Offers to create a starter .ui entry
10
+ // file when none exists and prints the next build/flash/library steps at the
11
+ // end.
10
12
  // ---------------------------------------------------------------------------
11
13
  import fs from "node:fs";
12
14
  import path from "node:path";
@@ -14,6 +16,7 @@ import chalk from "chalk";
14
16
  import { createPromptInterface, promptConfirm, promptInt, promptSelect, promptText, } from "./prompts.js";
15
17
  import { DISPLAY_CATALOG, TOUCH_CATALOG, findPinConflicts, } from "./display-catalog.js";
16
18
  import { findCuttlefishConfig, findSyntaxError, readConfigSection, readEntryPath, renderDisplayProperty, upsertDisplaySection, } from "./config-writer.js";
19
+ import { findPackageJson, previewScriptCommand, readPackageScript, upsertPackageScript, } from "./package-writer.js";
17
20
  import { renderStarterUi } from "./starter-ui.js";
18
21
  /** Keys the wizard manages — anything else found in an existing display
19
22
  * section is carried over untouched instead of being silently dropped. */
@@ -405,7 +408,35 @@ export async function runIntegrationWizard(cwd = process.cwd(), streams = {}) {
405
408
  return { exitCode: 1 };
406
409
  }
407
410
  }
408
- // --- 8. Summary + write -------------------------------------------------------
411
+ // --- 8. package.json preview script ------------------------------------------
412
+ // `npm run preview` should start the desktop preview renderer, pointing at
413
+ // the config the wizard just verified. An existing identical script is
414
+ // left untouched; a differing one is only replaced with consent.
415
+ let packageJsonPath;
416
+ let packageJsonText;
417
+ let previewCommand;
418
+ let existingPreview;
419
+ let replacePreview = false;
420
+ const foundPackageJson = findPackageJson(path.dirname(configPath));
421
+ if (foundPackageJson) {
422
+ try {
423
+ packageJsonText = fs.readFileSync(foundPackageJson, "utf-8");
424
+ existingPreview = readPackageScript(packageJsonText, "preview");
425
+ packageJsonPath = foundPackageJson;
426
+ previewCommand = previewScriptCommand(foundPackageJson, configPath);
427
+ }
428
+ catch (err) {
429
+ console.log(` ${chalk.yellow("!")} ${err instanceof Error ? err.message : String(err)} — skipping the npm preview script.`);
430
+ }
431
+ if (previewCommand && existingPreview !== undefined && existingPreview !== previewCommand) {
432
+ console.log(` ${chalk.yellow("!")} package.json already has a preview script: ${chalk.white(existingPreview)}`);
433
+ replacePreview = await promptConfirm(rl, "Replace it with the wizard's preview command?", true);
434
+ }
435
+ }
436
+ else {
437
+ console.log(`${chalk.yellow("!")} No package.json found next to ${path.relative(cwd, configPath)} — skipping the npm preview script.`);
438
+ }
439
+ // --- 9. Summary + write -------------------------------------------------------
409
440
  console.log();
410
441
  console.log(chalk.dim(" The following display section will be written to"));
411
442
  console.log(chalk.dim(` ${path.relative(cwd, configPath)}:`));
@@ -421,6 +452,17 @@ export async function runIntegrationWizard(cwd = process.cwd(), streams = {}) {
421
452
  if (carried.length > 0) {
422
453
  console.log(` ${chalk.dim(`Kept existing display settings the wizard does not manage: ${carried.join(", ")}`)}`);
423
454
  }
455
+ if (previewCommand && packageJsonPath) {
456
+ if (existingPreview === previewCommand) {
457
+ console.log(` ${chalk.dim(`npm script already configured: preview — "${previewCommand}"`)}`);
458
+ }
459
+ else if (existingPreview === undefined || replacePreview) {
460
+ console.log(` ${chalk.dim("Also writes npm script:")} ${chalk.white("preview")}${chalk.dim(` — "${previewCommand}"`)}`);
461
+ }
462
+ else {
463
+ console.log(` ${chalk.dim(`Kept existing preview script: "${existingPreview}"`)}`);
464
+ }
465
+ }
424
466
  console.log();
425
467
  if (!(await promptConfirm(rl, "Write this configuration?", true))) {
426
468
  console.log(chalk.dim(" Aborted — nothing was written."));
@@ -434,7 +476,21 @@ export async function runIntegrationWizard(cwd = process.cwd(), streams = {}) {
434
476
  }
435
477
  fs.writeFileSync(configPath, updated.text, "utf-8");
436
478
  console.log(`${chalk.green("✓")} ${updated.mode === "replaced" ? "Updated" : "Added"} the display section in ${chalk.white(path.relative(cwd, configPath))}`);
437
- // --- 9. Starter entry file ------------------------------------------------------
479
+ // The preview script rides along with the confirmed write: added when
480
+ // missing, confirmed verbatim when already the wizard's command, and only
481
+ // replaced when the user consented above.
482
+ if (packageJsonPath && packageJsonText !== undefined && previewCommand
483
+ && (existingPreview === undefined || existingPreview === previewCommand || replacePreview)) {
484
+ const scriptUpsert = upsertPackageScript(packageJsonText, "preview", previewCommand);
485
+ if (scriptUpsert.changed) {
486
+ fs.writeFileSync(packageJsonPath, scriptUpsert.text, "utf-8");
487
+ console.log(`${chalk.green("✓")} ${scriptUpsert.previous !== undefined ? "Updated" : "Added"} npm script ${chalk.white("preview")} in ${chalk.white(path.relative(cwd, packageJsonPath))}`);
488
+ }
489
+ else {
490
+ console.log(`${chalk.green("✓")} npm script ${chalk.white("preview")} already configured in ${chalk.white(path.relative(cwd, packageJsonPath))}`);
491
+ }
492
+ }
493
+ // --- 10. Starter entry file ------------------------------------------------------
438
494
  if (entryPath) {
439
495
  const entryAbs = path.resolve(path.dirname(configPath), entryPath);
440
496
  if (!fs.existsSync(entryAbs) && entryAbs.endsWith(".ui")) {
@@ -445,7 +501,7 @@ export async function runIntegrationWizard(cwd = process.cwd(), streams = {}) {
445
501
  }
446
502
  }
447
503
  }
448
- // --- 10. Next steps ---------------------------------------------------------------
504
+ // --- 11. Next steps ---------------------------------------------------------------
449
505
  console.log();
450
506
  console.log(chalk.cyan("Next steps"));
451
507
  console.log();
@@ -463,7 +519,7 @@ export async function runIntegrationWizard(cwd = process.cwd(), streams = {}) {
463
519
  const firstStep = libraries.length > 0 ? 2 : 1;
464
520
  console.log(` ${firstStep}. Preview your UI on the desktop:`);
465
521
  console.log();
466
- console.log(` ${chalk.cyan("npx @typecad/cuttlefish preview")}`);
522
+ console.log(` ${chalk.cyan(previewCommand ? "npm run preview" : "npx @typecad/cuttlefish preview")}`);
467
523
  console.log();
468
524
  console.log(` ${firstStep + 1}. Compile for your board:`);
469
525
  console.log();
@@ -478,7 +534,11 @@ export async function runIntegrationWizard(cwd = process.cwd(), streams = {}) {
478
534
  console.log(chalk.dim(" off-target, calibrate your panel and update display.touch.calibration."));
479
535
  console.log();
480
536
  }
481
- return { exitCode: 0, configPath };
537
+ return {
538
+ exitCode: 0,
539
+ configPath,
540
+ ...(packageJsonPath && previewCommand ? { packageJsonPath } : {}),
541
+ };
482
542
  }
483
543
  finally {
484
544
  rl.close();
@@ -0,0 +1,20 @@
1
+ /** Walk up from `startDir` looking for package.json. */
2
+ export declare function findPackageJson(startDir: string): string | undefined;
3
+ /** Read a single npm script; undefined when missing. Throws on invalid JSON. */
4
+ export declare function readPackageScript(sourceText: string, name: string): string | undefined;
5
+ export interface UpsertScriptResult {
6
+ text: string;
7
+ /** false — the script already had this exact command; text is unchanged. */
8
+ changed: boolean;
9
+ /** The command that was replaced, when a different script existed. */
10
+ previous?: string;
11
+ }
12
+ /** Insert or update one npm script, preserving every other key and their order. */
13
+ export declare function upsertPackageScript(sourceText: string, name: string, command: string): UpsertScriptResult;
14
+ /**
15
+ * The `cuttlefish preview` command the wizard writes as the `preview` npm
16
+ * script. The --config path is made relative to the package.json directory
17
+ * (npm scripts run there), posix-separated, and explicitly ./-prefixed so
18
+ * sibling configs render as `./cuttlefish.config.ts`.
19
+ */
20
+ export declare function previewScriptCommand(packageJsonPath: string, configPath: string): string;
@@ -0,0 +1,79 @@
1
+ // ---------------------------------------------------------------------------
2
+ // package.json script writer for the @typecad/ui integration wizard.
3
+ //
4
+ // package.json is plain JSON (no comments, no expressions), so unlike
5
+ // cuttlefish.config.ts (see config-writer.ts) this needs no AST surgery —
6
+ // parse, compare, set, and re-serialize with the 2-space formatting npm and
7
+ // `cuttlefish create` both write. An unchanged script is a no-op: the
8
+ // original text is returned verbatim so a re-run never reformats the file.
9
+ // ---------------------------------------------------------------------------
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ const PACKAGE_FILENAME = "package.json";
13
+ /** Walk up from `startDir` looking for package.json. */
14
+ export function findPackageJson(startDir) {
15
+ let dir = path.resolve(startDir);
16
+ for (;;) {
17
+ const candidate = path.join(dir, PACKAGE_FILENAME);
18
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
19
+ return candidate;
20
+ }
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir)
23
+ return undefined;
24
+ dir = parent;
25
+ }
26
+ }
27
+ function parsePackageJson(sourceText) {
28
+ try {
29
+ return JSON.parse(sourceText);
30
+ }
31
+ catch (err) {
32
+ throw new Error(`package.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
33
+ }
34
+ }
35
+ function scriptsOf(pkg) {
36
+ const scripts = pkg.scripts;
37
+ return typeof scripts === "object" && scripts !== null && !Array.isArray(scripts)
38
+ ? scripts
39
+ : undefined;
40
+ }
41
+ /** Read a single npm script; undefined when missing. Throws on invalid JSON. */
42
+ export function readPackageScript(sourceText, name) {
43
+ const scripts = scriptsOf(parsePackageJson(sourceText));
44
+ const value = scripts?.[name];
45
+ return typeof value === "string" ? value : undefined;
46
+ }
47
+ /** Insert or update one npm script, preserving every other key and their order. */
48
+ export function upsertPackageScript(sourceText, name, command) {
49
+ const pkg = parsePackageJson(sourceText);
50
+ const scripts = scriptsOf(pkg);
51
+ const previous = scripts?.[name];
52
+ if (previous === command) {
53
+ return { text: sourceText, changed: false };
54
+ }
55
+ if (!scripts) {
56
+ pkg.scripts = {};
57
+ }
58
+ pkg.scripts[name] = command;
59
+ // Keep the file's trailing-newline convention (npm writes LF; a missing
60
+ // final newline stays missing so the diff is one line, not two).
61
+ const eol = sourceText.endsWith("\r\n") ? "\r\n" : sourceText.endsWith("\n") ? "\n" : "";
62
+ return {
63
+ text: `${JSON.stringify(pkg, null, 2)}${eol}`,
64
+ changed: true,
65
+ ...(previous !== undefined ? { previous } : {}),
66
+ };
67
+ }
68
+ /**
69
+ * The `cuttlefish preview` command the wizard writes as the `preview` npm
70
+ * script. The --config path is made relative to the package.json directory
71
+ * (npm scripts run there), posix-separated, and explicitly ./-prefixed so
72
+ * sibling configs render as `./cuttlefish.config.ts`.
73
+ */
74
+ export function previewScriptCommand(packageJsonPath, configPath) {
75
+ let rel = path.relative(path.dirname(packageJsonPath), configPath).split(path.sep).join("/");
76
+ if (!rel.startsWith("."))
77
+ rel = `./${rel}`;
78
+ return `cuttlefish preview --config ${rel}`;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typecad/ui",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.14",
4
4
  "description": "TypeCAD UI authoring library — HTML/CSS-driven graphics for microcontrollers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -44,8 +44,8 @@
44
44
  "assets"
45
45
  ],
46
46
  "dependencies": {
47
- "@typecad/cuttlefish": "1.0.0-alpha.13",
48
- "chalk": "^4.1.2",
47
+ "@typecad/cuttlefish": "1.0.0-alpha.14",
48
+ "chalk": "^6.0.0",
49
49
  "css-tree": "^3.2.1",
50
50
  "decode-ico": "^0.4.1",
51
51
  "linkedom": "^0.18.12",
@@ -86,7 +86,7 @@
86
86
  "firmware"
87
87
  ],
88
88
  "engines": {
89
- "node": ">=18"
89
+ "node": ">=22.11.0"
90
90
  },
91
91
  "devDependencies": {
92
92
  "@types/node": "^22.10.7",