create-nodebuilderjs 0.2.1 → 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.
Files changed (34) hide show
  1. package/index.js +12 -10
  2. package/package.json +2 -2
  3. package/template/.agents/skills/nodebuilder-charts/SKILL.md +87 -0
  4. package/template/.agents/skills/nodebuilder-drawing/SKILL.md +89 -0
  5. package/template/.agents/skills/nodebuilder-from-react/SKILL.md +80 -0
  6. package/template/.agents/skills/nodebuilder-icons/SKILL.md +95 -0
  7. package/template/.agents/skills/nodebuilder-images/SKILL.md +56 -0
  8. package/template/.agents/skills/nodebuilder-packaging/SKILL.md +37 -0
  9. package/template/.agents/skills/nodebuilder-performance/SKILL.md +59 -0
  10. package/template/.agents/skills/nodebuilder-testing/SKILL.md +199 -0
  11. package/template/.agents/skills/nodebuilder-ui/SKILL.md +535 -58
  12. package/template/AGENTS.md +34 -27
  13. package/template/README.md +19 -18
  14. package/template/_gitignore +1 -0
  15. package/template/src/App.tsx +16 -13
  16. package/template/src/components/Activity.tsx +24 -0
  17. package/template/src/components/Counter.tsx +24 -0
  18. package/template/src/components/Tasks.tsx +54 -0
  19. package/template/src/icons.ts +10 -0
  20. package/template/src/state.ts +50 -0
  21. package/template/test/App.test.tsx +45 -35
  22. package/template/.agents/skills/nodebuilder-desempenho/SKILL.md +0 -36
  23. package/template/.agents/skills/nodebuilder-desenho/SKILL.md +0 -54
  24. package/template/.agents/skills/nodebuilder-empacotar/SKILL.md +0 -37
  25. package/template/.agents/skills/nodebuilder-graficos/SKILL.md +0 -87
  26. package/template/.agents/skills/nodebuilder-icones/SKILL.md +0 -53
  27. package/template/.agents/skills/nodebuilder-imagens/SKILL.md +0 -50
  28. package/template/.agents/skills/nodebuilder-sem-react/SKILL.md +0 -48
  29. package/template/.agents/skills/nodebuilder-testes/SKILL.md +0 -56
  30. package/template/src/componentes/Atividade.tsx +0 -24
  31. package/template/src/componentes/Contador.tsx +0 -22
  32. package/template/src/componentes/Tarefas.tsx +0 -38
  33. package/template/src/estado.ts +0 -50
  34. package/template/src/icones.ts +0 -11
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // bun create nodebuilderjs [dir] [--title "Meu App"] [--no-install] [--no-git]
2
+ // bun create nodebuilderjs [dir] [--title "My App"] [--no-install] [--no-git]
3
3
  // Plain JS (not TS) so it runs under both `bun create` and `npm create`.
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
@@ -22,19 +22,19 @@ const { values: opts, positionals } = parseArgs({
22
22
  });
23
23
 
24
24
  if (opts.help) {
25
- console.log(`uso: bun create nodebuilderjs [pasta] [--title "Meu App"] [--no-install] [--no-git]`);
25
+ console.log(`usage: bun create nodebuilderjs [folder] [--title "My App"] [--no-install] [--no-git]`);
26
26
  process.exit(0);
27
27
  }
28
28
 
29
29
  let dir = positionals[0];
30
30
  if (!dir) {
31
31
  const rl = createInterface({ input: process.stdin, output: process.stdout });
32
- dir = (await rl.question("Nome do projeto: (meu-app) ")).trim() || "meu-app";
32
+ dir = (await rl.question("Project name: (my-app) ")).trim() || "my-app";
33
33
  rl.close();
34
34
  }
35
35
  const target = resolve(dir);
36
36
  if (existsSync(target) && readdirSync(target).length) {
37
- console.error(`A pasta ${dir} já existe e não está vazia.`);
37
+ console.error(`The folder ${dir} already exists and is not empty.`);
38
38
  process.exit(1);
39
39
  }
40
40
 
