@brika/ui-kit 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 (45) hide show
  1. package/README.md +3 -0
  2. package/package.json +32 -0
  3. package/src/__tests__/define-brick.test.ts +125 -0
  4. package/src/__tests__/mutations.test.ts +211 -0
  5. package/src/__tests__/nodes.test.ts +1595 -0
  6. package/src/colors.ts +99 -0
  7. package/src/define-brick.ts +92 -0
  8. package/src/descriptors.ts +28 -0
  9. package/src/index.ts +154 -0
  10. package/src/jsx-dev-runtime.ts +3 -0
  11. package/src/jsx-runtime.ts +60 -0
  12. package/src/mutations.ts +79 -0
  13. package/src/nodes/_shared.ts +129 -0
  14. package/src/nodes/avatar.ts +36 -0
  15. package/src/nodes/badge.ts +29 -0
  16. package/src/nodes/box.ts +69 -0
  17. package/src/nodes/button.ts +44 -0
  18. package/src/nodes/callout.ts +23 -0
  19. package/src/nodes/chart.ts +43 -0
  20. package/src/nodes/checkbox.ts +27 -0
  21. package/src/nodes/code-block.ts +27 -0
  22. package/src/nodes/column.ts +37 -0
  23. package/src/nodes/divider.ts +25 -0
  24. package/src/nodes/grid.ts +44 -0
  25. package/src/nodes/icon.ts +28 -0
  26. package/src/nodes/image.ts +29 -0
  27. package/src/nodes/index.ts +54 -0
  28. package/src/nodes/key-value.ts +31 -0
  29. package/src/nodes/link.ts +25 -0
  30. package/src/nodes/markdown.ts +16 -0
  31. package/src/nodes/progress.ts +28 -0
  32. package/src/nodes/row.ts +37 -0
  33. package/src/nodes/section.ts +42 -0
  34. package/src/nodes/select.ts +44 -0
  35. package/src/nodes/skeleton.ts +23 -0
  36. package/src/nodes/slider.ts +40 -0
  37. package/src/nodes/spacer.ts +17 -0
  38. package/src/nodes/stat-value.ts +26 -0
  39. package/src/nodes/status.ts +20 -0
  40. package/src/nodes/table.ts +35 -0
  41. package/src/nodes/tabs.ts +52 -0
  42. package/src/nodes/text-input.ts +53 -0
  43. package/src/nodes/text.ts +66 -0
  44. package/src/nodes/toggle.ts +32 -0
  45. package/src/nodes/video.ts +24 -0
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @brika/ui-kit
2
+
3
+ Descriptor types and helpers for building Brika plugin UI bricks.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@brika/ui-kit",
3
+ "version": "0.3.0",
4
+ "description": "Descriptor types and builder functions for BRIKA plugin UI bricks",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "BRIKA Team",
8
+ "homepage": "https://github.com/maxscharwath/brika/tree/main/packages/ui-kit#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/maxscharwath/brika/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/maxscharwath/brika.git",
15
+ "directory": "packages/ui-kit"
16
+ },
17
+ "keywords": ["brika", "ui", "bricks", "board", "descriptors"],
18
+ "exports": {
19
+ ".": "./src/index.ts",
20
+ "./jsx-runtime": "./src/jsx-runtime.ts",
21
+ "./jsx-dev-runtime": "./src/jsx-dev-runtime.ts"
22
+ },
23
+ "files": ["src"],
24
+ "scripts": {
25
+ "tsc": "bunx --biome tsc --noEmit",
26
+ "prepublishOnly": "bun run tsc"
27
+ },
28
+ "dependencies": {
29
+ "@brika/shared": "0.3.0",
30
+ "zod": "^4.3.4"
31
+ }
32
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Tests for defineBrick
3
+ */
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import type {
7
+ BrickComponent,
8
+ BrickInstanceContext,
9
+ BrickTypeSpec,
10
+ CompiledBrickType,
11
+ } from '../define-brick';
12
+ import { defineBrick } from '../define-brick';
13
+ import { Stat, Text } from '../nodes';
14
+
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // Helpers
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ const minimalSpec: BrickTypeSpec = {
20
+ id: 'test-brick',
21
+ families: ['sm'],
22
+ };
23
+
24
+ const fullSpec: BrickTypeSpec = {
25
+ id: 'thermostat',
26
+ name: 'Thermostat',
27
+ description: 'Shows temperature and allows control',
28
+ icon: 'thermometer',
29
+ color: '#ff6b35',
30
+ category: 'climate',
31
+ families: ['sm', 'md', 'lg'],
32
+ minSize: { w: 1, h: 1 },
33
+ maxSize: { w: 6, h: 6 },
34
+ config: [{ name: 'room', type: 'text', label: 'Room name', default: 'Living Room' }],
35
+ };
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Tests
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+
41
+ describe('defineBrick', () => {
42
+ test('returns a CompiledBrickType with spec and component', () => {
43
+ const component: BrickComponent = () => Text({ content: 'hello' });
44
+ const result = defineBrick(minimalSpec, component);
45
+
46
+ expect(result).toHaveProperty('spec');
47
+ expect(result).toHaveProperty('component');
48
+ expect(result.spec).toBe(minimalSpec);
49
+ expect(result.component).toBe(component);
50
+ });
51
+
52
+ test('preserves the exact spec reference', () => {
53
+ const component: BrickComponent = () => [];
54
+ const result = defineBrick(fullSpec, component);
55
+ expect(result.spec).toBe(fullSpec);
56
+ });
57
+
58
+ test('preserves the exact component function reference', () => {
59
+ const component: BrickComponent = () => Text({ content: 'x' });
60
+ const result = defineBrick(minimalSpec, component);
61
+ expect(result.component).toBe(component);
62
+ });
63
+
64
+ test('component can return a single ComponentNode', () => {
65
+ const component: BrickComponent = (ctx) =>
66
+ Stat({ label: 'Temp', value: ctx.config.temp as number, unit: '°C' });
67
+ const result = defineBrick(minimalSpec, component);
68
+
69
+ const ctx: BrickInstanceContext = {
70
+ instanceId: 'inst-1',
71
+ config: { temp: 21.5 },
72
+ };
73
+ const output = result.component(ctx);
74
+ expect(output).toHaveProperty('type', 'stat-value');
75
+ expect(output).toHaveProperty('value', 21.5);
76
+ });
77
+
78
+ test('component can return an array of ComponentNodes', () => {
79
+ const component: BrickComponent = () => [
80
+ Stat({ label: 'A', value: 1 }),
81
+ Stat({ label: 'B', value: 2 }),
82
+ ];
83
+ const result = defineBrick(minimalSpec, component);
84
+
85
+ const ctx: BrickInstanceContext = { instanceId: 'inst-2', config: {} };
86
+ const output = result.component(ctx);
87
+ expect(Array.isArray(output)).toBe(true);
88
+ expect((output as unknown[]).length).toBe(2);
89
+ });
90
+
91
+ test('spec with all fields is preserved', () => {
92
+ const component: BrickComponent = () => [];
93
+ const result = defineBrick(fullSpec, component);
94
+
95
+ expect(result.spec.id).toBe('thermostat');
96
+ expect(result.spec.name).toBe('Thermostat');
97
+ expect(result.spec.description).toBe('Shows temperature and allows control');
98
+ expect(result.spec.icon).toBe('thermometer');
99
+ expect(result.spec.color).toBe('#ff6b35');
100
+ expect(result.spec.category).toBe('climate');
101
+ expect(result.spec.families).toEqual(['sm', 'md', 'lg']);
102
+ expect(result.spec.minSize).toEqual({ w: 1, h: 1 });
103
+ expect(result.spec.maxSize).toEqual({ w: 6, h: 6 });
104
+ expect(result.spec.config).toHaveLength(1);
105
+ });
106
+
107
+ test('component receives instanceId and config', () => {
108
+ let receivedCtx: BrickInstanceContext | null = null;
109
+ const component: BrickComponent = (ctx) => {
110
+ receivedCtx = ctx;
111
+ return Text({ content: 'test' });
112
+ };
113
+
114
+ const result = defineBrick(minimalSpec, component);
115
+ const ctx: BrickInstanceContext = {
116
+ instanceId: 'brick-abc',
117
+ config: { key1: 'value1', key2: 42 },
118
+ };
119
+ result.component(ctx);
120
+
121
+ expect(receivedCtx).not.toBeNull();
122
+ expect(receivedCtx!.instanceId).toBe('brick-abc');
123
+ expect(receivedCtx!.config).toEqual({ key1: 'value1', key2: 42 });
124
+ });
125
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Tests for applyMutations
3
+ */
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { MUT } from '../descriptors';
7
+ import type { BoxNode, ButtonNode, ColumnNode, ComponentNode, Mutation, TextNode } from '../index';
8
+ import { applyMutations } from '../mutations';
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Helpers
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ const text = (content: string): TextNode => ({ type: 'text', content });
15
+
16
+ const column = (children: ComponentNode[]): ColumnNode => ({ type: 'column', children });
17
+
18
+ /** Extract children from a container node at a given index in the result array. */
19
+ function childrenAt(nodes: ComponentNode[], index: number): ComponentNode[] {
20
+ const node = nodes[index];
21
+ if (node && 'children' in node) return node.children;
22
+ throw new Error(`Node at index ${index} has no children`);
23
+ }
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // Tests
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ describe('applyMutations', () => {
30
+ describe('create', () => {
31
+ test('appends node when index >= length', () => {
32
+ const body = [text('A')];
33
+ const result = applyMutations(body, [[MUT.CREATE, '1', text('B')]]);
34
+
35
+ expect(result).toHaveLength(2);
36
+ expect(result[1]).toHaveProperty('content', 'B');
37
+ });
38
+
39
+ test('inserts node at index', () => {
40
+ const body = [text('A'), text('C')];
41
+ const result = applyMutations(body, [[MUT.CREATE, '1', text('B')]]);
42
+
43
+ expect(result).toHaveLength(3);
44
+ expect(result[0]).toHaveProperty('content', 'A');
45
+ expect(result[1]).toHaveProperty('content', 'B');
46
+ expect(result[2]).toHaveProperty('content', 'C');
47
+ });
48
+
49
+ test('inserts at beginning', () => {
50
+ const body = [text('B')];
51
+ const result = applyMutations(body, [[MUT.CREATE, '0', text('A')]]);
52
+
53
+ expect(result).toHaveLength(2);
54
+ expect(result[0]).toHaveProperty('content', 'A');
55
+ expect(result[1]).toHaveProperty('content', 'B');
56
+ });
57
+ });
58
+
59
+ describe('replace', () => {
60
+ test('replaces node at index in-place', () => {
61
+ const button: ButtonNode = { type: 'button', label: 'Click' };
62
+ const body = [text('A'), text('B')];
63
+ const result = applyMutations(body, [[MUT.REPLACE, '0', button]]);
64
+
65
+ expect(result).toHaveLength(2);
66
+ expect(result[0]).toBe(button);
67
+ expect(result[1]).toHaveProperty('content', 'B');
68
+ });
69
+ });
70
+
71
+ describe('update', () => {
72
+ test('merges props into existing node', () => {
73
+ const body = [text('Hello')];
74
+ const result = applyMutations(body, [[MUT.UPDATE, '0', { content: 'Updated' }]]);
75
+
76
+ expect(result[0]).toHaveProperty('content', 'Updated');
77
+ expect(result[0]).toHaveProperty('type', 'text');
78
+ });
79
+
80
+ test('adds new props without removing existing ones', () => {
81
+ const body = [text('Hello')];
82
+ const result = applyMutations(body, [[MUT.UPDATE, '0', { variant: 'heading' }]]);
83
+
84
+ expect(result[0]).toHaveProperty('content', 'Hello');
85
+ expect(result[0]).toHaveProperty('variant', 'heading');
86
+ });
87
+
88
+ test('removes props listed in removed array', () => {
89
+ const box: BoxNode = { type: 'box', blur: 'sm', padding: 'lg', children: [] };
90
+ const result = applyMutations([box], [[MUT.UPDATE, '0', {}, ['blur']]]);
91
+
92
+ expect(result[0]).not.toHaveProperty('blur');
93
+ expect(result[0]).toHaveProperty('padding', 'lg');
94
+ });
95
+
96
+ test('preserves null as a legitimate prop value', () => {
97
+ const body = [text('Hello')];
98
+ const result = applyMutations(body, [[MUT.UPDATE, '0', { color: null }]]);
99
+
100
+ expect(result[0]).toHaveProperty('color', null);
101
+ });
102
+ });
103
+
104
+ describe('remove', () => {
105
+ test('removes node at index', () => {
106
+ const body = [text('A'), text('B'), text('C')];
107
+ const result = applyMutations(body, [[MUT.REMOVE, '1']]);
108
+
109
+ expect(result).toHaveLength(2);
110
+ expect(result[0]).toHaveProperty('content', 'A');
111
+ expect(result[1]).toHaveProperty('content', 'C');
112
+ });
113
+
114
+ test('removes first node', () => {
115
+ const body = [text('A'), text('B')];
116
+ const result = applyMutations(body, [[MUT.REMOVE, '0']]);
117
+
118
+ expect(result).toHaveLength(1);
119
+ expect(result[0]).toHaveProperty('content', 'B');
120
+ });
121
+ });
122
+
123
+ describe('nested paths', () => {
124
+ test('updates a nested child', () => {
125
+ const body = [column([text('inner')])];
126
+ const result = applyMutations(body, [[MUT.UPDATE, '0.0', { content: 'updated-inner' }]]);
127
+
128
+ const children = childrenAt(result, 0);
129
+ expect(children[0]).toHaveProperty('content', 'updated-inner');
130
+ });
131
+
132
+ test('creates a nested child', () => {
133
+ const body = [column([text('first')])];
134
+ const result = applyMutations(body, [[MUT.CREATE, '0.1', text('second')]]);
135
+
136
+ const children = childrenAt(result, 0);
137
+ expect(children).toHaveLength(2);
138
+ expect(children[1]).toHaveProperty('content', 'second');
139
+ });
140
+
141
+ test('removes a nested child', () => {
142
+ const body = [column([text('A'), text('B')])];
143
+ const result = applyMutations(body, [[MUT.REMOVE, '0.0']]);
144
+
145
+ const children = childrenAt(result, 0);
146
+ expect(children).toHaveLength(1);
147
+ expect(children[0]).toHaveProperty('content', 'B');
148
+ });
149
+ });
150
+
151
+ describe('structural sharing', () => {
152
+ test('siblings keep original references after update', () => {
153
+ const a = text('A');
154
+ const b = text('B');
155
+ const body = [a, b];
156
+
157
+ const result = applyMutations(body, [[MUT.UPDATE, '0', { content: 'A2' }]]);
158
+
159
+ // Updated node is a new reference
160
+ expect(result[0]).not.toBe(a);
161
+ // Sibling keeps original reference
162
+ expect(result[1]).toBe(b);
163
+ });
164
+
165
+ test('parent is new reference but sibling subtrees are shared', () => {
166
+ const child0 = text('unchanged');
167
+ const child1 = text('will-change');
168
+ const body = [column([child0, child1])];
169
+
170
+ const result = applyMutations(body, [[MUT.UPDATE, '0.1', { content: 'changed' }]]);
171
+
172
+ // Parent container is a new reference
173
+ expect(result[0]).not.toBe(body[0]);
174
+ // Unchanged sibling keeps reference
175
+ expect(childrenAt(result, 0)[0]).toBe(child0);
176
+ });
177
+ });
178
+
179
+ describe('batch mutations', () => {
180
+ test('applies multiple mutations in sequence', () => {
181
+ const body = [text('A')];
182
+ const mutations: Mutation[] = [
183
+ [MUT.CREATE, '1', text('B')],
184
+ [MUT.CREATE, '2', text('C')],
185
+ [MUT.UPDATE, '0', { content: 'A2' }],
186
+ ];
187
+
188
+ const result = applyMutations(body, mutations);
189
+
190
+ expect(result).toHaveLength(3);
191
+ expect(result[0]).toHaveProperty('content', 'A2');
192
+ expect(result[1]).toHaveProperty('content', 'B');
193
+ expect(result[2]).toHaveProperty('content', 'C');
194
+ });
195
+ });
196
+
197
+ describe('edge cases', () => {
198
+ test('empty mutations returns same array', () => {
199
+ const body = [text('A')];
200
+ const result = applyMutations(body, []);
201
+ expect(result).toBe(body);
202
+ });
203
+
204
+ test('returns nodes unchanged when targeting non-existent nested path on leaf', () => {
205
+ const body = [text('leaf')];
206
+ // text node has no children, so path "0.0" should return nodes unchanged
207
+ const result = applyMutations(body, [[MUT.UPDATE, '0.0', { content: 'nope' }]]);
208
+ expect(result).toBe(body);
209
+ });
210
+ });
211
+ });