@1agh/maude 0.25.0 → 0.27.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/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/sync/agent.ts +23 -8
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/sync-agent.test.ts +28 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
CURRENT_COLOR,
|
|
4
|
+
colorDistribution,
|
|
5
|
+
contrastRatio,
|
|
6
|
+
isPerceptuallyEven,
|
|
7
|
+
meetsWcag,
|
|
8
|
+
oklchRamp,
|
|
9
|
+
oklchToRgb,
|
|
10
|
+
parseColor,
|
|
11
|
+
parseOklch,
|
|
12
|
+
relativeLuminance,
|
|
13
|
+
toHex,
|
|
14
|
+
} from '../palette.ts';
|
|
15
|
+
|
|
16
|
+
describe('parsing', () => {
|
|
17
|
+
test('hex (short + long)', () => {
|
|
18
|
+
expect(parseColor('#fff')).toEqual({ r: 255, g: 255, b: 255 });
|
|
19
|
+
expect(parseColor('#000000')).toEqual({ r: 0, g: 0, b: 0 });
|
|
20
|
+
expect(parseColor('#1a2b3c')).toEqual({ r: 26, g: 43, b: 60 });
|
|
21
|
+
});
|
|
22
|
+
test('rgb()', () => {
|
|
23
|
+
expect(parseColor('rgb(10, 20, 30)')).toEqual({ r: 10, g: 20, b: 30 });
|
|
24
|
+
});
|
|
25
|
+
test('rejects junk', () => {
|
|
26
|
+
expect(() => parseColor('chartreuse')).toThrow();
|
|
27
|
+
});
|
|
28
|
+
test('oklch', () => {
|
|
29
|
+
const o = parseOklch('oklch(70% 0.1 250)');
|
|
30
|
+
expect(o.l).toBeCloseTo(0.7, 6);
|
|
31
|
+
expect(o.c).toBeCloseTo(0.1, 6);
|
|
32
|
+
expect(o.h).toBeCloseTo(250, 6);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('WCAG contrast', () => {
|
|
37
|
+
test('black on white is 21:1', () => {
|
|
38
|
+
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 1);
|
|
39
|
+
});
|
|
40
|
+
test('order independent', () => {
|
|
41
|
+
expect(contrastRatio('#ffffff', '#000000')).toBeCloseTo(21, 1);
|
|
42
|
+
});
|
|
43
|
+
test('#767676 on white ≈ 4.54 (the AA reference gray)', () => {
|
|
44
|
+
expect(contrastRatio('#767676', '#ffffff')).toBeCloseTo(4.54, 1);
|
|
45
|
+
});
|
|
46
|
+
test('relativeLuminance bounds', () => {
|
|
47
|
+
expect(relativeLuminance('#000000')).toBeCloseTo(0, 6);
|
|
48
|
+
expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 6);
|
|
49
|
+
});
|
|
50
|
+
test('meetsWcag thresholds', () => {
|
|
51
|
+
expect(meetsWcag(4.5)).toBe(true);
|
|
52
|
+
expect(meetsWcag(4.49)).toBe(false);
|
|
53
|
+
expect(meetsWcag(3.2, { large: true })).toBe(true);
|
|
54
|
+
expect(meetsWcag(3.2, { nonText: true })).toBe(true);
|
|
55
|
+
expect(meetsWcag(6.9, { level: 'AAA' })).toBe(false);
|
|
56
|
+
expect(meetsWcag(7.1, { level: 'AAA' })).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('OKLCH → sRGB', () => {
|
|
61
|
+
test('achromatic extremes', () => {
|
|
62
|
+
expect(oklchToRgb({ l: 1, c: 0, h: 0 })).toEqual({ r: 255, g: 255, b: 255 });
|
|
63
|
+
expect(oklchToRgb({ l: 0, c: 0, h: 0 })).toEqual({ r: 0, g: 0, b: 0 });
|
|
64
|
+
});
|
|
65
|
+
test('mid achromatic is neutral gray (r=g=b)', () => {
|
|
66
|
+
const g = oklchToRgb({ l: 0.5, c: 0, h: 0 });
|
|
67
|
+
expect(g.r).toBe(g.g);
|
|
68
|
+
expect(g.g).toBe(g.b);
|
|
69
|
+
expect(g.r).toBeGreaterThan(70);
|
|
70
|
+
expect(g.r).toBeLessThan(150);
|
|
71
|
+
});
|
|
72
|
+
test('a red-ish hue makes red the dominant channel', () => {
|
|
73
|
+
const red = oklchToRgb({ l: 0.63, c: 0.25, h: 29 });
|
|
74
|
+
expect(red.r).toBeGreaterThan(red.g);
|
|
75
|
+
expect(red.r).toBeGreaterThan(red.b);
|
|
76
|
+
});
|
|
77
|
+
test('round-trips through hex', () => {
|
|
78
|
+
expect(toHex(oklchToRgb({ l: 1, c: 0, h: 0 }))).toBe('#ffffff');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('ramps', () => {
|
|
83
|
+
test('evenly spaced lightness', () => {
|
|
84
|
+
const ramp = oklchRamp({ hue: 250, chroma: 0.1, count: 5, lMax: 0.9, lMin: 0.3 });
|
|
85
|
+
expect(ramp).toHaveLength(5);
|
|
86
|
+
expect(ramp[0].l).toBeCloseTo(0.9, 6);
|
|
87
|
+
expect(ramp[4].l).toBeCloseTo(0.3, 6);
|
|
88
|
+
expect(isPerceptuallyEven(ramp)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
test('detects an uneven ramp', () => {
|
|
91
|
+
const uneven = [
|
|
92
|
+
{ l: 0.9, c: 0.1, h: 0 },
|
|
93
|
+
{ l: 0.85, c: 0.1, h: 0 },
|
|
94
|
+
{ l: 0.4, c: 0.1, h: 0 },
|
|
95
|
+
];
|
|
96
|
+
expect(isPerceptuallyEven(uneven)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('60-30-10 distribution', () => {
|
|
101
|
+
test('passes when accent ≤ 15%', () => {
|
|
102
|
+
const r = colorDistribution([
|
|
103
|
+
{ role: 'dominant', area: 60 },
|
|
104
|
+
{ role: 'secondary', area: 30 },
|
|
105
|
+
{ role: 'accent', area: 10 },
|
|
106
|
+
]);
|
|
107
|
+
expect(r.ok).toBe(true);
|
|
108
|
+
expect(r.accentRatio).toBeCloseTo(0.1, 6);
|
|
109
|
+
expect(r.dominantRole).toBe('dominant');
|
|
110
|
+
});
|
|
111
|
+
test('fails when accent dominates', () => {
|
|
112
|
+
const r = colorDistribution([
|
|
113
|
+
{ role: 'base', area: 50 },
|
|
114
|
+
{ role: 'accent', area: 50 },
|
|
115
|
+
]);
|
|
116
|
+
expect(r.ok).toBe(false);
|
|
117
|
+
expect(r.accentRatio).toBeCloseTo(0.5, 6);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('CURRENT_COLOR constant', () => {
|
|
122
|
+
expect(CURRENT_COLOR).toBe('currentColor');
|
|
123
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
VIEWBOX,
|
|
4
|
+
boxViewBox,
|
|
5
|
+
circle,
|
|
6
|
+
group,
|
|
7
|
+
line,
|
|
8
|
+
place,
|
|
9
|
+
polygon,
|
|
10
|
+
rect,
|
|
11
|
+
snap,
|
|
12
|
+
squareViewBox,
|
|
13
|
+
text,
|
|
14
|
+
transformString,
|
|
15
|
+
use,
|
|
16
|
+
} from '../primitives.ts';
|
|
17
|
+
|
|
18
|
+
describe('snap', () => {
|
|
19
|
+
test('grid 0 is a no-op (optical adjustments survive)', () => {
|
|
20
|
+
expect(snap(12.34)).toBe(12.34);
|
|
21
|
+
expect(snap(12.34, 0)).toBe(12.34);
|
|
22
|
+
});
|
|
23
|
+
test('snaps to nearest multiple', () => {
|
|
24
|
+
expect(snap(13, 8)).toBe(16);
|
|
25
|
+
expect(snap(11, 8)).toBe(8);
|
|
26
|
+
expect(snap(7, 4)).toBe(8);
|
|
27
|
+
expect(snap(5, 4)).toBe(4);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('constructors', () => {
|
|
32
|
+
test('rect carries geometry + style', () => {
|
|
33
|
+
const r = rect({ x: 1, y: 2, width: 10, height: 20, rx: 3, fill: '#abc' });
|
|
34
|
+
expect(r).toMatchObject({ el: 'rect', x: 1, y: 2, width: 10, height: 20, rx: 3, fill: '#abc' });
|
|
35
|
+
});
|
|
36
|
+
test('rect snaps geometry to grid when requested', () => {
|
|
37
|
+
const r = rect({ x: 3, y: 5, width: 13, height: 21, grid: 8 }) as {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
};
|
|
43
|
+
expect(r.x).toBe(0);
|
|
44
|
+
expect(r.y).toBe(8);
|
|
45
|
+
expect(r.width).toBe(16);
|
|
46
|
+
expect(r.height).toBe(24);
|
|
47
|
+
});
|
|
48
|
+
test('grid is not leaked as a primitive field', () => {
|
|
49
|
+
const r = rect({ x: 0, y: 0, width: 8, height: 8, grid: 8 }) as Record<string, unknown>;
|
|
50
|
+
expect(r.grid).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
test('circle / line / polygon shapes', () => {
|
|
53
|
+
expect(circle({ cx: 12, cy: 12, r: 6 })).toMatchObject({ el: 'circle', cx: 12, cy: 12, r: 6 });
|
|
54
|
+
expect(line({ x1: 0, y1: 0, x2: 4, y2: 4 })).toMatchObject({ el: 'line', x2: 4 });
|
|
55
|
+
const poly = polygon({
|
|
56
|
+
points: [
|
|
57
|
+
{ x: 0, y: 0 },
|
|
58
|
+
{ x: 4, y: 0 },
|
|
59
|
+
{ x: 2, y: 4 },
|
|
60
|
+
],
|
|
61
|
+
}) as {
|
|
62
|
+
points: Array<{ x: number }>;
|
|
63
|
+
};
|
|
64
|
+
expect(poly.points).toHaveLength(3);
|
|
65
|
+
});
|
|
66
|
+
test('text carries content + type attrs', () => {
|
|
67
|
+
const t = text({
|
|
68
|
+
x: 0,
|
|
69
|
+
y: 0,
|
|
70
|
+
content: 'Hi',
|
|
71
|
+
fontSize: 16,
|
|
72
|
+
fontWeight: 700,
|
|
73
|
+
textAnchor: 'middle',
|
|
74
|
+
});
|
|
75
|
+
expect(t).toMatchObject({
|
|
76
|
+
el: 'text',
|
|
77
|
+
content: 'Hi',
|
|
78
|
+
fontSize: 16,
|
|
79
|
+
fontWeight: 700,
|
|
80
|
+
textAnchor: 'middle',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
test('use references a symbol id', () => {
|
|
84
|
+
expect(use({ href: '#leaf', x: 2, y: 4 })).toMatchObject({
|
|
85
|
+
el: 'use',
|
|
86
|
+
href: '#leaf',
|
|
87
|
+
x: 2,
|
|
88
|
+
y: 4,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('transform composition', () => {
|
|
94
|
+
test('transformString emits canonical translate→rotate→scale, skipping identity', () => {
|
|
95
|
+
expect(transformString({})).toBe('');
|
|
96
|
+
expect(transformString({ x: 4, y: 6 })).toBe('translate(4 6)');
|
|
97
|
+
expect(transformString({ rotate: 45 })).toBe('rotate(45)');
|
|
98
|
+
expect(transformString({ rotate: 45, originX: 12, originY: 12 })).toBe('rotate(45 12 12)');
|
|
99
|
+
expect(transformString({ x: 2, scale: 1.5, rotate: 90 })).toBe(
|
|
100
|
+
'translate(2 0) rotate(90) scale(1.5)'
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
test('place wraps children in a transformed group', () => {
|
|
104
|
+
const g = place([circle({ cx: 0, cy: 0, r: 1 })], { x: 10, y: 10 }) as {
|
|
105
|
+
el: string;
|
|
106
|
+
transform: string;
|
|
107
|
+
children: unknown[];
|
|
108
|
+
};
|
|
109
|
+
expect(g.el).toBe('group');
|
|
110
|
+
expect(g.transform).toBe('translate(10 10)');
|
|
111
|
+
expect(g.children).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
test('group passes opacity + id', () => {
|
|
114
|
+
const g = group([], { opacity: 0.5, id: 'x' }) as { opacity: number; id: string };
|
|
115
|
+
expect(g.opacity).toBe(0.5);
|
|
116
|
+
expect(g.id).toBe('x');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('viewBox presets', () => {
|
|
121
|
+
test('square / box helpers', () => {
|
|
122
|
+
expect(squareViewBox(24)).toBe('0 0 24 24');
|
|
123
|
+
expect(boxViewBox(1200, 630)).toBe('0 0 1200 630');
|
|
124
|
+
});
|
|
125
|
+
test('named presets', () => {
|
|
126
|
+
expect(VIEWBOX.icon).toBe('0 0 24 24');
|
|
127
|
+
expect(VIEWBOX.logo).toBe('0 0 64 64');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { path, circle, group, line, rect, text } from '../primitives.ts';
|
|
3
|
+
import type { DrawPrimitive } from '../primitives.ts';
|
|
4
|
+
import { primitivesToNodes, toJsx, toSvg } from '../serialize.ts';
|
|
5
|
+
|
|
6
|
+
const SAMPLE: DrawPrimitive[] = [
|
|
7
|
+
rect({ x: 2, y: 2, width: 20, height: 20, rx: 4 }), // fillable, no paint → currentColor
|
|
8
|
+
circle({ cx: 12, cy: 12, r: 6, stroke: '#333', strokeWidth: 1.5 }), // stroked → fill none
|
|
9
|
+
line({ x1: 0, y1: 0, x2: 24, y2: 24 }), // bare line → stroke currentColor
|
|
10
|
+
group([path({ d: 'M0 0 L4 4', fill: 'none', stroke: 'currentColor' })], {
|
|
11
|
+
transform: 'translate(1 1)',
|
|
12
|
+
}),
|
|
13
|
+
text({ x: 12, y: 20, content: 'Hi & <there>', fontSize: 8, textAnchor: 'middle' }),
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const OPTS = { viewBox: '0 0 24 24', a11y: { title: 'Sample mark', desc: 'A test drawing' } };
|
|
17
|
+
|
|
18
|
+
describe('toSvg', () => {
|
|
19
|
+
const svg = toSvg(SAMPLE, OPTS);
|
|
20
|
+
test('is a complete accessible document', () => {
|
|
21
|
+
expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"');
|
|
22
|
+
expect(svg).toContain('viewBox="0 0 24 24"');
|
|
23
|
+
expect(svg).toContain('role="img"');
|
|
24
|
+
expect(svg).toContain('<title>Sample mark</title>');
|
|
25
|
+
expect(svg).toContain('<desc>A test drawing</desc>');
|
|
26
|
+
});
|
|
27
|
+
test('defaults a paint-less fillable shape to currentColor', () => {
|
|
28
|
+
expect(svg).toMatch(/<rect[^>]*fill="currentColor"/);
|
|
29
|
+
});
|
|
30
|
+
test('stroked shape with no fill gets fill="none"', () => {
|
|
31
|
+
expect(svg).toMatch(/<circle[^>]*fill="none"[^>]*stroke="#333"/);
|
|
32
|
+
});
|
|
33
|
+
test('bare line inherits stroke currentColor', () => {
|
|
34
|
+
expect(svg).toMatch(/<line[^>]*stroke="currentColor"/);
|
|
35
|
+
});
|
|
36
|
+
test('kebab-case attribute names + escaped text', () => {
|
|
37
|
+
expect(svg).toContain('stroke-width="1.5"');
|
|
38
|
+
expect(svg).toContain('text-anchor="middle"');
|
|
39
|
+
expect(svg).toContain('Hi & <there>');
|
|
40
|
+
});
|
|
41
|
+
test('self-closes leaf shapes', () => {
|
|
42
|
+
expect(svg).toMatch(/<rect[^>]*\/>/);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('toJsx', () => {
|
|
47
|
+
const jsx = toJsx(SAMPLE, OPTS);
|
|
48
|
+
test('uses camelCase attribute dialect', () => {
|
|
49
|
+
expect(jsx).toContain('strokeWidth="1.5"');
|
|
50
|
+
expect(jsx).toContain('textAnchor="middle"');
|
|
51
|
+
});
|
|
52
|
+
test('omits the xmlns document marker', () => {
|
|
53
|
+
expect(jsx).not.toContain('xmlns=');
|
|
54
|
+
});
|
|
55
|
+
test('keeps viewBox + role + title', () => {
|
|
56
|
+
expect(jsx).toContain('viewBox="0 0 24 24"');
|
|
57
|
+
expect(jsx).toContain('role="img"');
|
|
58
|
+
expect(jsx).toContain('<title>Sample mark</title>');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('SVG ↔ JSX parity (the single-source invariant)', () => {
|
|
63
|
+
const svg = toSvg(SAMPLE, OPTS);
|
|
64
|
+
const jsx = toJsx(SAMPLE, OPTS);
|
|
65
|
+
|
|
66
|
+
const openTags = (s: string): string[] =>
|
|
67
|
+
Array.from(s.matchAll(/<([a-zA-Z][a-zA-Z0-9]*)/g), (m) => m[1]);
|
|
68
|
+
const numbers = (s: string): number[] =>
|
|
69
|
+
Array.from(s.replace(/\sxmlns="[^"]*"/, '').matchAll(/-?\d+\.?\d*/g), (m) => Number(m[0])).sort(
|
|
70
|
+
(a, b) => a - b
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
test('identical element sequence', () => {
|
|
74
|
+
expect(openTags(jsx)).toEqual(openTags(svg));
|
|
75
|
+
});
|
|
76
|
+
test('identical geometry (number multiset, ignoring xmlns)', () => {
|
|
77
|
+
expect(numbers(jsx)).toEqual(numbers(svg));
|
|
78
|
+
});
|
|
79
|
+
test('both built from one node tree (primitivesToNodes)', () => {
|
|
80
|
+
const root = primitivesToNodes(SAMPLE, OPTS);
|
|
81
|
+
expect(root.tag).toBe('svg');
|
|
82
|
+
// title + desc + 5 primitives
|
|
83
|
+
expect(root.children.map((c) => c.tag)).toEqual([
|
|
84
|
+
'title',
|
|
85
|
+
'desc',
|
|
86
|
+
'rect',
|
|
87
|
+
'circle',
|
|
88
|
+
'line',
|
|
89
|
+
'g',
|
|
90
|
+
'text',
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('decorative a11y', () => {
|
|
96
|
+
test('decorative marks get aria-hidden and no title/desc/role', () => {
|
|
97
|
+
const svg = toSvg([circle({ cx: 12, cy: 12, r: 6 })], {
|
|
98
|
+
viewBox: '0 0 24 24',
|
|
99
|
+
a11y: { decorative: true },
|
|
100
|
+
});
|
|
101
|
+
expect(svg).toContain('aria-hidden="true"');
|
|
102
|
+
expect(svg).not.toContain('role="img"');
|
|
103
|
+
expect(svg).not.toContain('<title>');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -317,15 +317,30 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
317
317
|
lastMeta = docMeta;
|
|
318
318
|
lastCss = docCss;
|
|
319
319
|
if (localHtml !== docHtml) {
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
320
|
+
// DATA-LOSS GUARD: an EMPTY hub doc at cold-start means the hub holds no
|
|
321
|
+
// body for this slug yet (fresh / never-seeded hub) — NOT an authoritative
|
|
322
|
+
// "this canvas is blank". Overwriting a non-empty local body with it would
|
|
323
|
+
// silently destroy the canvas (observed in the wild: a fresh hub emptied
|
|
324
|
+
// every local .tsx on first connect). The comments/annotations/meta/css
|
|
325
|
+
// branches below already skip empty-doc writes, and the shared-doc
|
|
326
|
+
// projection path documents the same "never clobber non-empty local with
|
|
327
|
+
// an empty doc value" rule — the HTML body was the one branch missing it.
|
|
328
|
+
// Resolution: seed the doc FROM local instead, so the body survives AND
|
|
329
|
+
// the hub gets our content (local→doc, exactly like a first-sync seed).
|
|
330
|
+
if (docHtml === '' && localHtml !== null && localHtml.trim() !== '') {
|
|
331
|
+
applyHtmlToDoc(doc, localHtml, origin);
|
|
332
|
+
lastHtml = localHtml;
|
|
333
|
+
} else {
|
|
334
|
+
// Local had divergent, non-empty content that hub-wins is discarding —
|
|
335
|
+
// notify so the user knows their local edits were overwritten (Task 8).
|
|
336
|
+
// An absent/empty local file is a clean first-sync, not a conflict.
|
|
337
|
+
if (localHtml !== null && localHtml.trim() !== '') {
|
|
338
|
+
opts.onConflict?.({ slug, kind: 'cold-start-hub-wins' });
|
|
339
|
+
}
|
|
340
|
+
const hash = hashBytes(docHtml);
|
|
341
|
+
echoGuard.record(paths.html, hash);
|
|
342
|
+
writer(paths.html, docHtml);
|
|
325
343
|
}
|
|
326
|
-
const hash = hashBytes(docHtml);
|
|
327
|
-
echoGuard.record(paths.html, hash);
|
|
328
|
-
writer(paths.html, docHtml);
|
|
329
344
|
}
|
|
330
345
|
if (docCommentsStr !== '' && localComments !== docCommentsStr) {
|
|
331
346
|
const hash = hashBytes(docCommentsStr);
|
|
@@ -246,6 +246,19 @@ export function createSyncRuntime(
|
|
|
246
246
|
return;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
// DDR-072 — loud boot banner when the project-level TSX opt-in is on against
|
|
250
|
+
// a NON-loopback hub. Broadening from hand-picked canvases to "all .tsx"
|
|
251
|
+
// also broadens the WebRTC/self-nav exfil residual (the sandbox contains
|
|
252
|
+
// execution but not that lane) to every synced canvas, so this must never be
|
|
253
|
+
// silent: a sneaky PR that flips `linkedHub.syncTsx` surfaces here on the
|
|
254
|
+
// next `serve`. Loopback hubs (local dev) skip it — no remote exfil concern.
|
|
255
|
+
if (linkedHub.syncTsx === true && !isLoopbackHubUrl(linkedHub.url)) {
|
|
256
|
+
const tsxCount = canvases.filter((c) => c.html.toLowerCase().endsWith('.tsx')).length;
|
|
257
|
+
console.warn(
|
|
258
|
+
`[sync] syncTsx=true — ${tsxCount} TSX canvas BODIES will sync to ${linkedHub.url} (project-level opt-in, DDR-072). The sandbox contains execution, but the WebRTC/self-nav exfil residual now applies to ALL synced canvases — only for hubs you operate or fully trust. Set a canvas .meta.json "syncable": false to exclude it.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
249
262
|
statusStore =
|
|
250
263
|
opts.statusStore ??
|
|
251
264
|
createSyncStatusStore({
|
|
@@ -599,6 +612,12 @@ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
|
|
|
599
612
|
// `.tsx` syncs — the per-canvas opt-in is inert without the sandbox, and
|
|
600
613
|
// decoupling them would re-open the CRITICAL F1 RCE (DDR-060, DDR-054 §F1).
|
|
601
614
|
const splitActive = !!ctx.canvasOrigin;
|
|
615
|
+
// DDR-072 — project-level TSX opt-in. When `linkedHub.syncTsx` is true, a
|
|
616
|
+
// `.tsx` with no explicit sidecar verdict defaults to syncable (the per-canvas
|
|
617
|
+
// sidecar still wins either way — see resolveSyncable). Coupled with the
|
|
618
|
+
// sandbox via `splitActive` exactly like the per-canvas opt-in, so the flag is
|
|
619
|
+
// inert without the F1 containment (DDR-060 Lock-2 invariant).
|
|
620
|
+
const projectSyncTsx = ctx.cfg.linkedHub?.syncTsx === true;
|
|
602
621
|
for (const group of ctx.cfg.canvasGroups) {
|
|
603
622
|
const groupAbs = path.join(ctx.paths.designRoot, group.path);
|
|
604
623
|
if (!existsSync(groupAbs)) continue;
|
|
@@ -609,27 +628,37 @@ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
|
|
|
609
628
|
ctx.paths.designRel,
|
|
610
629
|
out,
|
|
611
630
|
counter,
|
|
612
|
-
splitActive
|
|
631
|
+
splitActive,
|
|
632
|
+
projectSyncTsx
|
|
613
633
|
);
|
|
614
634
|
}
|
|
615
635
|
return { canvases: out, tsxCount: counter.tsx };
|
|
616
636
|
}
|
|
617
637
|
|
|
618
638
|
/**
|
|
619
|
-
* T3 (9.1-B)
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
639
|
+
* T3 (9.1-B) + DDR-072 — resolve whether a `.tsx` body is syncable.
|
|
640
|
+
*
|
|
641
|
+
* Tri-state precedence:
|
|
642
|
+
* 1. The sibling `<name>.meta.json` `"syncable"` boolean ALWAYS wins when
|
|
643
|
+
* present (`true` opts in, `false` opts out) — set by a human editing the
|
|
644
|
+
* sidecar; deliberately NOT in the untrusted `/_api/canvas-meta` PATCH
|
|
645
|
+
* whitelist (api.ts), so a hostile canvas/hub cannot flip its own body into
|
|
646
|
+
* or out of the sync set.
|
|
647
|
+
* 2. Otherwise fall back to the project-level `linkedHub.syncTsx` default.
|
|
648
|
+
*
|
|
649
|
+
* Missing sidecar / parse error → no explicit verdict → the project default.
|
|
624
650
|
*/
|
|
625
|
-
function
|
|
651
|
+
function resolveSyncable(bodyAbs: string, projectSyncTsx: boolean): boolean {
|
|
626
652
|
const metaAbs = bodyAbs.replace(/\.(tsx|html)$/i, '.meta.json');
|
|
627
653
|
try {
|
|
628
654
|
const obj = JSON.parse(readFileSync(metaAbs, 'utf8'));
|
|
629
|
-
|
|
655
|
+
if (obj && typeof obj === 'object' && typeof obj.syncable === 'boolean') {
|
|
656
|
+
return obj.syncable; // per-canvas verdict wins (true OR explicit false)
|
|
657
|
+
}
|
|
630
658
|
} catch {
|
|
631
|
-
|
|
659
|
+
/* missing / unparseable sidecar → defer to the project default below */
|
|
632
660
|
}
|
|
661
|
+
return projectSyncTsx;
|
|
633
662
|
}
|
|
634
663
|
|
|
635
664
|
async function walk(
|
|
@@ -639,7 +668,8 @@ async function walk(
|
|
|
639
668
|
designRel: string,
|
|
640
669
|
acc: CanvasDescriptor[],
|
|
641
670
|
counter: { tsx: number },
|
|
642
|
-
splitActive: boolean
|
|
671
|
+
splitActive: boolean,
|
|
672
|
+
projectSyncTsx: boolean
|
|
643
673
|
): Promise<void> {
|
|
644
674
|
let entries: import('node:fs').Dirent[];
|
|
645
675
|
try {
|
|
@@ -652,16 +682,26 @@ async function walk(
|
|
|
652
682
|
if (entry.isDirectory()) {
|
|
653
683
|
// Skip plugin runtime dirs.
|
|
654
684
|
if (entry.name.startsWith('_')) continue;
|
|
655
|
-
await walk(
|
|
685
|
+
await walk(
|
|
686
|
+
abs,
|
|
687
|
+
designRoot,
|
|
688
|
+
commentsDir,
|
|
689
|
+
designRel,
|
|
690
|
+
acc,
|
|
691
|
+
counter,
|
|
692
|
+
splitActive,
|
|
693
|
+
projectSyncTsx
|
|
694
|
+
);
|
|
656
695
|
continue;
|
|
657
696
|
}
|
|
658
697
|
const ext = path.extname(entry.name).toLowerCase();
|
|
659
|
-
// T3 (9.1-B) — a `.tsx` syncs ONLY when the sandbox is active AND
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
698
|
+
// T3 (9.1-B) + DDR-072 — a `.tsx` syncs ONLY when the sandbox is active AND
|
|
699
|
+
// it resolves to syncable (per-canvas sidecar verdict, else the project-level
|
|
700
|
+
// `linkedHub.syncTsx` default — see resolveSyncable + the splitActive
|
|
701
|
+
// coupling above). Otherwise tally it for 9.1-D's loud zero-syncable surface
|
|
702
|
+
// so the message can explain *why* it isn't syncing.
|
|
663
703
|
if (ext === '.tsx') {
|
|
664
|
-
if (!(splitActive &&
|
|
704
|
+
if (!(splitActive && resolveSyncable(abs, projectSyncTsx))) {
|
|
665
705
|
counter.tsx += 1;
|
|
666
706
|
continue;
|
|
667
707
|
}
|
|
@@ -712,7 +752,7 @@ export function buildNoSyncablePayload(
|
|
|
712
752
|
): NoSyncablePayload {
|
|
713
753
|
const reason =
|
|
714
754
|
tsxCount > 0
|
|
715
|
-
? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs
|
|
755
|
+
? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs when the canvas opts in (.meta.json "syncable": true) OR the project opts in all of them (.design/config.json linkedHub.syncTsx: true, DDR-072). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
|
|
716
756
|
: `no canvases found under ${designRoot}.`;
|
|
717
757
|
return {
|
|
718
758
|
linked: true,
|
|
@@ -916,3 +956,19 @@ export function checkUrlScheme(url: string): string | null {
|
|
|
916
956
|
}
|
|
917
957
|
return null;
|
|
918
958
|
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* DDR-072 — true when the hub URL points at a loopback host (localhost,
|
|
962
|
+
* 127.0.0.1, ::1). Used to suppress the `syncTsx` boot banner for local dev
|
|
963
|
+
* hubs (no remote exfil concern). Unparseable URL → treated as non-loopback
|
|
964
|
+
* (fail loud / show the banner). Mirrors checkUrlScheme's loopback host set.
|
|
965
|
+
*/
|
|
966
|
+
export function isLoopbackHubUrl(url: string): boolean {
|
|
967
|
+
let host: string;
|
|
968
|
+
try {
|
|
969
|
+
host = new URL(url).hostname.toLowerCase();
|
|
970
|
+
} catch {
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
974
|
+
}
|
|
@@ -202,6 +202,34 @@ describe('CanvasSyncAgent — cold start reconciliation', () => {
|
|
|
202
202
|
expect(htmlFromDoc(docB)).toBe('<button>hub-v2</button>');
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
test('empty hub doc does NOT clobber a non-empty local body — seeds local up instead (data-loss guard)', async () => {
|
|
206
|
+
// Local holds the real canvas body; the hub is fresh — no state for this
|
|
207
|
+
// slug yet (docB starts empty → docHtml === ''). The old behaviour wrote
|
|
208
|
+
// the empty doc over disk and emptied the file; the guard must keep local.
|
|
209
|
+
writeFileSync(paths().html, '<button>real-canvas</button>');
|
|
210
|
+
|
|
211
|
+
let conflicts = 0;
|
|
212
|
+
agent = createCanvasSyncAgent({
|
|
213
|
+
slug: 'screen',
|
|
214
|
+
doc: docB,
|
|
215
|
+
paths: paths(),
|
|
216
|
+
echoGuard: createEchoGuard(),
|
|
217
|
+
flushMs: 0,
|
|
218
|
+
onConflict: () => {
|
|
219
|
+
conflicts++;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
agent.start();
|
|
223
|
+
await agent.reconcile();
|
|
224
|
+
|
|
225
|
+
// The local body MUST survive — an empty hub must never empty a real canvas.
|
|
226
|
+
expect(readFileSync(paths().html, 'utf8')).toBe('<button>real-canvas</button>');
|
|
227
|
+
// …and it is seeded UP to the hub instead of being discarded (local→doc).
|
|
228
|
+
expect(htmlFromDoc(docA)).toBe('<button>real-canvas</button>');
|
|
229
|
+
// An empty-hub seed is not a "hub overwrote your local" conflict.
|
|
230
|
+
expect(conflicts).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
205
233
|
test('identical disk + doc states: no disk write', async () => {
|
|
206
234
|
writeFileSync(paths().html, '<button>same</button>');
|
|
207
235
|
applyHtmlToDoc(docA, '<button>same</button>');
|
|
@@ -783,6 +783,58 @@ describe('scanCanvases', () => {
|
|
|
783
783
|
});
|
|
784
784
|
});
|
|
785
785
|
|
|
786
|
+
// DDR-072 — project-level TSX opt-in (linkedHub.syncTsx). A .tsx with no
|
|
787
|
+
// explicit sidecar verdict defaults to syncable when the project flag is on;
|
|
788
|
+
// the per-canvas sidecar still wins; the sandbox coupling is preserved.
|
|
789
|
+
describe('scanCanvases — project-level syncTsx (DDR-072)', () => {
|
|
790
|
+
test('syncTsx:true + sandbox ON admits a .tsx WITHOUT a per-canvas opt-in', async () => {
|
|
791
|
+
const ctx = makeCtx(
|
|
792
|
+
{ url: 'https://h.example.com', linkedAt: 1, syncTsx: true },
|
|
793
|
+
'http://localhost:9'
|
|
794
|
+
);
|
|
795
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'free.tsx'), 'export default () => null;');
|
|
796
|
+
const scan = await scanCanvases(ctx);
|
|
797
|
+
const free = scan.canvases.find((c) => c.slug === 'ui-free');
|
|
798
|
+
expect(free).toBeDefined();
|
|
799
|
+
expect(free?.html).toBe(join(ctx.paths.designRoot, 'ui', 'free.tsx'));
|
|
800
|
+
expect(scan.tsxCount).toBe(0);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test('syncTsx:true is INERT when the sandbox split is OFF (Lock-2 coupling preserved)', async () => {
|
|
804
|
+
// canvasOrigin undefined → sandbox not in force → no .tsx syncs, flag or not.
|
|
805
|
+
const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1, syncTsx: true });
|
|
806
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'free.tsx'), 'export default () => null;');
|
|
807
|
+
const scan = await scanCanvases(ctx);
|
|
808
|
+
expect(scan.canvases.map((c) => c.slug)).not.toContain('ui-free');
|
|
809
|
+
expect(scan.tsxCount).toBe(1);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('per-canvas "syncable": false OVERRIDES syncTsx:true (sidecar wins)', async () => {
|
|
813
|
+
const ctx = makeCtx(
|
|
814
|
+
{ url: 'https://h.example.com', linkedAt: 1, syncTsx: true },
|
|
815
|
+
'http://localhost:9'
|
|
816
|
+
);
|
|
817
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'secret.tsx'), 'export default () => null;');
|
|
818
|
+
writeFileSync(
|
|
819
|
+
join(ctx.paths.designRoot, 'ui', 'secret.meta.json'),
|
|
820
|
+
JSON.stringify({ syncable: false, title: 'Secret' })
|
|
821
|
+
);
|
|
822
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'open.tsx'), 'export default () => null;');
|
|
823
|
+
const scan = await scanCanvases(ctx);
|
|
824
|
+
const slugs = scan.canvases.map((c) => c.slug);
|
|
825
|
+
expect(slugs).toContain('ui-open'); // no sidecar → project default
|
|
826
|
+
expect(slugs).not.toContain('ui-secret'); // explicit opt-out wins
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test('without the flag (syncTsx absent) a .tsx still needs the per-canvas opt-in', async () => {
|
|
830
|
+
const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 }, 'http://localhost:9');
|
|
831
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'plain.tsx'), 'export default () => null;');
|
|
832
|
+
const scan = await scanCanvases(ctx);
|
|
833
|
+
expect(scan.canvases.map((c) => c.slug)).not.toContain('ui-plain');
|
|
834
|
+
expect(scan.tsxCount).toBe(1);
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
786
838
|
describe('buildNoSyncablePayload', () => {
|
|
787
839
|
test('TSX-only project: reason names the count + DDR-060', () => {
|
|
788
840
|
const p = buildNoSyncablePayload('https://h.example.com', 3, '/proj/.design');
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
"forceConsistentCasingInFileNames": true,
|
|
22
22
|
"allowImportingTsExtensions": true
|
|
23
23
|
},
|
|
24
|
-
"include": [
|
|
24
|
+
"include": [
|
|
25
|
+
"*.ts",
|
|
26
|
+
"draw/**/*.ts",
|
|
27
|
+
"client/**/*.ts",
|
|
28
|
+
"client/**/*.tsx",
|
|
29
|
+
"client/**/*.jsx",
|
|
30
|
+
"test/**/*.ts"
|
|
31
|
+
],
|
|
25
32
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
26
33
|
}
|