@@ -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}` },
@@ -84,8 +86,8 @@ const run = (cmd, args) => spawnSync(cmd, args, { cwd: target, stdio: "inherit"
84
86
  const hasBun = spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0;
85
87
 
86
88
  if (!opts["no-install"]) {
87
- if (!hasBun) console.warn("Bun não encontrado: instale em https://bun.sh e rode `bun install`.");
88
- else if (!run("bun", ["install"])) console.warn("`bun install` falhou; rode de novo dentro da pasta.");
89
+ if (!hasBun) console.warn("Bun not found: install it from https://bun.sh and run `bun install`.");
90
+ else if (!run("bun", ["install"])) console.warn("`bun install` failed; run it again inside the folder.");
89
91
  }
90
92
  if (!opts["no-git"] && spawnSync("git", ["--version"], { stdio: "ignore" }).status === 0) {
91
93
  run("git", ["init", "-q"]);
@@ -93,10 +95,10 @@ if (!opts["no-git"] && spawnSync("git", ["--version"], { stdio: "ignore" }).stat
93
95
 
94
96
  const cd = relative(process.cwd(), target) || ".";
95
97
  console.log(`
96
- Pronto! ${title} criado em ${cd}
98
+ Done! ${title} created in ${cd}
97
99
 
98
100
  cd ${cd}
