@1agh/maude 0.25.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.
Files changed (36) hide show
  1. package/cli/commands/design.mjs +5 -0
  2. package/cli/lib/design-link.mjs +13 -6
  3. package/cli/lib/gitignore-block.mjs +1 -0
  4. package/cli/lib/gitignore-block.test.mjs +1 -0
  5. package/package.json +8 -8
  6. package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
  7. package/plugins/design/dev-server/bin/draw-build.sh +48 -0
  8. package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
  9. package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
  10. package/plugins/design/dev-server/canvas-lib.tsx +110 -0
  11. package/plugins/design/dev-server/config.schema.json +10 -0
  12. package/plugins/design/dev-server/context.ts +9 -0
  13. package/plugins/design/dev-server/dist/client.bundle.js +3 -3
  14. package/plugins/design/dev-server/draw/brush.ts +639 -0
  15. package/plugins/design/dev-server/draw/composition.ts +229 -0
  16. package/plugins/design/dev-server/draw/geometry.ts +578 -0
  17. package/plugins/design/dev-server/draw/index.ts +28 -0
  18. package/plugins/design/dev-server/draw/layout.ts +260 -0
  19. package/plugins/design/dev-server/draw/optimize.ts +65 -0
  20. package/plugins/design/dev-server/draw/palette.ts +417 -0
  21. package/plugins/design/dev-server/draw/primitives.ts +643 -0
  22. package/plugins/design/dev-server/draw/serialize.ts +458 -0
  23. package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
  24. package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
  25. package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
  26. package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
  27. package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
  28. package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
  29. package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
  30. package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
  31. package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
  32. package/plugins/design/dev-server/sync/index.ts +73 -17
  33. package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
  34. package/plugins/design/dev-server/tsconfig.json +8 -1
  35. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
  36. 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 &amp; &lt;there&gt;');
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
+ });
@@ -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) per-canvas sync opt-in. A `.tsx` canvas is syncable only if its
620
- * sibling `<name>.meta.json` declares `"syncable": true`. The flag is set by a
621
- * human editing the sidecar; it is deliberately NOT in the untrusted
622
- * `/_api/canvas-meta` PATCH whitelist (api.ts), so a hostile canvas/hub cannot
623
- * flip its own body into the sync set. Missing sidecar / parse error → false.
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 readSyncableFlag(bodyAbs: string): boolean {
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
- return obj && typeof obj === 'object' && obj.syncable === true;
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
- return false;
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(abs, designRoot, commentsDir, designRel, acc, counter, splitActive);
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 the
660
- // sidecar opts in (see readSyncableFlag + the splitActive coupling above).
661
- // Otherwise tally it for 9.1-D's loud zero-syncable surface so the message
662
- // can explain *why* it isn't syncing.
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 && readSyncableFlag(abs))) {
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 only when the canvas opts in (.meta.json "syncable": true). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
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
+ }
@@ -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": ["*.ts", "client/**/*.ts", "client/**/*.tsx", "client/**/*.jsx", "test/**/*.ts"],
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
  }
@@ -153,10 +153,25 @@ Every `signature_treatment_options[i]` in the payload MUST set `family` to one o
153
153
  | `hard-edges` | Radii collapse to `0/2/4`; shadows removed except focus ring; borders bumped to `--border-strong`; thicker outlines on key elements. Brutalist / no-nonsense. |
154
154
  | `depth-stretch` | Shadow ladder stretched — `--shadow-md/lg/xl` get longer offsets and softer blurs; cards float higher; hover state lifts more. Soft-floaty depth. |
155
155
  | `inset-recess` | Tokens add `--shadow-inset-sm/md`; inputs + toggles use inset-shadow surfaces; never inside chrome with text on it (accessibility). Hardware-toggle / recessed feel. |
156
+ | `chromatic-blocks` | Multiple `--accent-*` filled surfaces used as **structural blocks** (Memphis / Canva / Figma-Config); bold high-chroma colour fields where **colour carries the hierarchy**, not just an accent dot. Requires `accentStrategy ≥ chromatic-3` (the scaffold emits N accent families). Fills use real accent/surface tokens — never a decorative-backdrop token as a product fill (D-5). For `expressive`/`maximalist` ambition. |
157
+ | `gradient-mesh` | Body bg = soft multi-stop **mesh / aurora** gradient (Figma / Stripe-marketing); tokens add a single-role `--mesh-*` backdrop family (backdrop ONLY, per D-5); cards stay accent-tinted, not mesh-filled. Honours `prefers-reduced-motion` (no animated mesh under reduce). For `expressive`/`maximalist` ambition; overridden to a solid translucent fill when Q10 hard-NOs include "no gradients". |
156
158
  | `none` | No body-level treatment; `_layout.css` stays minimal; chrome reads flat. The opt-out value. |
157
159
 
158
160
  The agent may also propose treatments outside this catalog — when it does, it must either map them to the closest family OR flag in `research_quality_notes` that a new family ID is needed (which then becomes a spec-change conversation, not a silent extension).
159
161
 
162
+ ### Aesthetic ambition → structural knobs (anchor inference — DDR-073)
163
+
164
+ `aesthetic_ambition` is the **inferred anchor** for the whole DS (the `ux-research-agent` reads it from brand character — Probe A lineage + Probe B Zrcadlo+Charakter — NOT a picker). It is emitted BEFORE the per-knob structural decisions, and those decisions **derive from it** instead of each defaulting conservative. The scaffold writes the inferred value into `config.json.aestheticAmbition`, which also sets the default `opt_out_scope` for canvases under this DS (so expressiveness is a DS property, not a per-canvas flag).
165
+
166
+ | Pole | `accentStrategy` | shadow / decor | favoured Q9 families | default `opt_out_scope` |
167
+ |---|---|---|---|---|
168
+ | `restrained` | `single` | soft / none | `none`, `chrome-glow`, `inset-recess`, `hard-edges` | `palette` |
169
+ | `confident` | `single \| paired` | soft, +1 chromatic-surface tolerance | `chrome-glow`, `depth-stretch`, `frosted-blur` | `palette` |
170
+ | `expressive` | `paired \| chromatic-3` | accent-tinted, gradients OK | `gradient-mesh`, `chromatic-blocks`, `frosted-blur` | `aesthetic` |
171
+ | `maximalist` | `chromatic-N` | bold, colour-as-structure | `chromatic-blocks`, `gradient-mesh`, `body-pattern` | `full` |
172
+
173
+ **Anti-funnel invariant:** absence of a clear brand-character signal does NOT mean `restrained` — it means low confidence, so Stage 3 ASKS across the full scale (incl. a multi-colour palette option). High-confidence skip is legitimate only when the character is unambiguous, at **both** ends of the scale. Q10 hard-NOs still override (e.g. "no gradients" disables `gradient-mesh`).
174
+
160
175
  ### Q10 hard NOs — sub-agent guardrails
161
176
 
162
177
  | Q | Answer source | Effect on scaffold |
@@ -19,6 +19,7 @@
19
19
  "activeFamilies": {{active_families}},
20
20
  "accentStrategy": "{{accent_strategy}}",
21
21
  "colorSpace": "{{color_space}}",
22
+ "aestheticAmbition": "{{aesthetic_ambition}}",
22
23
  "designSystems": [
23
24
  {
24
25
  "name": "{{ds_dirname}}",