@1agh/maude 0.24.0 → 0.26.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/dependencies.json +30 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
- package/plugins/design/dev-server/annotations-layer.tsx +817 -170
- package/plugins/design/dev-server/api.ts +15 -1
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- 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/smoke.sh +114 -12
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +130 -82
- package/plugins/design/dev-server/canvas-icons.tsx +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/canvas-shell.tsx +113 -89
- package/plugins/design/dev-server/client/app.jsx +1084 -417
- 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 +242 -20
- package/plugins/design/dev-server/dist/comment-mount.js +40 -62
- 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/export-dialog.tsx +189 -1
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/http.ts +40 -9
- package/plugins/design/dev-server/input-router.tsx +9 -2
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +11 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
- package/plugins/design/dev-server/tool-palette.tsx +140 -11
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
- package/plugins/design/templates/_shell.html +36 -9
- 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
|
+
});
|
|
@@ -24,12 +24,141 @@ import {
|
|
|
24
24
|
useState,
|
|
25
25
|
} from 'react';
|
|
26
26
|
|
|
27
|
+
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
28
|
+
|
|
27
29
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
30
|
// Types
|
|
29
31
|
|
|
32
|
+
// ─── cross-origin export bridge ──────────────────────────────────────────────
|
|
33
|
+
// This dialog renders INSIDE the canvas iframe. Since phase-9.1 the canvas is
|
|
34
|
+
// served from a segregated origin (default ON) whose CSP is `connect-src 'self'`
|
|
35
|
+
// and whose route allowlist deliberately excludes the privileged /_api/export +
|
|
36
|
+
// /_api/export-history (DDR-060). A direct in-iframe fetch therefore 403s
|
|
37
|
+
// ("Forbidden (canvas origin)"). When the parent is cross-origin we ask the
|
|
38
|
+
// trusted main shell (app.jsx onMessage) to run the request same-origin via a
|
|
39
|
+
// `dgn` postMessage — the channel comments/selection already use. When the split
|
|
40
|
+
// is OFF (same-origin iframe) we fall through to the direct fetch.
|
|
41
|
+
|
|
42
|
+
/** True when framed by a different origin (the canvas-origin split is on). */
|
|
43
|
+
function isCrossOriginFramed(): boolean {
|
|
44
|
+
if (typeof window === 'undefined' || window.parent === window) return false;
|
|
45
|
+
try {
|
|
46
|
+
// Same-origin parent → this read succeeds → split is OFF, use direct fetch.
|
|
47
|
+
void window.parent.location.href;
|
|
48
|
+
return false;
|
|
49
|
+
} catch {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parent (main shell) origin, for postMessage targeting. */
|
|
55
|
+
function parentOrigin(): string {
|
|
56
|
+
try {
|
|
57
|
+
if (document.referrer) return new URL(document.referrer).origin;
|
|
58
|
+
} catch {
|
|
59
|
+
/* fall through to wildcard */
|
|
60
|
+
}
|
|
61
|
+
return '*';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let bridgeSeq = 0;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Post one request to the parent shell and resolve with its matching reply.
|
|
68
|
+
* Only accepts a reply from the parent window (source check) with the matching
|
|
69
|
+
* id; rejects on a 60 s timeout so a dropped reply can't hang the dialog.
|
|
70
|
+
*/
|
|
71
|
+
function bridgeRequest<T>(
|
|
72
|
+
reqDgn: string,
|
|
73
|
+
resDgn: string,
|
|
74
|
+
extra: Record<string, unknown>
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
bridgeSeq += 1;
|
|
77
|
+
const id = `exp-${bridgeSeq}`;
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const onMsg = (e: MessageEvent) => {
|
|
80
|
+
if (e.source !== window.parent) return;
|
|
81
|
+
const m = e.data as { dgn?: string; id?: string } | null;
|
|
82
|
+
if (!m || typeof m !== 'object' || m.dgn !== resDgn || m.id !== id) return;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
window.removeEventListener('message', onMsg);
|
|
85
|
+
resolve(m as unknown as T);
|
|
86
|
+
};
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
window.removeEventListener('message', onMsg);
|
|
89
|
+
reject(new Error('export bridge timed out'));
|
|
90
|
+
}, 60_000);
|
|
91
|
+
window.addEventListener('message', onMsg);
|
|
92
|
+
window.parent.postMessage({ dgn: reqDgn, id, ...extra }, parentOrigin());
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
30
96
|
export type Format = 'png' | 'pdf' | 'svg' | 'html' | 'pptx' | 'canva' | 'zip';
|
|
31
97
|
export type Scope = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-raw';
|
|
32
98
|
|
|
99
|
+
// ─── PNG size presets (item 1) ───────────────────────────────────────────────
|
|
100
|
+
// Resolution multiplier applied as Chromium `deviceScaleFactor`. The native
|
|
101
|
+
// artboard is 1440×900; 2× → 2880×1800. Default 2× because a single-scale PNG
|
|
102
|
+
// was uselessly small. The shim clamps deviceScaleFactor ≤ 4.
|
|
103
|
+
type PngScale = 1 | 2 | 3;
|
|
104
|
+
const PNG_SCALES: ReadonlyArray<{ value: PngScale; label: string }> = [
|
|
105
|
+
{ value: 1, label: '1× (native)' },
|
|
106
|
+
{ value: 2, label: '2× (retina)' },
|
|
107
|
+
{ value: 3, label: '3× (max)' },
|
|
108
|
+
];
|
|
109
|
+
const DEFAULT_PNG_SCALE: PngScale = 2;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The artboard under the viewport centre — the export dialog's notion of "the
|
|
113
|
+
* active artboard" when nothing is selected. getBoundingClientRect is in
|
|
114
|
+
* screen coords (post-zoom) so the centre-distance metric is zoom-invariant.
|
|
115
|
+
* Returns undefined off-DOM (tests) or when the canvas has no artboards.
|
|
116
|
+
*/
|
|
117
|
+
function activeArtboardId(): string | undefined {
|
|
118
|
+
if (typeof document === 'undefined' || typeof window === 'undefined') return undefined;
|
|
119
|
+
const screens = Array.from(document.querySelectorAll('[data-dc-screen]'));
|
|
120
|
+
if (!screens.length) return undefined;
|
|
121
|
+
const cx = window.innerWidth / 2;
|
|
122
|
+
const cy = window.innerHeight / 2;
|
|
123
|
+
let best: Element | null = null;
|
|
124
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
125
|
+
for (const el of screens) {
|
|
126
|
+
const r = el.getBoundingClientRect();
|
|
127
|
+
const dx = (r.left + r.right) / 2 - cx;
|
|
128
|
+
const dy = (r.top + r.bottom) / 2 - cy;
|
|
129
|
+
const d = dx * dx + dy * dy;
|
|
130
|
+
if (d < bestDist) {
|
|
131
|
+
bestDist = d;
|
|
132
|
+
best = el;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return best?.getAttribute('data-dc-screen') ?? undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Snapshot the live canvas selection + active artboard at submit time so the
|
|
140
|
+
* export is independent of whether opening the dialog cleared the persisted
|
|
141
|
+
* `_active.json.selected`. Rides the `options` bag through the cross-origin
|
|
142
|
+
* bridge into `resolveScope` (see scope.ts `ExportScopeHints`). `selSet` is the
|
|
143
|
+
* optional selection-set context — null when the dialog is mounted outside a
|
|
144
|
+
* provider (tests), in which case we still contribute the viewport-centre
|
|
145
|
+
* artboard id.
|
|
146
|
+
*/
|
|
147
|
+
function captureScopeHints(
|
|
148
|
+
selSet: {
|
|
149
|
+
selected: Array<{ selector?: string; file?: string; artboardId?: string | null }>;
|
|
150
|
+
} | null
|
|
151
|
+
): { selection?: { selector: string; file?: string }; artboardId?: string } {
|
|
152
|
+
const out: { selection?: { selector: string; file?: string }; artboardId?: string } = {};
|
|
153
|
+
const sel = selSet?.selected?.[0];
|
|
154
|
+
if (sel?.selector) {
|
|
155
|
+
out.selection = { selector: sel.selector, ...(sel.file ? { file: sel.file } : {}) };
|
|
156
|
+
}
|
|
157
|
+
const artboardId = (sel?.artboardId ?? undefined) || activeArtboardId();
|
|
158
|
+
if (artboardId) out.artboardId = artboardId;
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
33
162
|
const FORMAT_META: Record<Format, { label: string; description: string; defaultExt: string }> = {
|
|
34
163
|
png: { label: 'PNG', description: 'Raster image, one per artboard.', defaultExt: '.png' },
|
|
35
164
|
pdf: { label: 'PDF', description: 'Multi-page PDF, one page per artboard.', defaultExt: '.pdf' },
|
|
@@ -167,6 +296,15 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
|
|
|
167
296
|
// Pre-load history when the dialog opens; refresh after each export.
|
|
168
297
|
const loadHistory = useCallback(async () => {
|
|
169
298
|
try {
|
|
299
|
+
if (isCrossOriginFramed()) {
|
|
300
|
+
const res = await bridgeRequest<{ history?: ExportHistoryEntry[] }>(
|
|
301
|
+
'export-history-request',
|
|
302
|
+
'export-history-result',
|
|
303
|
+
{}
|
|
304
|
+
);
|
|
305
|
+
setHistory(Array.isArray(res.history) ? res.history : []);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
170
308
|
const r = await fetch('/_api/export-history');
|
|
171
309
|
if (!r.ok) return;
|
|
172
310
|
const data = (await r.json()) as { history: ExportHistoryEntry[] };
|
|
@@ -213,6 +351,23 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
|
|
|
213
351
|
setSubmitting(true);
|
|
214
352
|
setStatus(null);
|
|
215
353
|
try {
|
|
354
|
+
if (isCrossOriginFramed()) {
|
|
355
|
+
// Bridge through the main shell — the iframe can't reach /_api/export
|
|
356
|
+
// (canvas origin 403s it). The parent runs the export + triggers the
|
|
357
|
+
// download, then replies with the saved filename or an error string.
|
|
358
|
+
const res = await bridgeRequest<{ ok?: boolean; filename?: string; error?: string }>(
|
|
359
|
+
'export-request',
|
|
360
|
+
'export-result',
|
|
361
|
+
{ payload: { format, scope, options } }
|
|
362
|
+
);
|
|
363
|
+
if (!res.ok) {
|
|
364
|
+
setStatus({ text: `Export failed: ${res.error || 'unknown'}`, isError: true });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
setStatus({ text: `Saved ${res.filename ?? 'export'}`, isError: false });
|
|
368
|
+
void loadHistory();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
216
371
|
const r = await fetch('/_api/export', {
|
|
217
372
|
method: 'POST',
|
|
218
373
|
headers: { 'content-type': 'application/json' },
|
|
@@ -296,6 +451,20 @@ const DialogShell = (() => {
|
|
|
296
451
|
const { ref, openState, onClose, onSubmit, history, submitting, status } = props;
|
|
297
452
|
const [format, setFormat] = useState<Format>('png');
|
|
298
453
|
const [scope, setScope] = useState<Scope>('artboard');
|
|
454
|
+
const [pngScale, setPngScale] = useState<PngScale>(DEFAULT_PNG_SCALE);
|
|
455
|
+
// Optional — the dialog can be mounted in tests without a provider.
|
|
456
|
+
const selSet = useSelectionSetOptional();
|
|
457
|
+
|
|
458
|
+
// Build the options bag at submit time: snapshot the live selection /
|
|
459
|
+
// active artboard (items 3 & 5) plus the PNG size (item 1).
|
|
460
|
+
const handleSubmit = useCallback(() => {
|
|
461
|
+
const options: Record<string, unknown> = {};
|
|
462
|
+
const hints = captureScopeHints(selSet);
|
|
463
|
+
if (hints.selection) options.selection = hints.selection;
|
|
464
|
+
if (hints.artboardId) options.artboardId = hints.artboardId;
|
|
465
|
+
if (format === 'png') options.scale = pngScale;
|
|
466
|
+
onSubmit(format, scope, options);
|
|
467
|
+
}, [selSet, format, scope, pngScale, onSubmit]);
|
|
299
468
|
|
|
300
469
|
useEffect(() => {
|
|
301
470
|
if (!openState) return;
|
|
@@ -354,6 +523,25 @@ const DialogShell = (() => {
|
|
|
354
523
|
</select>
|
|
355
524
|
<p className="dc-ed-desc">{SCOPE_META[scope].description}</p>
|
|
356
525
|
</div>
|
|
526
|
+
{format === 'png' && (
|
|
527
|
+
<div>
|
|
528
|
+
<label htmlFor="dc-ed-png-scale">Size</label>
|
|
529
|
+
<select
|
|
530
|
+
id="dc-ed-png-scale"
|
|
531
|
+
value={pngScale}
|
|
532
|
+
onChange={(e) => setPngScale(Number(e.target.value) as PngScale)}
|
|
533
|
+
>
|
|
534
|
+
{PNG_SCALES.map((s) => (
|
|
535
|
+
<option key={s.value} value={s.value}>
|
|
536
|
+
{s.label}
|
|
537
|
+
</option>
|
|
538
|
+
))}
|
|
539
|
+
</select>
|
|
540
|
+
<p className="dc-ed-desc">
|
|
541
|
+
Resolution multiplier. 2× ≈ 2880×1800 for a 1440×900 artboard.
|
|
542
|
+
</p>
|
|
543
|
+
</div>
|
|
544
|
+
)}
|
|
357
545
|
</div>
|
|
358
546
|
{history.length > 0 && (
|
|
359
547
|
<div className="dc-ed-recent">
|
|
@@ -391,7 +579,7 @@ const DialogShell = (() => {
|
|
|
391
579
|
type="button"
|
|
392
580
|
className="dc-ed-primary"
|
|
393
581
|
disabled={submitting}
|
|
394
|
-
onClick={
|
|
582
|
+
onClick={handleSubmit}
|
|
395
583
|
>
|
|
396
584
|
{submitting ? 'Exporting…' : 'Export'}
|
|
397
585
|
</button>
|
|
@@ -40,7 +40,10 @@ async function captureHtml(
|
|
|
40
40
|
if (target.multi) {
|
|
41
41
|
args.push('--multi', '1', '--out-dir', outDir);
|
|
42
42
|
} else {
|
|
43
|
-
|
|
43
|
+
// Widen to the enclosing artboard only when scope.ts requested it; a
|
|
44
|
+
// `selection` target serializes the element exactly.
|
|
45
|
+
if (target.widen) args.push('--widen-to-artboard', '1');
|
|
46
|
+
args.push('--out', path.join(outDir, `${target.canvasSlug}.html`));
|
|
44
47
|
}
|
|
45
48
|
const proc = Bun.spawn(['node', ...args], {
|
|
46
49
|
cwd: path.dirname(HTML_PLAYWRIGHT),
|
|
@@ -89,7 +89,10 @@ export async function runExport(args: {
|
|
|
89
89
|
resolve: Omit<ResolveScopeArgs, 'scope'>;
|
|
90
90
|
ctx: ExportContext;
|
|
91
91
|
}): Promise<ExportResult> {
|
|
92
|
-
|
|
92
|
+
// Thread the options bag into the resolver so submit-time selection /
|
|
93
|
+
// active-artboard hints (DDR — selection-passthrough) win over the persisted
|
|
94
|
+
// `_active.json`. Additive — existing `resolve` callers stay valid.
|
|
95
|
+
const targets = await resolveScope({ ...args.resolve, scope: args.scope, options: args.options });
|
|
93
96
|
const adapter = getAdapter(args.format);
|
|
94
97
|
if (!adapter) {
|
|
95
98
|
throw new Error(`unknown format: ${args.format}`);
|
|
@@ -40,7 +40,13 @@ async function capturePdf(
|
|
|
40
40
|
String(timeoutSec),
|
|
41
41
|
];
|
|
42
42
|
if (target.multi) args.push('--multi', '1', '--out-dir', outDir);
|
|
43
|
-
else
|
|
43
|
+
else {
|
|
44
|
+
// Widen to the enclosing artboard only when scope.ts requested it
|
|
45
|
+
// (artboard-via-descendant fallback). selection / artboard-by-id targets
|
|
46
|
+
// already point at the exact element / screen.
|
|
47
|
+
if (target.widen) args.push('--widen-to-artboard', '1');
|
|
48
|
+
args.push('--out', path.join(outDir, `${target.canvasSlug}.pdf`));
|
|
49
|
+
}
|
|
44
50
|
|
|
45
51
|
const proc = Bun.spawn(['node', ...args], {
|
|
46
52
|
cwd: path.dirname(PDF_PLAYWRIGHT),
|
|
@@ -28,6 +28,17 @@ interface CaptureOptions {
|
|
|
28
28
|
timeoutSec?: number;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Coerce an arbitrary `options.scale` into the 1–3 preset range (default 2×).
|
|
33
|
+
* Exported for unit coverage of the item-1 default/clamp behaviour.
|
|
34
|
+
*/
|
|
35
|
+
export function clampScale(raw: unknown): 1 | 2 | 3 {
|
|
36
|
+
const n = Math.round(Number(raw));
|
|
37
|
+
if (n === 1) return 1;
|
|
38
|
+
if (n === 3) return 3;
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
async function captureElement(
|
|
32
43
|
target: Extract<Target, { kind: 'element' }>,
|
|
33
44
|
ctx: ExportContext,
|
|
@@ -48,7 +59,12 @@ async function captureElement(
|
|
|
48
59
|
if (target.multi) {
|
|
49
60
|
args.push('--multi', '1', '--out-dir', outDir);
|
|
50
61
|
} else {
|
|
51
|
-
|
|
62
|
+
// Only widen to the enclosing artboard when scope.ts asked for it
|
|
63
|
+
// (artboard-via-descendant fallback). `selection` scope sets widen=false so
|
|
64
|
+
// the capture is the element exactly; artboard-by-id targets the screen
|
|
65
|
+
// element directly and needs no widening.
|
|
66
|
+
if (target.widen) args.push('--widen-to-artboard', '1');
|
|
67
|
+
args.push('--out', path.join(outDir, `${target.canvasSlug}.png`));
|
|
52
68
|
}
|
|
53
69
|
const proc = Bun.spawn(['node', ...args], {
|
|
54
70
|
cwd: path.dirname(PNG_PLAYWRIGHT),
|
|
@@ -101,7 +117,10 @@ export async function run(
|
|
|
101
117
|
|
|
102
118
|
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-png-'));
|
|
103
119
|
const captureOpts: CaptureOptions = {
|
|
104
|
-
|
|
120
|
+
// Default 2× — a single-scale PNG was uselessly small (item 1). The dialog
|
|
121
|
+
// sends an explicit scale; this default covers direct API / curl callers.
|
|
122
|
+
// Clamped to the 1–3 preset range; the shim re-clamps deviceScaleFactor ≤ 4.
|
|
123
|
+
scale: clampScale(options.scale),
|
|
105
124
|
timeoutSec: (options.timeoutSec as number | undefined) ?? 8,
|
|
106
125
|
};
|
|
107
126
|
|