create-nodebuilderjs 0.2.5 → 0.3.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/index.js CHANGED
@@ -73,6 +73,8 @@ const pkg = {
73
73
  dev: "bun --watch src/main.tsx",
74
74
  test: "bun test",
75
75
  typecheck: "tsc --noEmit",
76
+ screenshot: "nodebuilder screenshot src/main.tsx screenshot.png",
77
+ "icons:add": "nodebuilder icons add --out src/icons.ts",
76
78
  package: `nodebuilder package src/main.tsx --title "${title}" --app-id ${appId}`,
77
79
  },
78
80
  dependencies: { "@nodebuilderjs/core": `^${version}` },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nodebuilderjs",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Creates a nodebuilder app: bun create nodebuilderjs my-app",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nodebuilder-drawing
3
- description: Freeform drawing in nodebuilder with the Canvas widget (lines, polygons, arcs, gauges, sparklines, annotations, custom visualizations). Use for non-rectangular shapes and charts that @nodebuilderjs/core/charts does not cover, instead of the browser <canvas> or D3. For regular line, area, bar and pie charts, use the nodebuilder-charts skill.
3
+ description: Freeform drawing in nodebuilder with the Canvas widget (lines, dashed lines, gradient fills, polygons, arcs, gauges, sparklines, annotations, labels cut to a width with an ellipsis, custom visualizations); the draw function is reactive. Use for non-rectangular shapes and charts that @nodebuilderjs/core/charts does not cover, instead of the browser <canvas> or D3. For regular line, area, bar and pie charts, use the nodebuilder-charts skill.
4
4
  ---
5
5
 
6
6
  # Drawing with `<Canvas>`
@@ -29,20 +29,55 @@ const [data, setData] = createSignal([12, 40, 33, 70, 55, 90]);
29
29
  |---|---|
30
30
  | `g.width`, `g.height` | canvas size (inside the `padding`) |
31
31
  | `rect(x, y, w, h, color, radius?)` | rectangle (bars) |
32
- | `line(x0, y0, x1, y1, color, width = 1, cap = "round")` | segment; cap `round`, `butt` (flat) or `square` |
33
- | `polyline(points, color, width = 1, { cap, closed })` | line through several points `[x0, y0, x1, y1, ...]`, round joins |
32
+ | `line(x0, y0, x1, y1, color, width = 1, cap = "round", dash?)` | segment; cap `round`, `butt` (flat) or `square`; `dash` like `[4, 3]` (dash, gap...) |
33
+ | `polyline(points, color, width = 1, { cap, closed, dash })` | line through several points `[x0, y0, x1, y1, ...]`, round joins |
34
34
  | `polygon(points, color)` | filled polygon (concave is fine; self-intersecting is not drawn) |
35
35
  | `circle(cx, cy, radius, color)` | disc |
36
36
  | `arc(cx, cy, outerRadius, innerRadius, start, sweep, color)` | slice (inner 0) or ring; radians, 0 = top, clockwise |
37
- | `text(text, x, y, color, size?, bold?)`, `measureText(text, size?)` | labels; `y` is the top of the line |
37
+ | `text(text, x, y, color, size?, bold?, { maxWidth, ellipsis, weight, italic, letterSpacing })` | one line of text; `y` is the top of the line. With `maxWidth`, text that does not fit is cut and ends in "…" (`ellipsis: false` just cuts; nothing is drawn if not even "…" fits). `weight` (1..1000) wins over `bold`. No wrapping |
38
+ | `measureText(text, size?, bold?, { weight, italic, letterSpacing })` | `{ width, height }` of that text, to align it |
39
+ | `linearGradient(x0, y0, x1, y1, stops)` | a gradient usable in place of `color` in `rect`, `line`, `polyline`, `polygon`, `circle`, `arc` (not `text`): offset 0 at `(x0, y0)`, 1 at `(x1, y1)`, end colors beyond; stops are colors or `[color, offset]` |
40
+
41
+ Gradient area under a line (fades out toward the bottom):
42
+
43
+ ```tsx
44
+ import { Canvas, createSignal, mixColor, theme } from "@nodebuilderjs/core";
45
+
46
+ const [points] = createSignal([0, 120, 80, 40, 160, 90, 240, 20]);
47
+ <Canvas height={160} draw={(g) => {
48
+ const pts = points();
49
+ const fade = g.linearGradient(0, 0, 0, g.height, [mixColor(theme.accent.base, "transparent", 0.6), "transparent"]);
50
+ g.polygon([pts[0]!, g.height, ...pts, pts[pts.length - 2]!, g.height], fade);
51
+ g.polyline(pts, theme.accent.base, 2);
52
+ }} />;
53
+ ```
54
+
55
+ Sizes default to the theme's font size (14). Text that must fit a width:
56
+
57
+ ```tsx
58
+ import { Canvas, createSignal, theme } from "@nodebuilderjs/core";
59
+
60
+ const [title] = createSignal("A very long track title that does not fit");
61
+ <Canvas width={180} height={40} draw={(g) => {
62
+ const { height } = g.measureText(title(), 13);
63
+ g.text(title(), 4, (g.height - height) / 2, theme.text.primary, 13, false, { maxWidth: g.width - 8 }); // "A very long track…", centered vertically
64
+ g.line(0, g.height - 1, g.width, g.height - 1, theme.divider, 1, "butt", [4, 3]); // dashed
65
+ }} />;
66
+ ```
67
+
68
+ For wrapping paragraphs, a `<Text>` next to the canvas (or over it: an absolute sibling in a
69
+ `Box`, declared after the `Canvas`) is simpler than drawing text.
38
70
 
39
71
  ## Rules
40
72
 
41
73
  - Give the canvas a size: `width`/`height` or `grow`. Without a size, `draw` does not run.
42
74
  - Local coordinates, with `y` pointing down. Flip it for charts: `g.height - value * scale`.
43
75
  - `draw` must only draw: do not create signals or effects or call `set` inside it.
44
- - The canvas redraws when a signal read in `draw` changes or its size changes; the geometry
45
- is recorded, so other parts of the window redrawing cost nothing here.
76
+ - `draw` is **reactive**: it runs inside an effect, so every signal it reads (the data, a
77
+ hovered index, `theme.*` colors) is tracked, and changing one redraws the canvas, once per
78
+ change. It also redraws when its size changes. Do not wrap it in `createEffect` and do not
79
+ read the data outside `draw` (that reads it once). The geometry is recorded, so other parts
80
+ of the window redrawing cost nothing here.
46
81
  - Live data: update the signal with `requestAnimationFrame`; thousands of points
47
82
  work (5000 line + area points at ~120 fps).
48
83
  - Pie/donut: accumulate the slices in radians (`fraction * 2 * Math.PI`); a small gap
@@ -36,13 +36,24 @@ or Solid**. The JSX looks the same as always, but the elements are the widgets f
36
36
  | `el.scrollIntoView()`, `el.scrollTo({ left, top })`, `scrollLeft`/`scrollTop` | `el.scrollIntoView()` (scrolls the minimum, on the next frame), `el.scrollTo({ x, y })`, `el.scrollX`/`el.scrollY` |
37
37
  | `min-height: 0` / `min-width: 0` around what scrolls | unnecessary: the content of a `Scroll` does not hold up the size of its container |
38
38
  | `ResizeObserver`, media queries | `onResize={(s) => setWidth(s.width)}` + signal |
