@dxos/diagram 0.11.1
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/LICENSE +105 -0
- package/README.md +10 -0
- package/dist/lib/index.mjs +3975 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +68 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/content.d.ts +60 -0
- package/dist/types/src/content.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +56 -0
- package/dist/types/src/diagnostics.d.ts.map +1 -0
- package/dist/types/src/diagnostics.test.d.ts +2 -0
- package/dist/types/src/diagnostics.test.d.ts.map +1 -0
- package/dist/types/src/dialect.d.ts +19 -0
- package/dist/types/src/dialect.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +16 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/layout.d.ts +26 -0
- package/dist/types/src/layout.d.ts.map +1 -0
- package/dist/types/src/mermaid-engine.d.ts +83 -0
- package/dist/types/src/mermaid-engine.d.ts.map +1 -0
- package/dist/types/src/mermaid-engine.test.d.ts +2 -0
- package/dist/types/src/mermaid-engine.test.d.ts.map +1 -0
- package/dist/types/src/mermaid.d.ts +56 -0
- package/dist/types/src/mermaid.d.ts.map +1 -0
- package/dist/types/src/mermaid.test.d.ts +3 -0
- package/dist/types/src/mermaid.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +75 -0
- package/dist/types/src/objective.d.ts.map +1 -0
- package/dist/types/src/objective.test.d.ts +2 -0
- package/dist/types/src/objective.test.d.ts.map +1 -0
- package/dist/types/src/ortho-router.d.ts +13 -0
- package/dist/types/src/ortho-router.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +1006 -0
- package/dist/types/src/scene.d.ts.map +1 -0
- package/dist/types/src/scene.test.d.ts +2 -0
- package/dist/types/src/scene.test.d.ts.map +1 -0
- package/dist/types/src/svg-handler.d.ts +5 -0
- package/dist/types/src/svg-handler.d.ts.map +1 -0
- package/dist/types/src/svg-handler.test.d.ts +2 -0
- package/dist/types/src/svg-handler.test.d.ts.map +1 -0
- package/dist/types/src/testing.d.ts +11 -0
- package/dist/types/src/testing.d.ts.map +1 -0
- package/dist/types/src/ui.d.ts +67 -0
- package/dist/types/src/ui.d.ts.map +1 -0
- package/dist/types/src/ui.test.d.ts +2 -0
- package/dist/types/src/ui.test.d.ts.map +1 -0
- package/dist/types/src/uml-engine.d.ts +14 -0
- package/dist/types/src/uml-engine.d.ts.map +1 -0
- package/dist/types/src/uml-engine.test.d.ts +2 -0
- package/dist/types/src/uml-engine.test.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts +99 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -0
- package/dist/types/src/uml-grid.test.d.ts +2 -0
- package/dist/types/src/uml-grid.test.d.ts.map +1 -0
- package/dist/types/src/uml-rules.d.ts +56 -0
- package/dist/types/src/uml-rules.d.ts.map +1 -0
- package/dist/types/src/uml-rules.test.d.ts +2 -0
- package/dist/types/src/uml-rules.test.d.ts.map +1 -0
- package/dist/types/src/uml-search.d.ts +26 -0
- package/dist/types/src/uml-search.d.ts.map +1 -0
- package/dist/types/src/uml-search.test.d.ts +2 -0
- package/dist/types/src/uml-search.test.d.ts.map +1 -0
- package/dist/types/src/uml.d.ts +60 -0
- package/dist/types/src/uml.d.ts.map +1 -0
- package/dist/types/src/uml.test.d.ts +2 -0
- package/dist/types/src/uml.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/src/__snapshots__/diagnostics.test.ts.snap +36 -0
- package/src/__snapshots__/mermaid-engine.test.ts.snap +22 -0
- package/src/content.ts +180 -0
- package/src/diagnostics.test.ts +173 -0
- package/src/diagnostics.ts +393 -0
- package/src/dialect.ts +53 -0
- package/src/index.ts +19 -0
- package/src/layout.ts +99 -0
- package/src/mermaid-engine.test.ts +244 -0
- package/src/mermaid-engine.ts +863 -0
- package/src/mermaid.test.ts +138 -0
- package/src/mermaid.ts +326 -0
- package/src/objective.test.ts +82 -0
- package/src/objective.ts +157 -0
- package/src/ortho-router.ts +372 -0
- package/src/scene.test.ts +36 -0
- package/src/scene.ts +281 -0
- package/src/svg-handler.test.ts +75 -0
- package/src/svg-handler.ts +119 -0
- package/src/testing.ts +68 -0
- package/src/ui.test.ts +183 -0
- package/src/ui.ts +398 -0
- package/src/uml-engine.test.ts +63 -0
- package/src/uml-engine.ts +146 -0
- package/src/uml-grid.test.ts +175 -0
- package/src/uml-grid.ts +559 -0
- package/src/uml-rules.test.ts +109 -0
- package/src/uml-rules.ts +319 -0
- package/src/uml-search.test.ts +108 -0
- package/src/uml-search.ts +266 -0
- package/src/uml.test.ts +172 -0
- package/src/uml.ts +390 -0
package/src/ui.test.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
import { describe, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { invariant } from '@dxos/invariant';
|
|
9
|
+
|
|
10
|
+
import * as Ui from './ui.ts';
|
|
11
|
+
|
|
12
|
+
const Address = Schema.Struct({
|
|
13
|
+
street: Schema.String,
|
|
14
|
+
city: Schema.String,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const Task = Schema.Struct({
|
|
18
|
+
title: Schema.String,
|
|
19
|
+
done: Schema.Boolean,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const Contact = Schema.Struct({
|
|
23
|
+
name: Schema.String,
|
|
24
|
+
active: Schema.Boolean,
|
|
25
|
+
role: Schema.Literals(['admin', 'member', 'guest']),
|
|
26
|
+
address: Address,
|
|
27
|
+
emails: Schema.Array(Schema.String),
|
|
28
|
+
tasks: Schema.Array(Task),
|
|
29
|
+
}).annotate({ title: 'Contact' });
|
|
30
|
+
|
|
31
|
+
describe('Ui.fromSchema', () => {
|
|
32
|
+
test('classifies controls and recurses into objects and arrays', ({ expect }) => {
|
|
33
|
+
const form = Ui.fromSchema(Contact);
|
|
34
|
+
// The schema title is container chrome, never part of the form itself.
|
|
35
|
+
expect(Ui.schemaTitle(Contact)).toBe('Contact');
|
|
36
|
+
expect(form.children.map((child) => child.kind)).toEqual([
|
|
37
|
+
'control', // name
|
|
38
|
+
'control', // active
|
|
39
|
+
'control', // role
|
|
40
|
+
'group', // address
|
|
41
|
+
'array', // emails
|
|
42
|
+
'array', // tasks
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const [name, active, role, address, emails, tasks] = form.children;
|
|
46
|
+
expect(name).toMatchObject({ control: 'input', path: 'name' });
|
|
47
|
+
expect(active).toMatchObject({ control: 'checkbox', path: 'active' });
|
|
48
|
+
expect(role).toMatchObject({ control: 'select', path: 'role' });
|
|
49
|
+
|
|
50
|
+
expect(address.kind).toBe('group');
|
|
51
|
+
if (address.kind === 'group') {
|
|
52
|
+
expect(address.children.map((child) => child.kind === 'control' && child.path)).toEqual([
|
|
53
|
+
'address.street',
|
|
54
|
+
'address.city',
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
expect(emails.kind).toBe('array');
|
|
59
|
+
if (emails.kind === 'array') {
|
|
60
|
+
expect(emails.item).toMatchObject({ kind: 'control', control: 'input', path: 'emails[]' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(tasks.kind).toBe('array');
|
|
64
|
+
if (tasks.kind === 'array') {
|
|
65
|
+
expect(tasks.item.kind).toBe('group');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('depth cap degrades nested structure to a control', ({ expect }) => {
|
|
70
|
+
const form = Ui.fromSchema(Contact, { maxDepth: 1 });
|
|
71
|
+
const address = form.children.find((child) => child.path === 'address');
|
|
72
|
+
// Depth 1 spends the budget on the property itself, so its nested fields collapse.
|
|
73
|
+
expect(address?.kind).toBe('group');
|
|
74
|
+
if (address?.kind === 'group') {
|
|
75
|
+
for (const child of address.children) {
|
|
76
|
+
expect(child.kind).toBe('control');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('Ui.renderAscii', () => {
|
|
83
|
+
test('renders the low-fidelity form', ({ expect }) => {
|
|
84
|
+
const ascii = Ui.renderAscii(Ui.fromSchema(Contact));
|
|
85
|
+
expect(ascii).toBe(
|
|
86
|
+
[
|
|
87
|
+
'name',
|
|
88
|
+
'[__________]',
|
|
89
|
+
'',
|
|
90
|
+
'active',
|
|
91
|
+
'[ ]',
|
|
92
|
+
'',
|
|
93
|
+
'role',
|
|
94
|
+
'[_________*]',
|
|
95
|
+
'',
|
|
96
|
+
'address',
|
|
97
|
+
' street',
|
|
98
|
+
' [__________]',
|
|
99
|
+
'',
|
|
100
|
+
' city',
|
|
101
|
+
' [__________]',
|
|
102
|
+
'',
|
|
103
|
+
'emails [+]',
|
|
104
|
+
' [__________]',
|
|
105
|
+
'',
|
|
106
|
+
'tasks [+]',
|
|
107
|
+
' title',
|
|
108
|
+
' [__________]',
|
|
109
|
+
'',
|
|
110
|
+
' done',
|
|
111
|
+
' [ ]',
|
|
112
|
+
].join('\n'),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('containers draw boxes', ({ expect }) => {
|
|
117
|
+
const deck = Ui.deckOf(Ui.fromSchema(Address), 'Address');
|
|
118
|
+
const ascii = Ui.renderAscii(deck);
|
|
119
|
+
const lines = ascii.split('\n');
|
|
120
|
+
expect(lines[0]).toMatch(/^\+- Address -+\+$/);
|
|
121
|
+
expect(lines.at(-1)).toMatch(/^\+-+\+$/);
|
|
122
|
+
expect(ascii).toContain('[__________]');
|
|
123
|
+
// Every line is the same width, so nested boxes stay aligned.
|
|
124
|
+
expect(new Set(lines.map((line) => line.length)).size).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('Ui.compile', () => {
|
|
129
|
+
test('emits one addressable object per control plus container frames', ({ expect }) => {
|
|
130
|
+
const commands = Ui.compile(Ui.deckOf(Ui.fromSchema(Contact)));
|
|
131
|
+
const ids = commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object.id] : []));
|
|
132
|
+
|
|
133
|
+
const plank = 'deck.plank0';
|
|
134
|
+
expect(ids).toEqual(
|
|
135
|
+
expect.arrayContaining([
|
|
136
|
+
'deck',
|
|
137
|
+
plank,
|
|
138
|
+
`${plank}.panel`,
|
|
139
|
+
`${plank}.panel.name`,
|
|
140
|
+
`${plank}.panel.active`,
|
|
141
|
+
`${plank}.panel.role`,
|
|
142
|
+
`${plank}.panel.address.street`,
|
|
143
|
+
]),
|
|
144
|
+
);
|
|
145
|
+
expect(ids).toEqual(expect.arrayContaining([`${plank}.panel.emails.add`, `${plank}.panel.tasks.add`]));
|
|
146
|
+
|
|
147
|
+
for (const command of commands) {
|
|
148
|
+
if (command.op === 'upsert-object') {
|
|
149
|
+
const { origin } = command.object;
|
|
150
|
+
invariant(origin, 'upsert-object command missing origin');
|
|
151
|
+
expect(Number.isFinite(origin.x)).toBe(true);
|
|
152
|
+
expect(Number.isFinite(origin.y)).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('two planks over the same schema emit distinct ids', ({ expect }) => {
|
|
158
|
+
const form = Ui.fromSchema(Address);
|
|
159
|
+
const deck: Ui.Deck = {
|
|
160
|
+
kind: 'deck',
|
|
161
|
+
planks: [
|
|
162
|
+
{ kind: 'plank', child: { kind: 'panel', child: form } },
|
|
163
|
+
{ kind: 'plank', child: { kind: 'panel', child: form } },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
const ids = Ui.compile(deck).flatMap((command) => (command.op === 'upsert-object' ? [command.object.id] : []));
|
|
167
|
+
|
|
168
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('scale and origin place the drawing on the canvas', ({ expect }) => {
|
|
172
|
+
const commands = Ui.compile(Ui.fromSchema(Address), { origin: { x: 100, y: 50 }, scale: 2 });
|
|
173
|
+
for (const command of commands) {
|
|
174
|
+
if (command.op === 'upsert-object') {
|
|
175
|
+
expect(command.object.scale).toBe(2);
|
|
176
|
+
const { origin } = command.object;
|
|
177
|
+
invariant(origin, 'upsert-object command missing origin');
|
|
178
|
+
expect(origin.x).toBeGreaterThanOrEqual(100);
|
|
179
|
+
expect(origin.y).toBeGreaterThanOrEqual(50);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// MOSAIC (Model-Oriented System for Adaptive Interface Composition) UI-schematic dialect:
|
|
7
|
+
// renders low-fidelity UI wireframes from the declarative app model. Phase 1 scope: a single
|
|
8
|
+
// schema compiles to a form drawing — the recursive structure (nested objects, arrays) with each
|
|
9
|
+
// control (input, switch, checkbox, select) as a rectangle and its label as text above — wrapped
|
|
10
|
+
// in the app-ontology containers (deck > plank > panel > form). Two renderers share the model:
|
|
11
|
+
// ASCII (tests, chat) and scene commands (tldraw, via the standard dialect contract). Unrelated
|
|
12
|
+
// to `react-ui-mosaic`. See `dialect.ts` for the extension mechanism this follows (mermaid is
|
|
13
|
+
// the sibling precedent).
|
|
14
|
+
//
|
|
15
|
+
|
|
16
|
+
import type * as Schema from 'effect/Schema';
|
|
17
|
+
import * as SchemaAST from 'effect/SchemaAST';
|
|
18
|
+
|
|
19
|
+
import type * as Scene from './scene.ts';
|
|
20
|
+
|
|
21
|
+
//
|
|
22
|
+
// Drawing model. Plain data (one discriminated tree) — the intermediate model between the app
|
|
23
|
+
// model (schema today; app-graph later) and the renderers.
|
|
24
|
+
//
|
|
25
|
+
|
|
26
|
+
export type ControlKind = 'input' | 'switch' | 'checkbox' | 'select';
|
|
27
|
+
|
|
28
|
+
export type Control = {
|
|
29
|
+
kind: 'control';
|
|
30
|
+
control: ControlKind;
|
|
31
|
+
label: string;
|
|
32
|
+
/** Dotted schema path; doubles as the element id so drawn controls stay addressable. */
|
|
33
|
+
path: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Nested object: a labeled group of fields. */
|
|
37
|
+
export type Group = {
|
|
38
|
+
kind: 'group';
|
|
39
|
+
label: string;
|
|
40
|
+
path: string;
|
|
41
|
+
children: FormChild[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Array: a labeled repetition of one item template. */
|
|
45
|
+
export type ArrayGroup = {
|
|
46
|
+
kind: 'array';
|
|
47
|
+
label: string;
|
|
48
|
+
path: string;
|
|
49
|
+
/** The element template, rendered once with a trailing "+" affordance. */
|
|
50
|
+
item: FormChild;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type FormChild = Control | Group | ArrayGroup;
|
|
54
|
+
|
|
55
|
+
/** A form has no title of its own — the schema title belongs to the enclosing container. */
|
|
56
|
+
export type Form = { kind: 'form'; children: FormChild[] };
|
|
57
|
+
export type Panel = { kind: 'panel'; label?: string; child: Form };
|
|
58
|
+
export type Plank = { kind: 'plank'; label?: string; child: Panel };
|
|
59
|
+
export type Deck = { kind: 'deck'; planks: Plank[] };
|
|
60
|
+
|
|
61
|
+
export type UiNode = Deck | Plank | Panel | Form | FormChild;
|
|
62
|
+
|
|
63
|
+
//
|
|
64
|
+
// Schema → drawing. Minimal v4 AST walking, self-contained (see @dxos/effect SchemaEx for the
|
|
65
|
+
// full-featured helpers): properties live on `Objects` nodes, `Schema.optional` is a
|
|
66
|
+
// `T | undefined` union, arrays are `Arrays` with a single rest element.
|
|
67
|
+
//
|
|
68
|
+
|
|
69
|
+
export type FromSchemaOptions = {
|
|
70
|
+
/** Recursion guard for self-referential schemas. */
|
|
71
|
+
maxDepth?: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const unwrapSuspend = (ast: SchemaAST.AST): SchemaAST.AST =>
|
|
75
|
+
SchemaAST.isSuspend(ast) ? unwrapSuspend(ast.thunk()) : ast;
|
|
76
|
+
|
|
77
|
+
const unwrapOptional = (ast: SchemaAST.AST): SchemaAST.AST => {
|
|
78
|
+
let node = ast;
|
|
79
|
+
while (SchemaAST.isUnion(node) && node.types.length === 2 && SchemaAST.isUndefined(node.types[1])) {
|
|
80
|
+
node = node.types[0];
|
|
81
|
+
}
|
|
82
|
+
return node;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const propertiesOf = (ast: SchemaAST.AST): ReadonlyArray<SchemaAST.PropertySignature> => {
|
|
86
|
+
const node = unwrapSuspend(ast);
|
|
87
|
+
return node._tag === 'Objects' ? node.propertySignatures : [];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isLiteralUnion = (node: SchemaAST.AST): boolean =>
|
|
91
|
+
SchemaAST.isUnion(node) && node.types.length > 0 && node.types.every(SchemaAST.isLiteral);
|
|
92
|
+
|
|
93
|
+
const arrayElementOf = (node: SchemaAST.AST): SchemaAST.AST | undefined =>
|
|
94
|
+
SchemaAST.isArrays(node) && node.elements.length === 0 && node.rest.length === 1 ? node.rest[0] : undefined;
|
|
95
|
+
|
|
96
|
+
const titleOf = (ast: SchemaAST.AST): string | undefined => {
|
|
97
|
+
const title = (ast.annotations?.title ?? SchemaAST.resolve(ast)?.title) as string | undefined;
|
|
98
|
+
return typeof title === 'string' ? title : undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const label = (name: PropertyKey, type: SchemaAST.AST): string => titleOf(type) ?? String(name);
|
|
102
|
+
|
|
103
|
+
const controlOf = (type: SchemaAST.AST, name: PropertyKey, path: string): Control => {
|
|
104
|
+
const control: ControlKind = isLiteralUnion(type) ? 'select' : type._tag === 'Boolean' ? 'checkbox' : 'input';
|
|
105
|
+
return { kind: 'control', control, label: label(name, type), path };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const childOf = (type: SchemaAST.AST, name: PropertyKey, path: string, depth: number): FormChild => {
|
|
109
|
+
const base = unwrapOptional(unwrapSuspend(type));
|
|
110
|
+
|
|
111
|
+
const element = arrayElementOf(base);
|
|
112
|
+
if (element && depth > 0) {
|
|
113
|
+
// The array frame already names the collection; an item template carries no label of its own.
|
|
114
|
+
const item = { ...childOf(element, '', `${path}[]`, depth - 1), label: '' };
|
|
115
|
+
return { kind: 'array', label: label(name, base), path, item };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Nested struct: recurse. Declarations (refs, opaque types) fall through to a plain control —
|
|
119
|
+
// walking into them risks unbounded recursion and they render as pickers anyway.
|
|
120
|
+
if (depth > 0 && !isLiteralUnion(base)) {
|
|
121
|
+
const properties = propertiesOf(base);
|
|
122
|
+
if (properties.length > 0) {
|
|
123
|
+
return {
|
|
124
|
+
kind: 'group',
|
|
125
|
+
label: label(name, base),
|
|
126
|
+
path,
|
|
127
|
+
children: properties.map((property) =>
|
|
128
|
+
childOf(property.type, property.name, `${path}.${String(property.name)}`, depth - 1),
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return controlOf(base, name, path);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Convert a schema into a form drawing: the phase-1 slice of the app model. */
|
|
138
|
+
export const fromSchema = (schema: Schema.Top, options: FromSchemaOptions = {}): Form => {
|
|
139
|
+
const { maxDepth = 4 } = options;
|
|
140
|
+
const children = propertiesOf(schema.ast)
|
|
141
|
+
.filter((property) => property.name !== 'id')
|
|
142
|
+
.map((property) => childOf(property.type, property.name, String(property.name), maxDepth));
|
|
143
|
+
return { kind: 'form', children };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** The schema's title annotation — container chrome, not form content. */
|
|
147
|
+
export const schemaTitle = (schema: Schema.Top): string | undefined => titleOf(schema.ast);
|
|
148
|
+
|
|
149
|
+
/** Wrap a form in the app-ontology containers for a full schematic. */
|
|
150
|
+
export const deckOf = (form: Form, title?: string): Deck => ({
|
|
151
|
+
kind: 'deck',
|
|
152
|
+
planks: [{ kind: 'plank', label: title, child: { kind: 'panel', child: form } }],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
//
|
|
156
|
+
// Scene layout. Vertical flow; containers pad and frame their content. Units are scene units
|
|
157
|
+
// (the caller scales), mirroring the mermaid dialect.
|
|
158
|
+
//
|
|
159
|
+
|
|
160
|
+
const CONTROL_W = 200;
|
|
161
|
+
const CONTROL_H = 28;
|
|
162
|
+
const TOGGLE_W = 28;
|
|
163
|
+
const LABEL_H = 18;
|
|
164
|
+
const GAP = 12;
|
|
165
|
+
const PAD = 16;
|
|
166
|
+
const TITLE_H = 22;
|
|
167
|
+
const PLUS_H = 20;
|
|
168
|
+
|
|
169
|
+
type Emit = { commands: Scene.Command[]; elements: Scene.Element[] };
|
|
170
|
+
|
|
171
|
+
const controlHeight = (node: Control) => (node.label ? LABEL_H : 0) + CONTROL_H;
|
|
172
|
+
|
|
173
|
+
const heightOf = (node: UiNode): number => {
|
|
174
|
+
switch (node.kind) {
|
|
175
|
+
case 'control':
|
|
176
|
+
return controlHeight(node);
|
|
177
|
+
case 'group':
|
|
178
|
+
// An anonymous group (an array's item template) flows its children without chrome.
|
|
179
|
+
if (!node.label) {
|
|
180
|
+
return node.children.reduce((sum, child, index) => sum + heightOf(child) + (index > 0 ? GAP : 0), 0);
|
|
181
|
+
}
|
|
182
|
+
return TITLE_H + node.children.reduce((sum, child) => sum + heightOf(child) + GAP, 0) + PAD;
|
|
183
|
+
case 'array':
|
|
184
|
+
return TITLE_H + heightOf(node.item) + PAD;
|
|
185
|
+
case 'form':
|
|
186
|
+
return node.children.reduce((sum, child, index) => sum + heightOf(child) + (index > 0 ? GAP : 0), 0);
|
|
187
|
+
case 'panel':
|
|
188
|
+
return heightOf(node.child) + PAD * 2 + TITLE_H;
|
|
189
|
+
case 'plank':
|
|
190
|
+
return heightOf(node.child) + PAD * 2 + (node.label ? TITLE_H : 0);
|
|
191
|
+
case 'deck':
|
|
192
|
+
return Math.max(...node.planks.map(heightOf), 0) + PAD * 2;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const widthOf = (node: UiNode): number => {
|
|
197
|
+
switch (node.kind) {
|
|
198
|
+
case 'control':
|
|
199
|
+
return CONTROL_W;
|
|
200
|
+
case 'group':
|
|
201
|
+
return Math.max(...node.children.map(widthOf), CONTROL_W) + (node.label ? PAD : 0);
|
|
202
|
+
case 'array':
|
|
203
|
+
return Math.max(widthOf(node.item), CONTROL_W) + PAD;
|
|
204
|
+
case 'form':
|
|
205
|
+
return Math.max(...node.children.map(widthOf), CONTROL_W);
|
|
206
|
+
case 'panel':
|
|
207
|
+
return widthOf(node.child) + PAD * 2;
|
|
208
|
+
case 'plank':
|
|
209
|
+
return widthOf(node.child) + PAD * 2;
|
|
210
|
+
case 'deck':
|
|
211
|
+
return node.planks.reduce((sum, plank, index) => sum + widthOf(plank) + (index > 0 ? GAP : 0), 0) + PAD * 2;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Qualify a node id with its container, so two planks over the same schema stay addressable. */
|
|
216
|
+
const qualify = (prefix: string, path: string): string => (prefix ? `${prefix}.${path}` : path);
|
|
217
|
+
|
|
218
|
+
/** One world object per control (id = container path), so drawn controls stay individually addressable. */
|
|
219
|
+
const emitControl = (node: Control, x: number, y: number, emit: Emit, prefix: string): void => {
|
|
220
|
+
const top = node.label ? LABEL_H : 0;
|
|
221
|
+
const elements: Scene.Element[] = node.label
|
|
222
|
+
? [{ kind: 'text', id: 'label', x: 0, y: 0, text: node.label, weight: 's', color: 'grey' }]
|
|
223
|
+
: [];
|
|
224
|
+
switch (node.control) {
|
|
225
|
+
case 'checkbox':
|
|
226
|
+
case 'switch':
|
|
227
|
+
elements.push({ kind: 'rect', id: 'box', x: 0, y: top, w: TOGGLE_W, h: CONTROL_H, stroke: 'solid' });
|
|
228
|
+
break;
|
|
229
|
+
case 'select':
|
|
230
|
+
elements.push(
|
|
231
|
+
{ kind: 'rect', id: 'box', x: 0, y: top, w: CONTROL_W, h: CONTROL_H, stroke: 'solid' },
|
|
232
|
+
{ kind: 'text', id: 'caret', x: CONTROL_W - 20, y: top + 4, text: '*', weight: 's', color: 'grey' },
|
|
233
|
+
);
|
|
234
|
+
break;
|
|
235
|
+
case 'input':
|
|
236
|
+
elements.push({ kind: 'rect', id: 'box', x: 0, y: top, w: CONTROL_W, h: CONTROL_H, stroke: 'solid' });
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
emit.commands.push({ op: 'upsert-object', object: { id: qualify(prefix, node.path), origin: { x, y }, elements } });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const frame = (id: string, x: number, y: number, w: number, h: number, title: string | undefined, emit: Emit): void => {
|
|
243
|
+
const elements: Scene.Element[] = [{ kind: 'rect', id: 'frame', x: 0, y: 0, w, h, stroke: 'dashed', color: 'grey' }];
|
|
244
|
+
if (title) {
|
|
245
|
+
elements.push({ kind: 'text', id: 'title', x: PAD / 2, y: 2, text: title, weight: 's' });
|
|
246
|
+
}
|
|
247
|
+
emit.commands.push({ op: 'upsert-object', object: { id, origin: { x, y }, elements } });
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const emitNode = (node: UiNode, x: number, y: number, emit: Emit, prefix = ''): void => {
|
|
251
|
+
switch (node.kind) {
|
|
252
|
+
case 'control':
|
|
253
|
+
emitControl(node, x, y, emit, prefix);
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
case 'group': {
|
|
257
|
+
const anonymous = !node.label;
|
|
258
|
+
if (!anonymous) {
|
|
259
|
+
frame(qualify(prefix, node.path), x, y, widthOf(node), heightOf(node), node.label, emit);
|
|
260
|
+
}
|
|
261
|
+
let cursor = y + (anonymous ? 0 : TITLE_H);
|
|
262
|
+
for (const child of node.children) {
|
|
263
|
+
emitNode(child, x + (anonymous ? 0 : PAD), cursor, emit, prefix);
|
|
264
|
+
cursor += heightOf(child) + GAP;
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
case 'array': {
|
|
270
|
+
const width = widthOf(node);
|
|
271
|
+
const id = qualify(prefix, node.path);
|
|
272
|
+
frame(id, x, y, width, heightOf(node), node.label, emit);
|
|
273
|
+
// The add button sits on the header line, where the array marker reads in ASCII.
|
|
274
|
+
emit.commands.push({
|
|
275
|
+
op: 'upsert-object',
|
|
276
|
+
object: {
|
|
277
|
+
id: `${id}.add`,
|
|
278
|
+
origin: { x: x + width - TOGGLE_W - 4, y: y + 2 },
|
|
279
|
+
elements: [{ kind: 'rect', id: 'box', x: 0, y: 0, w: TOGGLE_W, h: PLUS_H, text: '+', stroke: 'solid' }],
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
emitNode(node.item, x + PAD, y + TITLE_H, emit, prefix);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'form': {
|
|
287
|
+
let cursor = y;
|
|
288
|
+
for (const child of node.children) {
|
|
289
|
+
emitNode(child, x, cursor, emit, prefix);
|
|
290
|
+
cursor += heightOf(child) + GAP;
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'panel': {
|
|
296
|
+
const id = qualify(prefix, 'panel');
|
|
297
|
+
frame(id, x, y, widthOf(node), heightOf(node), node.label ?? 'panel', emit);
|
|
298
|
+
emitNode(node.child, x + PAD, y + TITLE_H + PAD, emit, id);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case 'plank': {
|
|
303
|
+
const id = prefix || 'plank';
|
|
304
|
+
frame(id, x, y, widthOf(node), heightOf(node), node.label, emit);
|
|
305
|
+
emitNode(node.child, x + PAD, y + PAD + (node.label ? TITLE_H : 0), emit, id);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case 'deck': {
|
|
310
|
+
const id = qualify(prefix, 'deck');
|
|
311
|
+
frame(id, x, y, widthOf(node), heightOf(node), undefined, emit);
|
|
312
|
+
let cursor = x + PAD;
|
|
313
|
+
// Index, not label: an unlabeled plank must still get a distinct id.
|
|
314
|
+
node.planks.forEach((plank, index) => {
|
|
315
|
+
emitNode(plank, cursor, y + PAD, emit, `${id}.plank${index}`);
|
|
316
|
+
cursor += widthOf(plank) + GAP;
|
|
317
|
+
});
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export type CompileOptions = {
|
|
324
|
+
/** Canvas position of the schematic's top-left, in canvas px. */
|
|
325
|
+
origin?: Scene.Point;
|
|
326
|
+
/** Canvas px per scene unit. */
|
|
327
|
+
scale?: number;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/** Compile a UI drawing into scene commands (frames first, controls above them). */
|
|
331
|
+
export const compile = (node: UiNode, options: CompileOptions = {}): Scene.Command[] => {
|
|
332
|
+
const { origin = { x: 0, y: 0 }, scale = 1 } = options;
|
|
333
|
+
const emit: Emit = { commands: [], elements: [] };
|
|
334
|
+
emitNode(node, 0, 0, emit);
|
|
335
|
+
return emit.commands.map((command) =>
|
|
336
|
+
command.op === 'upsert-object'
|
|
337
|
+
? {
|
|
338
|
+
...command,
|
|
339
|
+
object: {
|
|
340
|
+
...command.object,
|
|
341
|
+
scale,
|
|
342
|
+
origin: {
|
|
343
|
+
x: origin.x + (command.object.origin?.x ?? 0) * scale,
|
|
344
|
+
y: origin.y + (command.object.origin?.y ?? 0) * scale,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
: command,
|
|
349
|
+
);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
//
|
|
353
|
+
// ASCII renderer — the interim/low-cost target over the same model.
|
|
354
|
+
//
|
|
355
|
+
|
|
356
|
+
const GLYPHS: Record<ControlKind, string> = {
|
|
357
|
+
input: '[__________]',
|
|
358
|
+
select: '[_________*]',
|
|
359
|
+
checkbox: '[ ]',
|
|
360
|
+
switch: '(o )',
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const indent = (lines: string[], prefix: string): string[] => lines.map((line) => (prefix + line).trimEnd());
|
|
364
|
+
|
|
365
|
+
/** Wrap lines in a +--- box, optionally titled. */
|
|
366
|
+
const boxed = (lines: string[], title?: string): string[] => {
|
|
367
|
+
const width = Math.max(...lines.map((line) => line.length), (title?.length ?? 0) + 4, 12);
|
|
368
|
+
const top = title
|
|
369
|
+
? `+- ${title} ${'-'.repeat(Math.max(width - title.length - 1, 1))}+`
|
|
370
|
+
: `+${'-'.repeat(width + 2)}+`;
|
|
371
|
+
return [top, ...lines.map((line) => `| ${line.padEnd(width)} |`), `+${'-'.repeat(width + 2)}+`];
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const asciiLines = (node: UiNode): string[] => {
|
|
375
|
+
switch (node.kind) {
|
|
376
|
+
case 'control':
|
|
377
|
+
return node.label ? [node.label, GLYPHS[node.control]] : [GLYPHS[node.control]];
|
|
378
|
+
case 'group': {
|
|
379
|
+
const children = node.children.flatMap((child, index) =>
|
|
380
|
+
index > 0 ? ['', ...asciiLines(child)] : asciiLines(child),
|
|
381
|
+
);
|
|
382
|
+
return node.label ? [node.label, ...indent(children, ' ')] : children;
|
|
383
|
+
}
|
|
384
|
+
case 'array':
|
|
385
|
+
return [`${node.label} [+]`, ...indent(asciiLines(node.item), ' ')];
|
|
386
|
+
case 'form':
|
|
387
|
+
return node.children.flatMap((child, index) => (index > 0 ? ['', ...asciiLines(child)] : asciiLines(child)));
|
|
388
|
+
case 'panel':
|
|
389
|
+
return boxed(asciiLines(node.child), node.label);
|
|
390
|
+
case 'plank':
|
|
391
|
+
return boxed(asciiLines(node.child), node.label);
|
|
392
|
+
case 'deck':
|
|
393
|
+
return node.planks.flatMap((plank, index) => (index > 0 ? ['', ...asciiLines(plank)] : asciiLines(plank)));
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/** Render a UI drawing as ASCII art. */
|
|
398
|
+
export const renderAscii = (node: UiNode): string => asciiLines(node).join('\n');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import type * as Scene from './scene.ts';
|
|
8
|
+
import { CLASS_DIAGRAM } from './testing.ts';
|
|
9
|
+
import { type Engine, compile } from './uml-engine.ts';
|
|
10
|
+
import { GRID } from './uml-grid.ts';
|
|
11
|
+
|
|
12
|
+
const objectsOf = (commands: Scene.Command[]) =>
|
|
13
|
+
commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
|
|
14
|
+
|
|
15
|
+
const engines: Engine[] = ['dagre', 'elk'];
|
|
16
|
+
|
|
17
|
+
describe.each(engines)('uml-engine (%s)', (engine) => {
|
|
18
|
+
test('places every class on the grid with no overlaps', async ({ expect }) => {
|
|
19
|
+
const objects = objectsOf(await compile(CLASS_DIAGRAM, { engine }));
|
|
20
|
+
const nodes = objects.filter((object) => object.id !== 'edges');
|
|
21
|
+
|
|
22
|
+
expect(nodes.length).toBe(6);
|
|
23
|
+
for (const node of nodes) {
|
|
24
|
+
expect(node.origin!.x % GRID).toBe(0);
|
|
25
|
+
expect(node.origin!.y % GRID).toBe(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Equal cells: no two node frames intersect (distinct origins alone would allow overlap).
|
|
29
|
+
const frames = nodes.map((node) => {
|
|
30
|
+
const frame = node.elements[0] as Scene.Box;
|
|
31
|
+
return { x: node.origin!.x, y: node.origin!.y, w: frame.w, h: frame.h };
|
|
32
|
+
});
|
|
33
|
+
for (let a = 0; a < frames.length; a++) {
|
|
34
|
+
for (let b = a + 1; b < frames.length; b++) {
|
|
35
|
+
const overlap =
|
|
36
|
+
frames[a].x < frames[b].x + frames[b].w &&
|
|
37
|
+
frames[b].x < frames[a].x + frames[a].w &&
|
|
38
|
+
frames[a].y < frames[b].y + frames[b].h &&
|
|
39
|
+
frames[b].y < frames[a].y + frames[a].h;
|
|
40
|
+
expect(overlap).toBe(false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('ranks supertypes and dependency targets above their sources', async ({ expect }) => {
|
|
46
|
+
const objects = objectsOf(await compile(CLASS_DIAGRAM, { engine }));
|
|
47
|
+
const y = (id: string) => objects.find((object) => object.id === id)!.origin!.y;
|
|
48
|
+
|
|
49
|
+
expect(y('Animal')).toBeLessThan(y('Dog'));
|
|
50
|
+
expect(y('Serializable')).toBeLessThan(y('Dog'));
|
|
51
|
+
expect(y('Bone')).toBeLessThan(y('Dog'));
|
|
52
|
+
// Containment flows down: the part sits below the whole.
|
|
53
|
+
expect(y('Dog')).toBeLessThan(y('Leg'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('emits the same node structure as the grid dialect', async ({ expect }) => {
|
|
57
|
+
const objects = objectsOf(await compile(CLASS_DIAGRAM, { engine }));
|
|
58
|
+
const dog = objects.find((object) => object.id === 'Dog')!;
|
|
59
|
+
|
|
60
|
+
expect(dog.elements.map((element) => element.id)).toEqual(['frame', 'title', 'body']);
|
|
61
|
+
expect(objects.find((object) => object.id === 'edges')).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
});
|