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 +2 -0
- package/package.json +1 -1
- package/template/.agents/skills/nodebuilder-drawing/SKILL.md +41 -6
- package/template/.agents/skills/nodebuilder-from-react/SKILL.md +14 -3
- package/template/.agents/skills/nodebuilder-icons/SKILL.md +52 -10
- package/template/.agents/skills/nodebuilder-images/SKILL.md +11 -5
- package/template/.agents/skills/nodebuilder-performance/SKILL.md +24 -5
- package/template/.agents/skills/nodebuilder-testing/SKILL.md +110 -8
- package/template/.agents/skills/nodebuilder-ui/SKILL.md +269 -19
- package/template/AGENTS.md +10 -6
- package/template/README.md +1 -0
- package/template/_gitignore +1 -0
- package/template/src/icons.ts +2 -3
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: 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
|
|
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
|
-
-
|
|
45
|
-
is
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
<
|
|
23
|
-
<Icon svg={
|
|
24
|
-
<Icon svg={
|
|
25
|
-
<
|
|
26
|
-
<
|
|
27
|
-
<Icon svg={
|
|
28
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
<
|
|
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
|
|
12
|
-
|
|
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)
|
|
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
|
|
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
|
|
31
|
-
call `relayout()` before checking `x`, `y`, `width`, `height` after changing state.
|
|
32
|
-
- `
|
|
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
|
-
- `
|
|
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`, `
|
|
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
|
-
-
|
|
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)
|
|
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`
|
|
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
|
|
252
|
-
|
|
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
|
-
-
|
|
264
|
-
|
|
265
|
-
`
|
|
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
|
package/template/AGENTS.md
CHANGED
|
@@ -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
|
-
-
|
|
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`) |
|
package/template/README.md
CHANGED
|
@@ -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
|
|
package/template/_gitignore
CHANGED
package/template/src/icons.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// SVG icons (drawn for this template). For more
|
|
2
|
-
// `
|
|
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
|
|