39
- | `createPortal`, `position: fixed`, `z-index`, library modal/dropdown/tooltip | `<Overlay>` (`modal`, `anchor={() => el}`, `onDismiss`); skill `nodebuilder-ui` |
39
+ | `z-index` among siblings (badge over a card's content) | `zIndex={1}` (siblings only: no global stacking context; ties keep child order) |
40
+ | `createPortal`, `position: fixed`, `z-index` above everything, library modal/dropdown/tooltip | `<Overlay>` (`modal`, `anchor={() => el}`, `onDismiss`); skill `nodebuilder-ui` |
41
+ | CSS `display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; row-gap: 12px` | `<Grid columns={4} gap={8} rowGap={12}>` (equal columns only; no spans or named areas) |
42
+ | CSS `margin: 8px 16px`, `margin-top`, negative margins | `margin={[8, 16]}`, `margin={[12, 0, 0, 0]}`, `margin={[0, -16]}` (no collapsing, no `auto`; adds to `gap`) |
43
+ | CSS `flex-wrap: wrap` (chips, tags), `row-gap` | `<Row wrap gap={6} rowGap={8}>` (lines start at the top: no `align-content`) |
44
+ | CSS `flex: 1 1 0` (equal or 1:1:2 columns in one row) | `width={0} grow={1}` (`grow={2}`...) on each child |
45
+ | CSS `aspect-ratio: 16 / 9`, square thumbnails | `aspectRatio={16 / 9}`; `aspectRatio={1}` in `Grid` cells gives square cells |
46
+ | CSS `align-self`, `position: absolute; inset: 0` / `right: 8px` | `alignSelf`, `position="absolute" left={0} top={0} right={0} bottom={0}` / `right={8}` (inside a `Scroll` it scrolls with the content) |
47
+ | CSS `background: linear-gradient(180deg, #1db954, #121212 60%)` | `background={{ linear: ["#1db954", ["#121212", 0.6]], angle: 180 }}` (linear only; on `<canvas>`: `g.linearGradient(x0, y0, x1, y1, stops)`) |
40
48
  | CSS `opacity`, `transform: translate(...)` | props `opacity`, `translateX`, `translateY` (no `scale`/`rotate`) |
41
49
  | `transition`, framer-motion, react-spring, `AnimatePresence` | `createTween`, `createPresence` (+ `<Show when={p.mounted}>`), `animate` |
42
50
  | `prefers-color-scheme`, `ThemeProvider` with a dark theme | `setTheme("dark")`, `colorScheme()`, colors in functions: `() => theme.text.primary` |
43
51
  | `white-space: nowrap` / `text-overflow: ellipsis` / `-webkit-line-clamp` | `wrap={false}` / `maxLines={1}` / `maxLines={n}` |
44
- | CSS `text-align`, `line-height` | `textAlign` (`start`/`center`/`end`), `lineHeight` (multiplier) |
45
- | CSS `:hover` | `onPointerEnter`/`onPointerLeave` + signal |
52
+ | CSS `text-align`, `line-height`, `text-decoration: underline` | `textAlign` (`start`/`center`/`end`), `lineHeight` (multiplier), `underline` (inherited) |
53
+ | CSS `font-weight: 600`, `font-weight: bold` | `fontWeight={600}`, `bold` (= 700); inherited |
54
+ | CSS `font-style: italic` | `italic` (inherited) |
55
+ | CSS `letter-spacing: 0.08em` / `2px` | `letterSpacing={2}` (px only: multiply `em` by the `fontSize`); inherited |
56
+ | CSS `:hover` / `:active`, `.card:hover .play` | `hoverStyle={{ background: "#..." }}` / `pressStyle={{...}}` on any widget; state read elsewhere: `const card = createInteraction()`, `<Box {...card.props}>`, `<Show when={card.hovered}>` |
46
57
  | selectable text (the default on the web) | only with `<Text selectable>`; without the prop it does not select (like `user-select: none`) |
47
58
  | `navigator.clipboard.writeText/readText` | `clipboard.writeText(t)` / `await clipboard.readText()` |
48
59
  | `fetch`, `fs`, timers | they work (Bun/Node APIs); update signals with the result |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nodebuilder-icons
3
- description: Icons and SVG in nodebuilder with the Icon widget, using Lucide (lucide-static or lucide) and Phosphor (@phosphor-icons/core). Use when adding icons to buttons, menus, sidebars or lists, when choosing an icon library, or when showing any .svg file.
3
+ description: Icons and SVG in nodebuilder with the Icon widget, using Lucide (lucide-static or lucide) and Phosphor (@phosphor-icons/core). Use when adding icons to buttons, menus, sidebars or lists (generate the imports with `nodebuilder icons add`), when choosing an icon library, or when showing any .svg file.
4
4
  ---
5
5
 
6
6
  # Icons
@@ -12,22 +12,59 @@ bun add @phosphor-icons/core # Phosphor: assets/<weight>/<name>.svg
12
12
  ```
13
13
 
14
14
  ```tsx
15
- import { Button, Icon, Row, Text, createSignal, theme } from "@nodebuilderjs/core";
15
+ import { Button, Column, Icon, Row, Text, createSignal, theme } from "@nodebuilderjs/core";
16
16
  import camera from "lucide-static/icons/camera.svg" with { type: "text" };
17
17
  import heart from "@phosphor-icons/core/assets/duotone/heart-duotone.svg" with { type: "text" };
18
- import { Star } from "lucide";
18
+ import { Heart, Play, Star } from "lucide";
19
+ import starFill from "@phosphor-icons/core/assets/fill/star-fill.svg" with { type: "text" };
19
20
 
20
21
  const [active] = createSignal(false);
22
+ const [liked, setLiked] = createSignal(false);
21
23
 
22
- <Icon svg={camera} /> // 20 px, color of the surrounding text
23
- <Icon svg={heart} size={32} color="#e01b24" />
24
- <Icon svg={Star} size={16} strokeWidth={1.5} /> // stroke width (outline icons)
25
- <Button variant="primary"><Icon svg={Star} size={16} /><Text>Favorite</Text></Button>
26
- <Row color={() => (active() ? theme.accent.base : theme.text.secondary)}>
27
- <Icon svg={camera} /><Text>Camera</Text> {/* icon and text change color together */}
28
- </Row>
24
+ <Column gap={8}>
25
+ <Icon svg={camera} /> {/* 20 px, color of the surrounding text */}
26
+ <Icon svg={heart} size={32} color="#e01b24" />
27
+ <Icon svg={Star} size={16} strokeWidth={1.5} /> {/* stroke width (outline icons) */}
28
+ <Button variant="primary"><Icon svg={Star} size={16} /><Text>Favorite</Text></Button>
29
+ <Icon svg={Play} fill /> {/* solid Lucide icon (icon color) */}
30
+ <Icon svg={Heart} fill={() => liked() && "#e01b24"} onClick={() => setLiked(!liked())} />
31
+ <Icon svg={starFill} /> {/* Phosphor ships solid icons as "fill" */}
32
+ <Row color={() => (active() ? theme.accent.base : theme.text.secondary)}>
33
+ <Icon svg={camera} /><Text>Camera</Text> {/* icon and text change color together */}
34
+ </Row>
35
+ </Column>
29
36
  ```
30
37
 
38
+ ## Adding icons: `nodebuilder icons add`
39
+
40
+ For more than one or two icons, **generate the imports** instead of writing them:
41
+
42
+ ```sh
43
+ bun run icons:add play pause heart arrow-left # lucide-static -> src/icons.ts
44
+ bun run icons:add heart-fill house --from @phosphor-icons/core # Phosphor: weight by suffix
45
+ nodebuilder icons list arrow # names that contain "arrow"
46
+ ```
47
+
48
+ <!-- no-check -->
49
+ ```tsx
50
+ import { Icon } from "@nodebuilderjs/core";
51
+ import { arrowLeft, heartFill } from "./icons.js"; // camelCase of the file name
52
+
53
+ <Icon svg={arrowLeft} />;
54
+ <Icon svg={heartFill} color="#e01b24" />;
55
+ ```
56
+
57
+ - Install the package first (`bun add lucide-static`). `--out <file>` picks another module.
58
+ - One sorted, deduplicated line per icon; the rest of `src/icons.ts` (hand-drawn icons) stays.
59
+ A name already used in the file is an error (rename yours, or pick another icon).
60
+ - An unknown name fails with close matches (`did you mean heart-crack?`) and writes nothing:
61
+ run `nodebuilder icons list <part>` to find the right one.
62
+ - Writing them by hand: `import x from "lucide-static/icons/x.svg" with { type: "text" };`.
63
+ **Never** `export { default as x } from "....svg" with { type: "text" }`: Bun drops the
64
+ attribute on re-exports, so `x` becomes the file's path, not its text (`Icon` happens to
65
+ read paths too, anything expecting SVG text breaks); `icons add` rewrites those lines into
66
+ `import x from "....svg" with { type: "text" }; export { x };`.
67
+
31
68
  ## Rules
32
69
 
33
70
  - **Never** use `lucide-react`, `lucide-solid`, `@phosphor-icons/react`, `react-icons` or
@@ -38,6 +75,11 @@ const [active] = createSignal(false);
38
75
  a function (`svg={() => (open() ? chevronUp : chevronDown)}`).
39
76
  - `size` is the height in logical px (default 20, `theme.icon.size`); the width follows the
40
77
  `viewBox`. The color comes from `color` on the `Icon` itself or is inherited from a parent, as with `Text`.
78
+ - Solid icons: Lucide is outline only; `fill` (`true` = the icon's color, or a color) makes
79
+ it solid, like Lucide's own `fill` prop: it replaces the root `<svg>`'s `fill="none"`, so the
80
+ shapes get filled and keep their stroke. A fill a shape or group sets itself (`none`
81
+ included) still wins. It can be a function. **Never** patch the SVG text
82
+ (`svg.replace('fill="none"', ...)`). Phosphor has ready-made solid icons in the `fill` weight.
41
83
  - Changing the color is free (single-color icons are tinted on the GPU); changing the size
42
84
  rasterizes again (~0.5 ms). They stay sharp on HiDPI screens.
43
85
  - Phosphor weights: `thin`, `light`, `regular`, `bold`, `fill`, `duotone` (folders in
@@ -6,12 +6,17 @@ description: Show images (PNG, JPEG, WebP, AVIF, HEIC) with nodebuilder's Image
6
6
  # Images
7
7
 
8
8
  ```tsx
9
- import { Image } from "@nodebuilderjs/core";
9
+ import { Column, Image, createSignal } from "@nodebuilderjs/core";
10
10
  import logo from "./assets/logo.png" with { type: "file" };
11
+ import photo from "./assets/photo.jpg" with { type: "file" };
11
12
 
12
- <Image src={logo} width={64} /> // height follows the aspect ratio
13
- <Image src={photo} width={200} height={120} fit="cover" radius={12} />
14
- <Image src={() => avatarOf(user())} width={32} height={32} radius={16} opacity={0.9} />
13
+ const [user] = createSignal({ name: "Ana", avatar: logo });
14
+
15
+ <Column gap={8}>
16
+ <Image src={logo} width={64} /> {/* height follows the aspect ratio */}
17
+ <Image src={photo} width={200} height={120} fit="cover" radius={12} />
18
+ <Image src={() => user().avatar} width={32} height={32} radius={16} opacity={0.9} />
19
+ </Column>
15
20
  ```
16
21
 
17
22
  ## Rules
@@ -24,7 +29,8 @@ import logo from "./assets/logo.png" with { type: "file" };
24
29
  It can be a function: changing `src` releases the old image.
25
30
  - Size: without `width`/`height`, it uses the file's pixels (1 pixel = 1 logical px). With only one
26
31
  of the two, the other follows the aspect ratio. For HiDPI screens, use a 2x larger file and fix the
27
- size (`width={64}` for a 128 px PNG).
32
+ size (`width={64}` for a 128 px PNG). A shape of your own that follows the space:
33
+ `aspectRatio={1} fit="cover"` (square covers; in a `<Grid columns={4}>` they fill each cell).
28
34
  - `fit`: `contain` (default, whole image centered), `cover` (fills and crops), `fill`
29
35
  (stretches). `radius` rounds the corners; `opacity` goes from 0 to 1.
30
36
  - The same file used in several places is decoded once; the memory is released when the
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nodebuilder-performance
3
- description: Measure and improve the performance of nodebuilder apps (fps, CPU, memory) and avoid slow patterns in large lists, frequent updates and animations. Use when the app is slow, stuttering or freezing, using too much CPU/memory, or when building lists with hundreds of items, live data or animations.
3
+ description: Measure and improve the performance of nodebuilder apps (fps, CPU, memory) and avoid slow patterns in large lists, frequent updates and animations. Use when the app is slow, stuttering or freezing, using too much CPU/memory (also while idle), or when building lists with hundreds of items, live data or animations.
4
4
  ---
5
5
 
6
6
  # Performance
@@ -8,12 +8,31 @@ description: Measure and improve the performance of nodebuilder apps (fps, CPU,
8
8
  ## Measure
9
9
 
10
10
  ```sh
11
- NB_STATS=1 bun src/main.tsx # per second: fps, ms of state/layout/paint/native, nodes, memory
12
- NB_MAX_FPS=60 bun src/main.tsx # fps cap (default 120); also run(app, { maxFps: 60 })
11
+ NB_STATS=1 bun src/main.tsx # per second: fps, ms of state/layout/paint/native, why, nodes, memory
12
+ NB_TRACE=frames bun src/main.tsx # per frame: what asked for it, with the top of its stack
13
+ NB_MAX_FPS=60 bun src/main.tsx # fps cap (default 120); also run(app, { maxFps: 60 })
13
14
  ```
14
15
 
15
- When idle, the app draws nothing (0% CPU). State changes schedule a frame; several changes
16
- before it go out together in the same frame.
16
+ When idle, the app draws nothing (0% CPU) and `NB_STATS` prints nothing. State changes
17
+ schedule a frame; several changes before it go out together in the same frame.
18
+
19
+ ## The app never goes idle
20
+
21
+ `NB_STATS` shows why frames happen: `why: inv 3 ev 2 raf 0` counts frames requested by
22
+ `invalidate()` (a signal changed something on screen), by native events (resize, hover,
23
+ scroll) and by `requestAnimationFrame`/animations. While nothing moves on screen it should
24
+ print nothing at all. If lines keep coming, run with `NB_TRACE=frames` to see who asks:
25
+
26
+ ```
27
+ frame 41: inv [in frame]
28
+ at <anonymous> (src/components/clock.tsx:12:7)
29
+ at paintTree (node_modules/@nodebuilderjs/core/src/render/paint.ts:140:8) x3
30
+ ```
31
+
32
+ `[in frame]` means the request came while that frame was being laid out or painted: usually
33
+ code that runs there (an `onResize` handler, a custom `paint`) and writes a signal every
34
+ time, even when the value did not change. Write only when the value really changes. In
35
+ development, the app also warns once (`frame loop: ...`) after 30 such frames in a row.
17
36
 
18
37
  ## Patterns
19
38
 
@@ -1,12 +1,13 @@
1
1
  ---
2
2
  name: nodebuilder-testing
3
- description: Write and debug tests for nodebuilder apps with bun test, without opening a window (mount, findByText, click, interact for mouse and keyboard, layout, debugTree). Use when creating tests, when verifying a behavior or layout change, or when a component test fails.
3
+ description: Write and debug tests for nodebuilder apps with bun test, without opening a window (mount, findByText, findByLabel, click, interact for mouse and keyboard, layout, debugTree, layoutWarnings for hidden overflow, real text metrics), and look at the UI with screenshots (nodebuilder screenshot, screenshot()). Use when creating tests, when verifying a behavior, layout or visual change, or when a component test fails.
4
4
  ---
5
5
 
6
6
  # Tests without a window
7
7
 
8
8
  `bun test` runs the files in `test/`. Nothing opens a window: `mount` builds the tree with
9
- fake text metrics (**8 px per character, 20 px line**), so layout is deterministic.
9
+ fake text metrics (**8 px per character, 20 px line**), so layout is deterministic (real
10
+ fonts on request: `useRealTextMetrics()`, below).
10
11
 
11
12
  ```tsx
12
13
  import { expect, test } from "bun:test";
@@ -27,11 +28,23 @@ test("adds a task", () => {
27
28
 
28
29
  ## API (`@nodebuilderjs/core/testing`)
29
30
 
30
- - `mount(view, size?)` → `{ root, relayout, dispose }`. Layout runs once in `mount`;
31
- call `relayout()` before checking `x`, `y`, `width`, `height` after changing state.
32
- - `findByText(root, "text" | /regex/)`: first `Text` whose entire content matches.
31
+ - `mount(view, size?)` → `{ root, relayout, dispose, layoutWarnings }`. Layout runs once in
32
+ `mount`; call `relayout()` before checking `x`, `y`, `width`, `height` after changing state.
33
+ - `layoutWarnings()` (from `mount`, or `layoutWarnings(root)`): overflow warnings of the last
34
+ layout, e.g. `layout: Row > Text("Artists") overflows Box(panel) by 42px (children need
35
+ 342px, 300px available)`: children that need more room than they get, cut off by a `clip`
36
+ ancestor or the window (past a `Scroll` on its scrolling axis does not count). `mount` does
37
+ not fail on them; assert `expect(layoutWarnings()).toEqual([])` in screen tests so a hidden
38
+ chip fails right away instead of when a click finds it.
39
+ - `findByText(root, "text" | /regex/)`: first `Text` whose entire content matches;
40
+ `findAllByText` returns every match (e.g. a title repeated in a list and in a player bar).
41
+ - `findByLabel(root, "label" | /regex/)` / `findAllByLabel`: by `ariaLabel`, for elements
42
+ without text (`<Button ariaLabel="Play">` with only an icon). Built-in labels: the window's
43
+ title-bar buttons (`"Minimize"`, `"Maximize"` or `"Restore"`, `"Close"`), and a `Tooltip`'s
44
+ trigger gets the tip text when it has no `ariaLabel` of its own.
33
45
  - `findAll(root, (n) => ...)`: free-form search; `n.label` is `"Text \"...\""`, `"Button"`, `"Row"`...
34
- - `click(node)`: fires the `onClick` of the node or of its nearest ancestor (quick shortcut).
46
+ - `click(node)`: fires the `onClick` of the node or of its nearest ancestor (quick shortcut),
47
+ at the node's center.
35
48
  - `interact(root)`: mouse and keyboard delivered as in the real app (hover, focus, capture
36
49
  while dragging, Tab): `hover(n)`, `press(n)`, `release(n)`, `drag(n, dx, dy)`, `move(x, y)`,
37
50
  `down(x, y)`/`up(x, y)` (button at a window position, e.g. on the scrollbar),
@@ -45,7 +58,63 @@ ui.type("Ana"); // types into the focused field
45
58
  ui.key("Return"); // fires onSubmit
46
59
  ui.drag(slider, 80, 0); // drags 80 px to the right
47
60
  ```
48
- - `debugTree(node)` (from `@nodebuilderjs/core`): tree with positions, great for inspecting layout.
61
+ - **Hidden targets throw.** `click(n)` and the node helpers of `interact` (`hover`, `press`,
62
+ `release`, `drag`, `scroll`) throw when a user could not reach the node: it is `culled`
63
+ (it, or an ancestor, is outside the visible area of the last layout, e.g. a chip that
64
+ overflowed a `clip` panel or a row far down a `Scroll`), or its center is cut off by a
65
+ clipping/scrolling ancestor or the window. The message names the node and the ancestor.
66
+ Fix the test (or the layout): `n.scrollIntoView()` then `relayout()`, give it room (a
67
+ bigger `mount` size, `width`), or `relayout()` if the tree changed since the last layout.
68
+ `down(x, y)`/`up(x, y)`/`move(x, y)` take window positions and are never checked.
69
+
70
+ ```tsx
71
+ import { expect, test } from "bun:test";
72
+ import { Button, For, Row, Scroll } from "@nodebuilderjs/core";
73
+ import { click, findAllByText, findByLabel, findByText, mount } from "@nodebuilderjs/core/testing";
74
+
75
+ test("plays a genre that starts off screen", () => {
76
+ const played: string[] = [];
77
+ const genres = ["Rock", "Jazz", "Pop", "Funk", "Soul"];
78
+ const { root, relayout } = mount(() => (
79
+ <Row>
80
+ <Button ariaLabel="Play" label="▶" onClick={() => played.push("all")} />
81
+ <Scroll horizontal width={300}>
82
+ <For each={genres}>{(g) => <Button label={g} width={100} onClick={() => played.push(g)} />}</For>
83
+ </Scroll>
84
+ </Row>
85
+ ));
86
+ click(findByLabel(root, "Play")!);
87
+ const soul = findByText(root, "Soul")!;
88
+ expect(() => click(soul)).toThrow(/culled/); // past the 300 px Scroll
89
+ soul.scrollIntoView();
90
+ relayout();
91
+ click(soul);
92
+ expect(played).toEqual(["all", "Soul"]);
93
+ expect(findAllByText(root, /o/)).toHaveLength(3); // Rock, Pop, Soul
94
+ });
95
+ ```
96
+ - **Real text metrics:** `useRealTextMetrics()` makes this and every later `mount` measure
97
+ text with the installed fonts (no window) and returns a function that restores the previous
98
+ metrics; `useFakeTextMetrics({ charWidth, lineHeight })` sets fake ones (the default is 8
99
+ and 20). Use real metrics to check that a label really fits (`layoutWarnings()` empty with
100
+ a `clip` panel); numbers depend on the machine's fonts, so assert relations, not exact
101
+ pixels. Guard with `test.skipIf(!canUseRealTextMetrics())` and restore in `afterEach`.
102
+
103
+ ```tsx
104
+ import { afterEach, expect, test } from "bun:test";
105
+ import { Row, Text } from "@nodebuilderjs/core";
106
+ import { canUseRealTextMetrics, layoutWarnings, mount, useFakeTextMetrics, useRealTextMetrics } from "@nodebuilderjs/core/testing";
107
+
108
+ afterEach(() => useFakeTextMetrics());
109
+
110
+ test.skipIf(!canUseRealTextMetrics())("the header fits with real fonts", () => {
111
+ useRealTextMetrics();
112
+ const { root } = mount(() => <Row width={240} clip><Text>Recently played</Text></Row>);
113
+ expect(layoutWarnings(root)).toEqual([]);
114
+ });
115
+ ```
116
+ - `debugTree(node)` (from `@nodebuilderjs/core`): tree with positions, great for inspecting
117
+ layout; a container whose children overflow a clip is marked `[overflow +42]`.
49
118
  - Animations finish immediately in tests (there is no window): a closed `Dialog` leaves the
50
119
  tree at once. To test the animation, `const clock = frames()` after `mount()` and
51
120
  `clock.advance(ms)` (16 ms frames).
@@ -59,6 +128,39 @@ ui.drag(slider, 80, 0); // drags 80 px to the right
59
128
  Their content lives in `root.layers` (not in `root.children`); `findByText(root, ...)` searches
60
129
  both.
61
130
 
131
+ ## Look at the UI: screenshots
132
+
133
+ Tests check boxes, not looks. **After a visual change, take a screenshot and look at it**
134
+ (read the PNG): wrong colors, clipped text, misalignment and missing icons show up at once.
135
+ No window or display server is needed (it renders with the GPU through EGL):
136
+
137
+ ```sh
138
+ bun run screenshot # screenshot.png: src/main.tsx at the <Window>'s size
139
+ nodebuilder screenshot src/main.tsx dark.png --theme dark --scale 2 --width 1024 --height 700
140
+ ```
141
+
142
+ Animations run to their end on a simulated clock (`--wait-frames <n>` stops earlier). In a
143
+ test, `screenshot()` gives the real pixels of a mounted tree, with real fonts:
144
+
145
+ ```tsx
146
+ import { expect, test } from "bun:test";
147
+ import { Box } from "@nodebuilderjs/core";
148
+ import { canScreenshot, mount, savePNG, screenshot } from "@nodebuilderjs/core/testing";
149
+
150
+ test.skipIf(!canScreenshot())("the banner is red", () => {
151
+ const { root } = mount(() => <Box height={40} background="#e01b24" />, { width: 200, height: 40 });
152
+ const shot = screenshot(root, { scale: 2 }); // 400x80 pixels
153
+ expect(shot.pixel(10, 10)).toEqual([0xe0, 0x1b, 0x24, 255]); // [r, g, b, a], straight alpha
154
+ savePNG(shot, "banner.png"); // to look at it
155
+ });
156
+ ```
157
+
158
+ - `screenshot(root | () => view, { width, height, scale, background })` →
159
+ `{ width, height, scale, rgba, pixel(x, y), toPNG() }`; size in device pixels. Uncovered
160
+ areas stay transparent. The test's text metrics (fake by default) come back after the shot.
161
+ - Pixels depend on the machine's fonts and GPU: assert colors at chosen points, not whole
162
+ images. `canScreenshot()` is false without a GPU driver (e.g. some CI runners).
163
+
62
164
  ## Clipboard and selection
63
165
 
64
166
  In tests, `clipboard` is an in-memory area (no window, no system). To check a copy, read it
@@ -94,4 +196,4 @@ is shared within the file: write the expected value at the start of each test.
94
196
  After changes that move nodes, call `relayout()` before interacting again.
95
197
  - Test layout calculations with fixed sizes (`width`, `height`) so they do not depend on the
96
198
  fake metrics.
97
- - Before finishing a task: `bun run typecheck && bun test`.
199
+ - Before finishing a task: `bun run typecheck && bun test`, and a screenshot if anything visual changed.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nodebuilder-ui
3
- description: nodebuilder widgets, style props and layout (Window, Column, Row, Scroll, Text, Button, TextField, Checkbox, Toggle, RadioGroup, Slider, Select, Tabs, Tooltip, Dialog, ProgressBar, Spinner, Card, For, Index, Show, Switch/Match, ErrorBoundary, theme), scrolling (vertical, horizontal, scrollbars, keyboard, scrollIntoView), context (createContext), ref (focus, scroll, measure), onResize, dark theme (setTheme), animations (createTween, createPresence), overlays (modal dialog, dropdown menu, hint/tooltip), opacity and translate, selectable text and clipboard (copy/paste). Use when creating or changing any screen, form, list, sidebar or layout of a nodebuilder app, when the user needs to copy text from the screen, or when an element does not show up, grow or scroll as expected.
3
+ description: nodebuilder widgets, style props and layout (gradient backgrounds, Window, custom title bar with dragRegion and WindowControls, Column, Row, Scroll, Text, Button, TextField, Checkbox, Toggle, RadioGroup, Slider, Select, Tabs, Tooltip, Dialog, ProgressBar, Spinner, Card, For, Index, Show, Switch/Match, ErrorBoundary, theme), scrolling (vertical, horizontal, scrollbars, keyboard, scrollIntoView), context (createContext), ref (focus, scroll, measure), onResize, dark theme (setTheme), animations (createTween, createPresence, animate, easing, mixColor), equal-width columns, absolute positioning and stacking (zIndex), clip with rounded corners, shadows, events (double/right click, key names), reusable components (MaybeAccessor, read, batch), overlays (modal dialog, dropdown menu, hint/tooltip), opacity and translate, selectable text and clipboard (copy/paste). Use when creating or changing any screen, form, list, sidebar or layout of a nodebuilder app, when the user needs to copy text from the screen, or when an element does not show up, grow or scroll as expected.
4
4
  ---
5
5
 
6
6
  # UI with nodebuilder
@@ -12,10 +12,12 @@ style is flat props. SolidJS-style reactivity: **only functions are reactive** (
12
12
 
13
13
  | Widget | What for | Main props |
14
14
  |---|---|---|
15
- | `Window` | root, one per app, with its own title bar | `title`, `width`, `height` (initial size), `appId`, `onClose` (return `false` to keep it open) |
15
+ | `Window` | root, one per app, with its own title bar | `title`, `width`, `height` (initial size), `appId`, `onClose` (return `false` to keep it open), `decorations={false}` (your own title bar, see "Theme and window") |
16
+ | `WindowControls` | minimize, maximize/restore and close buttons, for a custom title bar | style (`gap`...) |
16
17
  | `Column` / `Row` / `Box` | flex container (column, row; `Box` is a column) | style + events |
18
+ | `Grid` | children in rows of equal-width cells (cards, covers, galleries) | `columns`, `gap`, `rowGap` (default `gap`); see "Layout rules" |
17
19
  | `Scroll` | scrolling: vertical (default), `horizontal` or `horizontal vertical` (both) | needs `grow={1}` or a size; see "Scrolling" below |
18
- | `Text` | text; wraps at spaces to fit the width | children (`{count}`, `"a"`) or `text={() => ...}`, `wrap` (default `true`), `maxLines` (truncates with "…"), `textAlign`, `lineHeight`, `selectable` |
20
+ | `Text` | text; wraps at spaces to fit the width | children (`{count}`, `"a"`) or `text={() => ...}`, `wrap` (default `true`), `maxLines` (truncates with "…"), `textAlign`, `lineHeight`, `underline`, `selectable` |
19
21
  | `Image` | PNG, JPEG, WebP, AVIF | see the `nodebuilder-images` skill |
20
22
  | `Icon` | SVG icon (Lucide, Phosphor), color inherited like text | `svg`, `size` (20), `color`, `strokeWidth`; skill `nodebuilder-icons` |
21
23
  | `Canvas` | free drawing: lines, polygons, arcs, text | `draw={(g) => ...}` + size; skill `nodebuilder-drawing` |
@@ -54,18 +56,55 @@ ignores clicks and keys. Do not fake it with `opacity` + an empty `onClick`.
54
56
  ## Style props (any widget)
55
57
 
56
58
  - Layout: `direction` (`"row"`/`"column"`), `gap`, `padding` (`8`, `[v, h]` or
57
- `[top, right, bottom, left]`), `width`, `height`, `minWidth`, `minHeight`, `maxWidth`,
58
- `maxHeight`, `grow`, `align`, `justify`, `position: "absolute"` + `left`/`top`, `clip`.
59
+ `[top, right, bottom, left]`), `margin` (same forms, negative allowed), `width`, `height`, `minWidth`, `minHeight`, `maxWidth`,
60
+ `maxHeight`, `grow`, `aspectRatio` (width / height), `align`, `alignSelf`, `justify`,
61
+ `wrap` (on a `Row`/`Column`: lines; on a `Text`: word wrapping), `rowGap`,
62
+ `position: "absolute"` + `left`/`top`/`right`/`bottom`, `zIndex`, `clip`.
59
63
  - Visual: `background`, `radius`, `borderWidth`, `borderColor`, `shadow={{ color, blur, y }}`,
60
64
  `opacity` (0 to 1, applies to children), `translateX`/`translateY` (shifts after
61
65
  layout, without pushing siblings; for animation).
62
- - Text (inherited by children): `color`, `fontSize`, `bold`. Cursor: `cursor="pointer"`.
66
+ - **Gradient background** (CSS `linear-gradient`): `background={{ linear: ["#1db954", "#121212"], angle: 180 }}`.
67
+ `angle` in degrees (0 = to top, 90 = to right, default 180 = to bottom); stops are colors or
68
+ `[color, offset]` (0..1, missing ones spread evenly). Works with `radius`, borders, `opacity`,
69
+ `hoverStyle`, and reactively: `background={() => ({ linear: [theme.accent.base, "transparent"] })}`.
70
+ Interpolated premultiplied (fading to `"transparent"` stays the same hue). Linear only (no
71
+ radial/conic); `borderColor`, `color` and `shadow` take solid colors only.
72
+ - `clip` cuts the children to the inside of the border, **rounded** (radius `radius - borderWidth`):
73
+ `<Card clip padding={0}>` + an `Image` or a colored header gets the card's rounded corners.
74
+ - `shadow` is the node's rounded box, blurred `blur` px on each side of its edge and moved down
75
+ by `y` (no x offset, no spread). It is drawn even without `background`: then the whole blurred
76
+ shape shows (a soft glow), and a translucent background lets it show through.
77
+ - Text (inherited by children): `color`, `fontSize`, `bold`, `fontWeight` (1..1000 like CSS:
78
+ 300 light, 500 medium, 700 bold, 900 black; `bold` is 700 and `fontWeight` wins on the same
79
+ widget), `italic`, `letterSpacing` (px after every character, may be negative: `letterSpacing={1.5}`
80
+ on small uppercase labels), `underline` (a line under each line of text, in its color;
81
+ `underline={false}` turns it off below; `TextField` ignores it). Weights come from the
82
+ system font (the closest it has).
83
+ Symbols, CJK and emoji the UI font lacks come from the system's fallback fonts; emoji are in
84
+ color (`color` does not tint them, `opacity` fades them). Cursor: `cursor="pointer"`. A link that underlines on hover:
85
+
86
+ ```tsx
87
+ import { Text, createSignal, theme } from "@nodebuilderjs/core";
88
+
89
+ const [hovered, setHovered] = createSignal(false);
90
+ <Text color={() => theme.accent.base} underline={hovered} cursor="pointer"
91
+ onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)}
92
+ onClick={() => console.log("open docs")}>Read the docs</Text>;
93
+ ```
63
94
  - Colors are hex strings (`"#3584e4"`, `"#00000010"` with alpha) or `"transparent"`.
64
95
  - Events on any container: `onClick`, `onPointerDown/Up/Enter/Leave`,
65
96
  `onPointerMove`, `onScroll`, `onKeyDown`, `onFocus`, `onBlur`. Pointer events carry
66
- `x`/`y` (window) and `localX`/`localY` (relative to the node); while the button is held,
97
+ `x`/`y` (window), `localX`/`localY` (relative to the node), `button` (0 left, 1 middle,
98
+ 2 right) and `clicks` (2 = double click); while the button is held,
67
99
  `onPointerMove` keeps reaching the node where the press started, even outside it (dragging,
68
- sliders). `onKeyDown` only arrives along the path of the focused element.
100
+ sliders). `onKeyDown` only arrives along the path of the focused element; it gets `key`
101
+ (xkb names: `"Return"`, `"Escape"`, `"Tab"`, `"BackSpace"`, `"space"`, `"Up"`, `"Left"`, `"a"`),
102
+ `text`, `ctrl`, `shift`, `alt`, `repeat`. `onScroll` gets `dx`/`dy`. `onClick` is the left button only
103
+ (context menu: `onPointerDown` with `e.button === 2`) and runs only on the innermost node that
104
+ has one (a button inside a clickable card clicks only the button); the other events go up
105
+ through the parents until a handler calls `e.stop()`, also past nodes with `onClick` (a
106
+ list's `onPointerDown` with `e.button === 2` sees a right press on any of its clickable
107
+ rows; on keys and the wheel, `stop()` also keeps the surrounding `Scroll` still).
69
108
  - Any prop accepts a fixed value or a function: `background={() => (sel() ? a : b)}`.
70
109
  - `onResize={(s) => ...}`: the node's size (`s.width`, `s.height`) after layout, the first
71
110
  time and whenever it changes. Use it to adapt the screen to the available width.
@@ -73,6 +112,47 @@ ignores clicks and keys. Do not fake it with `opacity` + an empty `onClick`.
73
112
  on a `Scroll` `el.scrollTo(y)` / `el.scrollTo({ x, y })` and `el.scrollX`/`el.scrollY`, and
74
113
  `el.x`/`y`/`width`/`height` from the last layout. It is called on creation, before layout:
75
114
  keep the `el` and read the box later (in a click, for example).
115
+ - `ariaLabel="Play"` on any widget names an element without text (an icon-only button);
116
+ tests find it with `findByLabel`. A `Tooltip` gives its tip to a trigger that has none.
117
+
118
+ ## Hover and press
119
+
120
+ Do not write `onPointerEnter`/`onPointerLeave` + a signal for hover effects. Use
121
+ `hoverStyle`/`pressStyle` (any widget, any style prop, values fixed or functions), and
122
+ `createInteraction()` when something else depends on the state (a child shown while its
123
+ parent is hovered):
124
+
125
+ ```tsx
126
+ import { Box, Button, Column, Show, Text, createInteraction } from "@nodebuilderjs/core";
127
+
128
+ function SongCard(props: { title: string; onPlay: () => void }) {
129
+ const card = createInteraction();
130
+ return (
131
+ <Box {...card.props} direction="row" align="center" gap={12} padding={12} radius={8}
132
+ background="#181818" hoverStyle={{ background: "#282828" }} pressStyle={{ opacity: 0.85 }}>
133
+ <Column grow={1}>
134
+ <Text color="#ffffff">{props.title}</Text>
135
+ </Column>
136
+ <Show when={card.hovered}>
137
+ <Button label="Play" variant="primary" onClick={props.onPlay} />
138
+ </Show>
139
+ </Box>
140
+ );
141
+ }
142
+ ```
143
+
144
+ - `pressStyle` wins over `hoverStyle`; on leave the base values come back (a base given as a
145
+ function keeps updating underneath). They win over a widget's own hover colors (`Button`);
146
+ a `disabled` control ignores them.
147
+ - `createInteraction()` returns `hovered`, `pressed`, `focused` (accessors) and `props`.
148
+ `{...card.props}` adds to the widget's handlers and to your own `onPointerEnter`... written
149
+ before or after it (nothing is overwritten); the card stays hovered while the pointer is
150
+ over its children. `focused` only changes on focusable widgets (`Button`, `TextField`).
151
+ - `hoverStyle` alone does not take the press; `pressStyle` and `card.props` do, so inside a
152
+ window's drag region (title bar) such a widget no longer drags the window.
153
+ - Like CSS `:active`, a parent's `pressStyle` (and `pressed()`) also turns on while a child
154
+ inside it (a button in the card) is pressed; `onPointerDown={(e) => e.stop()}` on the child
155
+ (or on a `Box` around a `Button`) keeps the press to the child.
76
156
 
77
157
  ## Conditions, lists and errors
78
158
 
@@ -141,6 +221,35 @@ const scrollToEnd = () => list?.scrollTo(Infinity);
141
221
  - `useContext` only works in the component body (not inside an `onClick` or `setTimeout`):
142
222
  read it there and keep it in a variable.
143
223
 
224
+ ## Your own components
225
+
226
+ ```tsx
227
+ import { Column, Row, Text, batch, createSignal, read } from "@nodebuilderjs/core";
228
+ import type { Child, MaybeAccessor } from "@nodebuilderjs/core";
229
+
230
+ // A prop that may be fixed or reactive: MaybeAccessor + read() inside a function.
231
+ function Stat(props: { label: string; value: MaybeAccessor<number>; children?: Child }) {
232
+ return (
233
+ <Row gap={8} align="center">
234
+ <Text>{props.label}</Text>
235
+ <Text bold>{() => read(props.value).toLocaleString()}</Text>
236
+ {props.children}
237
+ </Row>
238
+ );
239
+ }
240
+
241
+ const [plays, setPlays] = createSignal(0);
242
+ const [likes, setLikes] = createSignal(0);
243
+ <Column>
244
+ <Stat label="Plays" value={plays} />
245
+ <Stat label="Year" value={2024} />
246
+ </Column>;
247
+ batch(() => { setPlays(1200); setLikes(80); }); // several writes, effects run once at the end
248
+ ```
249
+
250
+ - `untrack(() => ...)` reads signals without subscribing; `createMemo(() => ...)` caches a
251
+ derived value; `StyleProps` (type) is every style prop, to accept and pass them on.
252
+
144
253
  ## Scrolling
145
254
 
146
255
  ```tsx
@@ -186,8 +295,8 @@ For the common cases use the ready-made widgets (`Dialog`, `Select`, `Tooltip`),
186
295
  focus, keyboard and closing. `<Overlay>` is for what they do not cover (options menu, toast,
187
296
  custom popover):
188
297
 
189
- No `zIndex` and no `position: fixed`: whatever must stay on top of everything (outside the
190
- clipping of a `Scroll` or `Card`) goes in an `<Overlay>`, almost always inside a `<Show>`.
298
+ No `position: fixed`, and `zIndex` only orders siblings: whatever must stay on top of everything
299
+ (outside the clipping of a `Scroll` or `Card`) goes in an `<Overlay>`, almost always inside a `<Show>`.
191
300
 
192
301
  ```tsx
193
302
  import { Button, Card, Overlay, Row, Show, Text, createSignal, theme } from "@nodebuilderjs/core";
@@ -235,7 +344,8 @@ let button: ElementRef | undefined;
235
344
  ## Layout rules (simplified flexbox)
236
345
 
237
346
  - Default `align` is **stretch**: children take the full width of a column. Use
238
- `align="start"`/`"center"` to shrink them to their content.
347
+ `align="start"`/`"center"` to shrink them to their content. `alignSelf` on one child
348
+ overrides its parent's `align` (e.g. one centered button in a stretched form).
239
349
  - `justify`: `start`, `center`, `end`, `space-between`. There is no `space-around`/`evenly`.
240
350
  - `grow={1}` splits the leftover space. Without `grow`, the size is the content's.
241
351
  - A child **does not shrink below its own content** (like CSS `min-size: auto`), except
@@ -246,10 +356,98 @@ let button: ElementRef | undefined;
246
356
  lose height (only what scrolls vertically gives up height).
247
357
  - In a `Row` without room, whoever has `grow` shrinks first: the long text gets
248
358
  `grow={1}` (and `maxLines={1}` to become "…"), the short labels next to it stay whole.
359
+ - **Something does not show up?** In development the app prints an overflow warning once per
360
+ node when children need more room than they get and a `clip` ancestor or the window cuts
361
+ them off: `layout: Row > Text("Artists") overflows Box(panel) by 42px (children need 342px,
362
+ 300px available)`; `debugTree(root)` marks the container `[overflow +42]` and tests read the
363
+ list with `layoutWarnings()` (see the testing skill). Fix: a `Scroll horizontal`, wrapping
364
+ text, `grow` with `width={0}`, `minWidth={0}` plus `clip` on what may be cut, or more room.
249
365
  - Text wraps by itself at the width it gets (`\n` also breaks); a word longer than the line
250
366
  overflows. A single line: `wrap={false}` (overflows) or `maxLines={1}` (truncates with "…").
251
- - There are no percentages, `flex-wrap` or grid: build rows with `Row` and fixed widths (see
252
- tables: `Text width={110}`) or `grow`.
367
+ - There are no percentages: build rows with `Row` and fixed widths (see tables:
368
+ `Text width={110}`), `grow`, proportional columns (below), `wrap` or a `Grid`.
369
+ - **`<Row wrap gap={6}>`** (CSS `flex-wrap: wrap`) for chips and tags: children that do not fit
370
+ start a new line, `rowGap` (default `gap`) between lines. Each line is its own row (`grow`,
371
+ `justify`, `alignSelf` apply inside it) and is as tall as its tallest child. The Row wraps at
372
+ whatever width it gets (stretched in a column, `grow`, a fixed `width`) and its height follows;
373
+ it can shrink down to its widest child. `<Column wrap height={...}>` breaks into columns at
374
+ its height.
375
+ - **`margin`** is space outside the border that the parent keeps free: it counts in every size
376
+ around the child (natural size, `grow`, `Grid` cells, `wrap` lines) and a stretched child
377
+ gets the room minus its margins. Prefer `gap`/`padding` on the parent for regular spacing;
378
+ use `margin` for one child (extra space above a section title) or negative values (a divider
379
+ that cancels the card's padding: `margin={[8, -16]}`). Not painted, not clickable, never
380
+ collapses, adds to `gap`. On an absolute child it adds to `left`/`top`/`right`/`bottom`.
381
+ - **`<Grid columns={3} gap={8}>`** (CSS `repeat(3, 1fr)`): children flow into rows of equal
382
+ cells, whatever their content; the last row keeps its empty cells. A row is as tall as its
383
+ tallest cell and cells stretch to it (`align`/`alignSelf` change that). `rowGap` sets the
384
+ space between rows (default `gap`). Use it with `<For>` too: adding an item reflows.
385
+ - **`aspectRatio={16 / 9}`** (width / height), like CSS `aspect-ratio`: in a column or a `Grid`
386
+ cell the height follows the width; with a fixed `height` (or stretched in a `Row` with a
387
+ height) the width follows the height. `min/max` still clamp, and children taller than the
388
+ ratio make the box taller. In a `Grid`, `aspectRatio={1}` gives square cells (covers,
389
+ thumbnails) that do not stretch to a taller neighbor.
390
+ - **Equal (or proportional) columns in one row:** `width={0} grow={1}` on each child, like CSS
391
+ `flex: 1 1 0`. The fixed width is the base `grow` adds to, so the widths follow only the
392
+ `grow` values (1:1:2...), not the content; text inside wraps to the column. Plain `grow={1}`
393
+ only shares the *leftover* space, so wider content makes a wider column. Rows of equal height
394
+ in a `Column` with a height: `height={0} grow={1}`. Content wider than its column overflows
395
+ into the next one (`clip` cuts it), and the row's own natural width is just its gaps: give it
396
+ room (stretched in a column, or `grow`).
397
+ - **`position="absolute"`** takes the child out of the flow: `left`/`top`/`right`/`bottom`
398
+ count from the parent's content box (inside its `padding`; negative values work), as in CSS.
399
+ It keeps its natural size, unless both `left` and `right` (or `top` and `bottom`) are set and
400
+ no `width` (`height`): then it stretches between them, within `min`/`max`. `right` (or
401
+ `bottom`) alone places it from that edge. It does not take space or change the parent's size.
402
+ Siblings are drawn and hit in child order (a later one covers an earlier one) unless they
403
+ have a **`zIndex`** (default 0; higher is above, ties keep child order). It only orders
404
+ siblings: a raised node stays inside its parent's place in the order (no global stacking
405
+ context; for that, an `<Overlay>`). What sticks out of the parent's box is drawn and clicked
406
+ there too, unless the parent clips (`clip`, `Scroll`).
407
+ - Inside a `Scroll`, an absolute child **scrolls with the content** (like CSS). For something
408
+ pinned to the visible area, put it next to the `Scroll` in a parent that does not scroll.
409
+
410
+ ```tsx
411
+ import { Box, Column, For, Grid, Image, Row, Text, createSignal } from "@nodebuilderjs/core";
412
+
413
+ // Square album covers, four per row, following the window width.
414
+ const [albums] = createSignal([{ title: "Blue", cover: "blue.jpg" }]);
415
+ <Grid columns={4} gap={12} rowGap={16}>
416
+ <For each={albums}>
417
+ {(a) => (
418
+ <Column gap={6}>
419
+ <Image src={a.cover} aspectRatio={1} fit="cover" radius={8} />
420
+ <Text maxLines={1}>{a.title}</Text>
421
+ </Column>
422
+ )}
423
+ </For>
424
+ </Grid>;
425
+
426
+ // Three columns in one row: 1 : 1 : 2, whatever their content (the manual alternative).
427
+ <Row gap={12}>
428
+ <Column width={0} grow={1}><Text>Now playing</Text></Column>
429
+ <Column width={0} grow={1}><Text>Up next, a much longer title that wraps</Text></Column>
430
+ <Column width={0} grow={2}><Text>Lyrics (twice as wide)</Text></Column>
431
+ </Row>;
432
+
433
+ // A layer over the whole cover and badges on its corners (declared last: on top).
434
+ <Box width={160} radius={12} clip background="#1f1f24">
435
+ <Column padding={16} gap={4}><Text color="#ffffff">Album</Text></Column>
436
+ <Box position="absolute" left={0} top={0} right={0} bottom={0} background="#00000066" />
437
+ <Text position="absolute" left={8} top={8} padding={[2, 6]} radius={8} background="#e01b24" color="#ffffff">NEW</Text>
438
+ <Text position="absolute" right={8} bottom={8} color="#ffffff">3:45</Text>
439
+ </Box>;
440
+
441
+ // A count badge sticking out of a card's corner (no `clip`, so it is drawn and clicked
442
+ // outside too); `zIndex` keeps it above the content even though it is declared first.
443
+ <Box width={160} padding={12} radius={10} background="#e8eaf2">
444
+ <Text position="absolute" right={-22} top={-22} zIndex={1} padding={[2, 8]} radius={10} background="#e5484d" color="#ffffff">3</Text>
445
+ <Box padding={8} radius={6} background="#3e63dd"><Text color="#ffffff">Inbox</Text></Box>
446
+ </Box>;
447
+ ```
448
+
449
+ Keep the `padding` on an inner child (as above) when an absolute layer must cover the padding
450
+ too: the edges count from inside the parent's `padding`.
253
451
 
254
452
  ## Theme and window
255
453
 
@@ -260,12 +458,64 @@ let button: ElementRef | undefined;
260
458
  follow theme changes. `color={theme.text.secondary}` keeps the color of that moment.
261
459
  - `windowControls.close()`, `.minimize()`, `.toggleMaximize()`, `.setTitle(t)`;
262
460
  `windowState.width()`, `.height()`, `.maximized()`, `.activated()` are accessors.
263
- - Animation: `createTween(() => target)` (a number that moves smoothly), `createPresence(when)`
264
- (`mounted` for `<Show>`, `progress` 0→1 for `opacity`/`translateY`), `animate(...)`.
265
- `Dialog`, `Select`, `Tooltip` and `Toggle` already animate on their own.
461
+ - **Custom title bar** (navigation or search merged with the window buttons): `<Window
462
+ decorations={false}>` drops the built-in bar (border, corners and edge resizing stay);
463
+ `dragRegion` on any widget makes it move the window, and a double click maximizes. Plain
464
+ text and empty space inside it drag; buttons, fields and anything clickable, focusable or
465
+ with `onPointerDown` keep their press.
466
+
467
+ ```tsx
468
+ import { Row, Spacer, Text, TextField, Window, WindowControls, createSignal, theme, windowState } from "@nodebuilderjs/core";
469
+
470
+ const [query, setQuery] = createSignal("");
471
+ <Window title="Player" decorations={false}>
472
+ <Row dragRegion height={56} align="center" gap={12} padding={[0, 12]}>
473
+ <Text bold color={() => (windowState.activated() ? theme.text.primary : theme.text.secondary)}>Player</Text>
474
+ <TextField width={280} value={query} onInput={setQuery} placeholder="Search" />
475
+ <Spacer />
476
+ <WindowControls />
477
+ </Row>
478
+ {/* the rest of the screen */}
479
+ </Window>
480
+ ```
481
+ - Animation: see the next section.
266
482
  - Not available yet: theme following the system, `scale`/`rotate`, selection in `TextField`
267
483
  (and Ctrl+C in it), IME, multiple windows.
268
484
 
485
+ ## Animation
486
+
487
+ ```tsx
488
+ import { Box, animate, createInteraction, createSignal, createTween, easing, mixColor, theme } from "@nodebuilderjs/core";
489
+
490
+ const row = createInteraction();
491
+ // Moves smoothly toward its source; a new target starts from wherever it is now.
492
+ const t = createTween(() => (row.hovered() ? 1 : 0), { duration: 150, easing: easing.easeInOut });
493
+ <Box
494
+ {...row.props} height={56} radius={8}
495
+ background={() => mixColor(theme.card.background, "#1db954", t() * 0.2)}
496
+ translateY={() => -2 * t()}
497
+ />;
498
+
499
+ // One-off: calls back once per frame; returns a function that stops it.
500
+ const [x, setX] = createSignal(0);
501
+ const stop = animate(0, 300, setX, { duration: 400, easing: easing.linear, onDone: () => console.log("done") });
502
+ <Box width={40} height={40} translateX={x} />;
503
+ ```
504
+
505
+ - `createTween(source, { duration, easing })`: an accessor that follows `source` (a function
506
+ returning a number). `animate(from, to, onUpdate, { duration, easing, onDone })`.
507
+ `duration` in ms (default 200), `easing` default `easing.easeOut`.
508
+ - `createPresence(when, { enter, exit, easing })` (ms, defaults 180/120): `mounted` stays true
509
+ until the exit ends (use it in `<Show when={p.mounted}>`), `progress` goes 0→1→0 (for
510
+ `opacity`, `translateY`).
511
+ - `easing.linear`, `.easeIn`, `.easeOut`, `.easeInOut`, or any `(t: number) => number`.
512
+ - `mixColor(a, b, t)`: `a` blended toward `b` (t 0..1), as `"#rrggbbaa"`; any hex form works.
513
+ Premultiplied like CSS: `mixColor("#1db954", "transparent", 0.5)` is the same green at half alpha.
514
+ - Animate numbers only (`opacity`, `translateX/Y`, `width`, colors via `mixColor`). For a
515
+ custom loop, `requestAnimationFrame((time) => ...)` runs right before the next frame.
516
+ - `Dialog`, `Select`, `Tooltip` and `Toggle` already animate on their own. Without a window
517
+ (tests) every animation jumps to its end; `frames()` (skill `nodebuilder-testing`) moves time.
518
+
269
519
  ## Selecting and copying text
270
520
 
271
521
  Plain text **does not select**, on purpose (labels, buttons, titles). Text the user
@@ -278,7 +528,7 @@ may want to copy (ids, links, paths, error messages, results) gets `selectable`:
278
528
 
279
529
  With `selectable`: dragging selects, double-click takes the word, triple-click the line,
280
530
  Ctrl+A everything and Ctrl+C copies; the cursor becomes the text cursor. It is not in the Tab order, and inside
281
- the title bar it selects instead of dragging the window. Selection is one `Text` at a time
531
+ the title bar (or any `dragRegion`) it selects instead of dragging the window. Selection is one `Text` at a time
282
532
  (it does not span several) and there is no Shift+click or Shift+arrows yet.
283
533
 
284
534
  The system clipboard (text only) is in `clipboard`:
@@ -287,8 +537,8 @@ The system clipboard (text only) is in `clipboard`:
287
537
  import { Button, clipboard, createSignal } from "@nodebuilderjs/core";
288
538
 
289
539
  const [link, setLink] = createSignal("https://example.com");
290
- <Button label="Copy link" onClick={() => clipboard.writeText(link())} />
291
- <Button label="Paste" onClick={async () => setLink(await clipboard.readText())} />
540
+ <Button label="Copy link" onClick={() => clipboard.writeText(link())} />;
541
+ <Button label="Paste" onClick={async () => setLink(await clipboard.readText())} />;
292
542
  ```
293
543
 
294
544
  - `readText()` is asynchronous (another program holds the content) and returns `""` if there
@@ -4,7 +4,11 @@ nodebuilder app: native Linux UI in TSX, **no DOM and no React**. Read this befo
4
4
 
5
5
  ## Commands
6
6
  - `bun run dev` opens the app; `bun test` runs the tests without a window; `bun run typecheck` checks types.
7
- - Before finishing: `bun run typecheck && bun test`.
7
+ - `bun run icons:add play pause` adds icon imports to `src/icons.ts` (after `bun add lucide-static`).
8
+ - `bun run screenshot` renders the app to `screenshot.png` without a window (also with no
9
+ display server); add `-- --theme dark` or `-- --scale 2`.
10
+ - Before finishing: `bun run typecheck && bun test`. **After a visual change, take a screenshot
11
+ and look at it** (open the PNG): tests check boxes, not colors, alignment or clipping.
8
12
 
9
13
  ## Model (like SolidJS, not React)
10
14
  - **Components run once.** There is no re-render and there are no hooks. State is `createSignal`;
@@ -26,12 +30,12 @@ that support skills load them on their own; others should read the topic's skill
26
30
 
27
31
  | Skill | When |
28
32
  |---|---|
29
- | `nodebuilder-ui` | screens, widgets (incl. `Toggle`, `RadioGroup`, `Slider`, `Select`, `Tabs`, `Tooltip`, `Dialog`, `ProgressBar`, `disabled`), style props, layout, `For`/`Index`/`Show`/`Switch`, `ErrorBoundary`, scrolling (horizontal, scrollbars, keyboard, `scrollIntoView`), context, `ref` (focus, scroll), `onResize`, `Overlay` (dialog, menu, tooltip), `opacity`/`translate`, theme (light/dark, `setTheme`), animations, selectable text, copy/paste |
33
+ | `nodebuilder-ui` | screens, widgets (incl. custom title bar with `dragRegion`/`WindowControls`, `Toggle`, `RadioGroup`, `Slider`, `Select`, `Tabs`, `Tooltip`, `Dialog`, `ProgressBar`, `disabled`), style props (incl. gradient `background={{ linear, angle }}`), hover/press (`hoverStyle`, `pressStyle`, `createInteraction`), layout (`Grid` of equal columns, `wrap` rows of chips, `margin`, `aspectRatio`, equal columns in a row with `width={0} grow={1}`, `position="absolute"`, `zIndex`, `clip`, `shadow`, overflow warnings), events, own components, `For`/`Index`/`Show`/`Switch`, `ErrorBoundary`, scrolling (horizontal, scrollbars, keyboard, `scrollIntoView`), context, `ref` (focus, scroll), `onResize`, `Overlay` (dialog, menu, tooltip), `opacity`/`translate`, theme (light/dark, `setTheme`), animations (`createTween`, `animate`, `easing`, `mixColor`), selectable text, copy/paste |
30
34
  | `nodebuilder-from-react` | before installing libraries or porting React/web code |
31
35
  | `nodebuilder-images` | images (PNG, JPEG, WebP, AVIF), files in the app |
32
- | `nodebuilder-icons` | icons (Lucide, Phosphor) and SVG with `Icon` |
36
+ | `nodebuilder-icons` | icons (Lucide, Phosphor) and SVG with `Icon`; `bun run icons:add <names...>` writes their imports into `src/icons.ts` |
33
37
  | `nodebuilder-charts` | ready-made charts (`@nodebuilderjs/core/charts`, Recharts API) |
34
- | `nodebuilder-drawing` | freehand drawing with `Canvas` |
35
- | `nodebuilder-testing` | tests without a window (`mount`, `findByText`, `click`, `interact`, `frames`, clipboard) |
38
+ | `nodebuilder-drawing` | freehand drawing with `Canvas` (reactive `draw`, dashed lines, gradient fills with `g.linearGradient`, text cut to a width with "…") |
39
+ | `nodebuilder-testing` | tests without a window (`mount`, `findByText`/`findAllByText`, `findByLabel` (`ariaLabel`), `click`, `interact`, hidden targets that throw, `layoutWarnings()`, real text metrics (`useRealTextMetrics`), `frames`, clipboard), screenshots (`nodebuilder screenshot`, `screenshot()`) |
36
40
  | `nodebuilder-packaging` | AppImage/.deb/.rpm, embedded files, icon and app id |
37
- | `nodebuilder-performance` | slowness, long lists, live data, animation |
41
+ | `nodebuilder-performance` | slowness, long lists, live data, animation, CPU use while idle (`NB_STATS`, `NB_TRACE=frames`) |
@@ -8,6 +8,7 @@ bun run dev # opens the window and restarts on save
8
8
  bun start # runs once
9
9
  bun test # tests without a window (test/)
10
10
  bun run typecheck # TypeScript 7
11
+ bun run screenshot # screenshot.png of the app, without opening a window
11
12
  bun run package # AppImage, .deb, .rpm and .tar.gz in dist/
12
13
  ```
13
14
 
@@ -1,2 +1,3 @@
1
1
  node_modules/
2
2
  dist/
3
+ screenshot.png
@@ -1,6 +1,5 @@
1
- // SVG icons (drawn for this template). For more icons, `bun add lucide-static` and
2
- // `import x from "lucide-static/icons/x.svg" with { type: "text" }`: see the
3
- // nodebuilder-icons skill.
1
+ // SVG icons (drawn for this template). For more: `bun add lucide-static`, then
2
+ // `bun run icons:add play pause` adds their imports below (see the nodebuilder-icons skill).
4
3
  const svg = (body: string) =>
5
4
  `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
6
5