99
- bun run dev # abre a janela e recarrega ao salvar
100
- bun test # testes sem janela
101
- bun run package # AppImage, .deb, .rpm e .tar.gz em dist/
101
+ bun run dev # opens the window and reloads on save
102
+ bun test # tests without a window
103
+ bun run package # AppImage, .deb, .rpm and .tar.gz in dist/
102
104
  `);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-nodebuilderjs",
3
- "version": "0.2.1",
4
- "description": "Cria um app nodebuilder: bun create nodebuilderjs meu-app",
3
+ "version": "0.3.0",
4
+ "description": "Creates a nodebuilder app: bun create nodebuilderjs my-app",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: nodebuilder-charts
3
+ description: Line, area, bar, composed and pie/donut charts in nodebuilder with @nodebuilderjs/core/charts, which uses the same API as Recharts. Use when building dashboards, reports, time series, category comparisons or share breakdowns (pie), and whenever you would reach for Recharts, Chart.js or Victory.
4
+ ---
5
+
6
+ # Charts with `@nodebuilderjs/core/charts`
7
+
8
+ Already ships with `@nodebuilderjs/core` (install nothing); it only ends up in the app if imported.
9
+
10
+ ```tsx
11
+ import { createSignal, theme } from "@nodebuilderjs/core";
12
+ import {
13
+ Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, XAxis, YAxis,
14
+ } from "@nodebuilderjs/core/charts";
15
+
16
+ const [sales] = createSignal([
17
+ { month: "Jan", revenue: 120, target: 100 },
18
+ { month: "Feb", revenue: 150, target: 110 },
19
+ { month: "Mar", revenue: 90, target: 120 },
20
+ ]);
21
+ const channels = [{ name: "Store", value: 42 }, { name: "Website", value: 31 }];
22
+
23
+ export const charts = () => (
24
+ <>
25
+ <LineChart data={sales} height={240}>
26
+ <CartesianGrid vertical={false} />
27
+ <XAxis dataKey="month" />
28
+ <YAxis tickFormatter={(v) => `$${v}`} />
29
+ <Line dataKey="revenue" name="Revenue" />
30
+ <Line dataKey="target" name="Target" stroke="#e5a50a" type="monotone" dot={false} />
31
+ <Legend />
32
+ </LineChart>
33
+ <BarChart data={sales} height={200}>
34
+ <XAxis dataKey="month" />
35
+ <YAxis />
36
+ <Bar dataKey="revenue" fill={theme.accent.base} radius={3} />
37
+ </BarChart>
38
+ <AreaChart data={sales} height={160}>
39
+ <YAxis domain={[0, 200]} />
40
+ <Area dataKey="revenue" type="monotone" />
41
+ </AreaChart>
42
+ <PieChart height={220}>
43
+ <Pie data={channels} dataKey="value" nameKey="name" innerRadius="50%" paddingAngle={2}>
44
+ <Cell fill="#3584e4" />
45
+ <Cell fill="#26a269" />
46
+ </Pie>
47
+ <Legend />
48
+ </PieChart>
49
+ </>
50
+ );
51
+ ```
52
+
53
+ ## What is supported
54
+
55
+ | Component | Props |
56
+ |---|---|
57
+ | `LineChart`, `AreaChart`, `BarChart`, `ComposedChart` (mixed) | `data` (array or signal), `height` (240), `width` or `grow`, and the style props |
58
+ | `Line` | `dataKey`, `name`, `stroke`, `strokeWidth` (2), `strokeDasharray` (`"6 4"`), `dot` (true), `type` (`linear`/`monotone`) |
59
+ | `Area` | `dataKey`, `name`, `stroke`, `fill`, `fillOpacity` (0.25), `type`, `stackId` |
60
+ | `Bar` | `dataKey`, `name`, `fill`, `radius`, `stackId`, `label` (true or `(value) => text`) |
61
+ | `XAxis` | `dataKey`, `tickFormatter`, `hide` |
62
+ | `YAxis` | `domain={[min, max]}`, `tickCount`, `tickFormatter`, `hide` |
63
+ | `CartesianGrid` | `horizontal`, `vertical`, `stroke`, `strokeDasharray` |
64
+ | `PieChart` + `Pie` | `data`, `dataKey`, `nameKey`, `innerRadius`, `outerRadius` (px or `"80%"`), `paddingAngle` (degrees), `label` (true or `({ name, value, percent }) => text`); `<Cell fill>` per slice |
65
+ | `Tooltip` | `formatter(value, name)`, `labelFormatter(category)`; guide line at the category under the mouse (on a pie: the slice) |
66
+ | `Legend` | no props (bottom, centered) |
67
+
68
+ `dataKey` is the field name or a function `(row) => value`.
69
+
70
+ ## Differences from Recharts
71
+
72
+ - **No `ResponsiveContainer`:** the chart already fills the parent's width; give it a `height`.
73
+ - Series with the same `stackId` stack (bars in a single column, areas on top of each other);
74
+ the y axis accounts for the totals.
75
+ - The x axis drops labels that would overlap, keeping the last one (like `interval="preserveEnd"`).
76
+ - Not available yet: `Brush`, `ReferenceLine`, `ScatterChart`, `RadarChart`, secondary
77
+ axes, animations; on `Line`, `label`.
78
+ - Pies start at the top and go clockwise (`startAngle`/`endAngle` do not exist); the
79
+ labels sit outside the slices, so keep `outerRadius` around 70% for them to fit.
80
+ - Colors: without `stroke`/`fill`, each series takes the next palette color (the first is
81
+ `theme.accent.base`).
82
+ - For anything beyond this, draw with `<Canvas>` (skill `nodebuilder-drawing`).
83
+
84
+ ## Performance
85
+
86
+ Changing the `data` signal redraws only that chart. Updating every frame (live data
87
+ with `requestAnimationFrame`) works; hundreds of points per series are fine.
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: nodebuilder-drawing
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
+ ---
5
+
6
+ # Drawing with `<Canvas>`
7
+
8
+ ```tsx
9
+ import { Canvas, createSignal, theme } from "@nodebuilderjs/core";
10
+
11
+ const [data, setData] = createSignal([12, 40, 33, 70, 55, 90]);
12
+
13
+ <Canvas height={160} draw={(g) => {
14
+ const v = data(); // signals read here redraw the canvas
15
+ const x = (i: number) => (i / (v.length - 1)) * g.width;
16
+ const y = (n: number) => g.height - (n / 100) * g.height;
17
+ for (let n = 0; n <= 100; n += 25) g.line(0, y(n), g.width, y(n), "#0000001a", 1, "butt"); // grid
18
+ const pts = v.flatMap((n, i) => [x(i), y(n)]);
19
+ g.polygon([0, g.height, ...pts, g.width, g.height], "#3584e433"); // area
20
+ g.polyline(pts, theme.accent.base, 2); // line
21
+ g.circle(x(v.length - 1), y(v[v.length - 1]!), 4, theme.accent.base);
22
+ g.text("max 100", 4, 0, theme.text.secondary, 11);
23
+ }} />
24
+ ```
25
+
26
+ ## API (`g`)
27
+
28
+ | Method | What |
29
+ |---|---|
30
+ | `g.width`, `g.height` | canvas size (inside the `padding`) |
31
+ | `rect(x, y, w, h, color, radius?)` | rectangle (bars) |
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
+ | `polygon(points, color)` | filled polygon (concave is fine; self-intersecting is not drawn) |
35
+ | `circle(cx, cy, radius, color)` | disc |
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?, { 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.
70
+
71
+ ## Rules
72
+
73
+ - Give the canvas a size: `width`/`height` or `grow`. Without a size, `draw` does not run.
74
+ - Local coordinates, with `y` pointing down. Flip it for charts: `g.height - value * scale`.
75
+ - `draw` must only draw: do not create signals or effects or call `set` inside it.
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.
81
+ - Live data: update the signal with `requestAnimationFrame`; thousands of points
82
+ work (5000 line + area points at ~120 fps).
83
+ - Pie/donut: accumulate the slices in radians (`fraction * 2 * Math.PI`); a small gap
84
+ (`- 0.03` on the sweep) separates the slices visually.
85
+ - Translucent colors on `polyline` darken at the joins: prefer opaque colors for lines
86
+ and translucent ones for areas.
87
+ - Interaction (tooltip, clicking a point, dragging): use `onPointerMove`/`onClick` on the
88
+ `Canvas` itself; `e.localX`/`e.localY` are in the same coordinates as the drawing (the `padding`
89
+ offsets them: subtract it).
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: nodebuilder-from-react
3
+ description: Translation of React, DOM, CSS and web library habits to nodebuilder, and which npm packages work. Use before installing any UI, icon, component, state or styling library (lucide-react, MUI, Tailwind, styled-components, react-*, solid-js...), when writing useState/useEffect/className/div/onChange, or when porting React/Solid/HTML code to this app.
4
+ ---
5
+
6
+ # Coming from React (or the web)
7
+
8
+ nodebuilder draws the interface with the GPU, without a browser: **there is no DOM, CSS, React
9
+ or Solid**. The JSX looks the same as always, but the elements are the widgets from
10
+ `@nodebuilderjs/core`.
11
+
12
+ ## Direct translation
13
+
14
+ | On the web / React | In nodebuilder |
15
+ |---|---|
16
+ | `useState(0)` | `const [n, setN] = createSignal(0)`; read with `n()` |
17
+ | `useMemo` | `createMemo(() => ...)` |
18
+ | `useEffect(() => ..., [deps])` | `createEffect(() => ...)` (automatic deps) + `onCleanup` |
19
+ | component re-render | does not exist: the component runs **once** |
20
+ | `{count}` in JSX | `{count}` (the function, not `count()`), otherwise it stays fixed |
21
+ | `style={{ padding: 8 }}`, `className` | props: `padding={8}`, `background="#fff"` |
22
+ | `div`, `span`, `p`, `h1` | `Column`, `Row`, `Box`, `Text fontSize={24} bold` |
23
+ | input `onChange` | `TextField value={v} onInput={setV}` |
24
+ | `<input type="range">`, `type="radio"`, MUI switch, `<progress>` | `Slider`, `RadioGroup`, `Toggle`, `ProgressBar`/`Spinner` |
25
+ | `<select>`, Dialog/Modal, Tabs, Tooltip (MUI, Radix, shadcn) | `Select`, `Dialog`, `Tabs` (+ `<Switch>` for the content), `Tooltip` |
26
+ | `disabled` on input/button | `disabled` on any control (value or function) |
27
+ | `list.map(...)` in JSX | `<For each={list}>{(item) => ...}</For>` |
28
+ | `list.map((x, i) => ...)` with `key={i}` (list of primitives) | `<Index each={list}>{(item, i) => ...}</Index>`; `item` is an accessor (`item()`), `i` is a number |
29
+ | `cond && <X/>` | `<Show when={() => cond()}><X/></Show>` |
30
+ | `cond ? <A/> : <B/>`, `switch` that returns JSX | `<Show when={...} fallback={<B/>}><A/></Show>`; several cases: `<Switch fallback={...}><Match when={() => s() === "a"}>...</Match></Switch>` |
31
+ | `user && <X name={user.name}/>` | `<Show when={user}>{(u) => <X name={u().name} />}</Show>` (same as in Solid) |
32
+ | error boundary (`componentDidCatch`, `react-error-boundary`) | `<ErrorBoundary fallback={(error, reset) => ...}>` (as in Solid); catches errors on creation and in effects, not in `onClick` code |
33
+ | `createContext` / `useContext` | the same: `createContext(default)`, `<Ctx.Provider value={...}>`, `useContext(Ctx)`; a value that changes goes as an accessor |
34
+ | `useRef` + `ref={r}` / `el.focus()` | `ref={(el) => ...}` on any widget: `el.focus()`, `el.blur()`, `el.scrollTo(y)`, `el.width` |
35
+ | `overflow-y: auto` / `overflow-x: auto` / `overflow: auto` | `<Scroll>` / `<Scroll horizontal>` / `<Scroll horizontal vertical>` (with `grow={1}` or a size) |
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
+ | `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
+ | `ResizeObserver`, media queries | `onResize={(s) => setWidth(s.width)}` + signal |
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)`) |
48
+ | CSS `opacity`, `transform: translate(...)` | props `opacity`, `translateX`, `translateY` (no `scale`/`rotate`) |
49
+ | `transition`, framer-motion, react-spring, `AnimatePresence` | `createTween`, `createPresence` (+ `<Show when={p.mounted}>`), `animate` |
50
+ | `prefers-color-scheme`, `ThemeProvider` with a dark theme | `setTheme("dark")`, `colorScheme()`, colors in functions: `() => theme.text.primary` |
51
+ | `white-space: nowrap` / `text-overflow: ellipsis` / `-webkit-line-clamp` | `wrap={false}` / `maxLines={1}` / `maxLines={n}` |
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}>` |
57
+ | selectable text (the default on the web) | only with `<Text selectable>`; without the prop it does not select (like `user-select: none`) |
58
+ | `navigator.clipboard.writeText/readText` | `clipboard.writeText(t)` / `await clipboard.readText()` |
59
+ | `fetch`, `fs`, timers | they work (Bun/Node APIs); update signals with the result |
60
+
61
+ ## npm libraries
62
+
63
+ - **Work:** pure logic without the DOM (date-fns, zod, lodash, HTTP clients, Bun's SQLite,
64
+ parsers). Plain SVG icons with the `Icon` widget: `lucide-static`, `lucide` (the data,
65
+ not `createIcons()`) and `@phosphor-icons/core` (skill `nodebuilder-icons`).
66
+ - **Do not work:** anything that renders React/Solid/Vue or touches the DOM: `lucide-react`,
67
+ `lucide-solid`, `@phosphor-icons/react`, MUI, Chakra, shadcn/ui, Radix, Tailwind,
68
+ styled-components, react-router, browser canvas/SVG charting libraries.
69
+ - Charts: use `@nodebuilderjs/core/charts` (same API as Recharts; skill
70
+ `nodebuilder-charts`) or draw with `<Canvas>` (skill `nodebuilder-drawing`). Recharts,
71
+ Chart.js and D3 need the DOM and do not work.
72
+ - When in doubt: if the package imports `react`, `react-dom`, `solid-js/web` or uses `document`,
73
+ it will not work here.
74
+
75
+ ## Common mistakes
76
+
77
+ - Calling `useContext` inside a handler or `setTimeout`: it only works in the component body.
78
+ - Reading the signal in the component body (`const x = count();`) and expecting it to update.
79
+ - Destructuring reactive props (`const { value } = props`) and losing the function.
80
+ - Putting `createSignal` inside an effect to "reset" state: create it outside and use `set`.
@@ -0,0 +1,95 @@
1
+ ---
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 (generate the imports with `nodebuilder icons add`), when choosing an icon library, or when showing any .svg file.
4
+ ---
5
+
6
+ # Icons
7
+
8
+ ```sh
9
+ bun add lucide-static # Lucide .svg files
10
+ bun add lucide # or: the same icons as data (import { Camera } from "lucide")
11
+ bun add @phosphor-icons/core # Phosphor: assets/<weight>/<name>.svg
12
+ ```
13
+
14
+ ```tsx
15
+ import { Button, Column, Icon, Row, Text, createSignal, theme } from "@nodebuilderjs/core";
16
+ import camera from "lucide-static/icons/camera.svg" with { type: "text" };
17
+ import heart from "@phosphor-icons/core/assets/duotone/heart-duotone.svg" with { type: "text" };
18
+ import { Heart, Play, Star } from "lucide";
19
+ import starFill from "@phosphor-icons/core/assets/fill/star-fill.svg" with { type: "text" };
20
+
21
+ const [active] = createSignal(false);
22
+ const [liked, setLiked] = createSignal(false);
23
+
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>
36
+ ```
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
+
68
+ ## Rules
69
+
70
+ - **Never** use `lucide-react`, `lucide-solid`, `@phosphor-icons/react`, `react-icons` or
71
+ `@heroicons/react`: they require React/DOM. Use the packages above.
72
+ - Import `.svg` files with `with { type: "text" }` (the import becomes the SVG text and
73
+ `nodebuilder package` embeds it). `with { type: "file" }` (path) also works.
74
+ - `svg` accepts: SVG text, a path to a `.svg`, or data from the `lucide` package. It can be
75
+ a function (`svg={() => (open() ? chevronUp : chevronDown)}`).
76
+ - `size` is the height in logical px (default 20, `theme.icon.size`); the width follows the
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.
83
+ - Changing the color is free (single-color icons are tinted on the GPU); changing the size
84
+ rasterizes again (~0.5 ms). They stay sharp on HiDPI screens.
85
+ - Phosphor weights: `thin`, `light`, `regular`, `bold`, `fill`, `duotone` (folders in
86
+ `assets/`; names carry the weight suffix, except `regular`: `heart.svg`, `heart-fill.svg`).
87
+
88
+ ## SVG limits
89
+
90
+ Supported: `path` (with arcs and curves), `rect`, `circle`, `ellipse`, `line`, `polyline`,
91
+ `polygon`, `g`, `transform`, `fill`/`stroke` (colors, `currentColor`), stroke width, caps,
92
+ joins, `fill-rule`, opacities and inline `style=""`.
93
+ Not supported (the rest of the drawing still shows): gradients, patterns, text, filters, masks,
94
+ clip paths, `<use>`, dashes and `<style>`. For logos and illustrations like that: export a 2x PNG and
95
+ use `Image`.
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: nodebuilder-images
3
+ description: Show images (PNG, JPEG, WebP, AVIF, HEIC) with nodebuilder's Image widget and include the files in the packaged app. Use when adding photos, logos, avatars, thumbnails or any image file to the UI. For icons and SVG, use the nodebuilder-icons skill.
4
+ ---
5
+
6
+ # Images
7
+
8
+ ```tsx
9
+ import { Column, Image, createSignal } from "@nodebuilderjs/core";
10
+ import logo from "./assets/logo.png" with { type: "file" };
11
+ import photo from "./assets/photo.jpg" with { type: "file" };
12
+
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>
20
+ ```
21
+
22
+ ## Rules
23
+
24
+ - **Import files with `with { type: "file" }`.** The import becomes the file path, and
25
+ `nodebuilder package` embeds the image in the executable. `new URL("./x.png", import.meta.url)`
26
+ and bare paths work in `bun run dev`, but **break in the packaged app**.
27
+ - `src` accepts: a path (relative to the current directory), a `file:` `URL`, the file bytes
28
+ (`Uint8Array`, e.g. from a `fetch`) or pixels `{ width, height, data }` (8-bit RGBA).
29
+ It can be a function: changing `src` releases the old image.
30
+ - Size: without `width`/`height`, it uses the file's pixels (1 pixel = 1 logical px). With only one
31
+ of the two, the other follows the aspect ratio. For HiDPI screens, use a 2x larger file and fix the
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).
34
+ - `fit`: `contain` (default, whole image centered), `cover` (fills and crops), `fill`
35
+ (stretches). `radius` rounds the corners; `opacity` goes from 0 to 1.
36
+ - The same file used in several places is decoded once; the memory is released when the
37
+ last `Image` leaves the screen.
38
+ - A file that fails to load: a warning in the console and nothing drawn (the app does not crash).
39
+
40
+ ## Formats
41
+
42
+ | Format | How | Notes |
43
+ |---|---|---|
44
+ | PNG, JPEG, WebP | decoded in TypeScript | always work; JPEG honors the EXIF orientation |
45
+ | AVIF, HEIC | `libheif` system library | without it, only these fail ("needs libheif"); the `.deb`/`.rpm` package suggests it but does not install it. Prefer WebP/PNG/JPEG for the app's own images |
46
+ | SVG | through the `Icon` widget (skill `nodebuilder-icons`) | subset for icons; illustrations with gradients/text: export a 2x PNG |
47
+ | Animated GIF, video | not supported | |
48
+
49
+ ## Caveats
50
+
51
+ - Decoding is synchronous: a 4-megapixel photo takes ~0.1 s and freezes the UI
52
+ meanwhile. Shrink large images before putting them in the app (e.g. thumbnails).
53
+ - Images scaled down a lot on screen look jagged (no mipmaps): use files close to the
54
+ displayed size.
55
+ - In `bun test`, `Image` works without a window (textures are simulated); AVIF/HEIC
56
+ need the native addon and libheif.
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: nodebuilder-packaging
3
+ description: Build and distribute the nodebuilder app (AppImage, .deb, .rpm, .tar.gz) with nodebuilder package, bundle files (images, data) into the executable and configure name, icon and app id. Use when preparing a release to install, when changing the app's name/icon, or when the packaged app behaves differently from bun run dev.
4
+ ---
5
+
6
+ # Packaging
7
+
8
+ ```sh
9
+ bun run package # = nodebuilder package src/main.tsx --title "..." --app-id ...
10
+ bunx nodebuilder package src/main.tsx --targets deb,rpm --icon assets/icon.svg --version 1.2.0
11
+ bunx nodebuilder package --help
12
+ ```
13
+
14
+ Produces in `dist/` a single executable (Bun + app + native core, ~31 MB compressed) inside
15
+ an AppImage, `.deb`, `.rpm` and `.tar.gz`, with a menu entry and icon. Runs on distros with glibc
16
+ 2.34+ (Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9+).
17
+
18
+ ## Rules
19
+
20
+ - **App files** (images, JSON, sounds) are bundled into the executable only if imported with
21
+ `with { type: "file" }` (the import becomes the path). `readFileSync("./data.json")` with a
22
+ loose path works in dev and **fails in the package**. For JSON, `import data from
23
+ "./data.json"` also bundles it.
24
+ - The `<Window>`'s `appId` must match `--app-id` (the template already does this); otherwise the
25
+ icon and taskbar grouping do not work.
26
+ - `--icon`: `.svg` or a 256x256 PNG. `--name` is the executable/package name (lowercase).
27
+ - System libraries (Wayland, X11, EGL, FreeType, HarfBuzz, fontconfig) are declared
28
+ dependencies of the packages, never bundled. `libheif` (only for AVIF/HEIC) is
29
+ only suggested.
30
+ - User data: save it in `~/.local/share/<app>` (`process.env.XDG_DATA_HOME`), never
31
+ next to the executable (`/usr/bin` is not writable).
32
+
33
+ ## Verify
34
+
35
+ - Run the generated executable outside the project folder (`cd / && path/to/app`) to catch
36
+ files that only existed on the development disk.
37
+ - AppImage: `chmod +x dist/*.AppImage && ./dist/*.AppImage`.
@@ -0,0 +1,59 @@
1
+ ---
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 (also while idle), or when building lists with hundreds of items, live data or animations.
4
+ ---
5
+
6
+ # Performance
7
+
8
+ ## Measure
9
+
10
+ ```sh
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 })
14
+ ```
15
+
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.
36
+
37
+ ## Patterns
38
+
39
+ - **Large lists:** always inside `<Scroll>`; whatever is outside the visible area is not
40
+ positioned or drawn. Even so, each item becomes nodes: with thousands, filter or paginate.
41
+ - **`<For>` is keyed by reference:** recreating the objects (`list.map((x) => ({ ...x }))`)
42
+ destroys and recreates every row. Keep the references and change only what changed; for
43
+ fields that change often, store a signal inside the item (`{ id, cpu: createSignal(0) }`).
44
+ If the data always arrives as new objects (a JSON every second) or is primitive,
45
+ use `<Index>`: rows stay by position and only the `item()` accessor changes.
46
+ - **Update the minimum:** one signal per changing value, and thin functions in props
47
+ (`text={() => fmt(item.cpu())}`) instead of one giant signal holding all the state.
48
+ - **Several changes together:** `batch(() => { setA(1); setB(2); })` produces a single frame and
49
+ runs the effects once.
50
+ - **Animation and live data:** to go from one value to another use `createTween`/`animate`; for
51
+ a loop, `requestAnimationFrame(cb)` (paced by the display), not a short `setInterval`. To
52
+ stop, do not request the next frame. `Spinner` and `ProgressBar` without a value request frames while
53
+ they are on screen: hide them when the wait is over.
54
+ - **Text:** measurements are cached; texts that change every frame (counters, clocks)
55
+ cost little, but avoid generating thousands of new strings per frame.
56
+ - **Images:** decoding is expensive and synchronous; reuse the same file (it is shared) and
57
+ use files at the displayed size.
58
+ - Heavy CPU work (parsing large files, computations) freezes the interface: split it into
59
+ chunks with `setTimeout(..., 0)` or use a `Worker`.