@1agh/maude 0.45.2 → 0.47.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 (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. package/plugins/design/templates/_shell.html +4 -0
@@ -8,13 +8,18 @@ import { describe, expect, test } from 'bun:test';
8
8
 
9
9
  import {
10
10
  applyDeleteElement,
11
+ applyDuplicateArtboard,
11
12
  applyInsertArtboard,
12
13
  applyInsertElement,
13
14
  applyInsertElementIntoArtboard,
14
15
  applyResizeArtboard,
16
+ applySetArtboardGuides,
15
17
  applySetArtboardHug,
18
+ applySetArtboardKind,
19
+ applySetArtboardPrint,
16
20
  applySetArtboardStyle,
17
21
  CanvasEditError,
22
+ readArtboardPrintProp,
18
23
  } from '../canvas-edit.ts';
19
24
  import { transpileCanvasSource } from '../canvas-pipeline.ts';
20
25
 
@@ -337,6 +342,309 @@ describe('canvas-edit / applySetArtboardStyle (background/padding/layout/gap)',
337
342
  CanvasEditError
338
343
  );
339
344
  });
345
+
346
+ // Dogfood (artboard panel ↔ shared inspector controls) — padding/gap accept
347
+ // a var(--token) STRING binding alongside raw px numbers.
348
+ test('padding/gap accept a var(--token) string (space-token binding)', () => {
349
+ const out = applySetArtboardStyle(CANVAS, canvas, 'home', {
350
+ padding: 'var(--space-4)',
351
+ gap: 'var(--space-2)',
352
+ });
353
+ expect(out.source).toContain('padding="var(--space-4)"');
354
+ expect(out.source).toContain('gap="var(--space-2)"');
355
+ expect(parses(out.source)).toBe(true);
356
+ });
357
+
358
+ test('a token binding round-trips back to a raw number (detach) and vice versa', () => {
359
+ const bound = applySetArtboardStyle(CANVAS, canvas, 'home', { padding: 'var(--space-4)' });
360
+ const detached = applySetArtboardStyle(CANVAS, bound.source, 'home', { padding: 24 });
361
+ expect(detached.source).toContain('padding={24}');
362
+ expect(detached.source).not.toContain('var(--space-4)');
363
+ const rebound = applySetArtboardStyle(CANVAS, detached.source, 'home', {
364
+ padding: 'var(--space-6)',
365
+ });
366
+ expect(rebound.source).toContain('padding="var(--space-6)"');
367
+ expect(rebound.source).not.toContain('padding={24}');
368
+ expect(parses(rebound.source)).toBe(true);
369
+ });
370
+ });
371
+
372
+ // feature-1-artboard-kinds-foundation, T5/T8 — kind-switch write lane.
373
+ describe('canvas-edit / applySetArtboardKind', () => {
374
+ const canvas = [
375
+ 'export default function Demo() {',
376
+ ' return (',
377
+ ' <DesignCanvas>',
378
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
379
+ ' <div>content</div>',
380
+ ' </DCArtboard>',
381
+ ' </DesignCanvas>',
382
+ ' );',
383
+ '}',
384
+ ].join('\n');
385
+
386
+ test('writes kind as a plain string-literal prop', () => {
387
+ const out = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
388
+ expect(out.source).toContain('kind="print"');
389
+ expect(parses(out.source)).toBe(true);
390
+ });
391
+
392
+ test('switching kind replaces the previous value, no duplicate attr', () => {
393
+ const printed = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
394
+ const out = applySetArtboardKind(CANVAS, printed.source, 'home', 'web');
395
+ expect(out.source).toContain('kind="web"');
396
+ expect(out.source).not.toContain('kind="print"');
397
+ expect(out.source.match(/\bkind=/g)?.length).toBe(1);
398
+ });
399
+
400
+ test('kind=null clears back to the implicit default', () => {
401
+ const printed = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
402
+ const out = applySetArtboardKind(CANVAS, printed.source, 'home', null);
403
+ expect(out.source).not.toContain('kind=');
404
+ expect(parses(out.source)).toBe(true);
405
+ });
406
+
407
+ test('an invalid kind value throws rather than writing garbage', () => {
408
+ expect(() => applySetArtboardKind(CANVAS, canvas, 'home', 'poster')).toThrow(CanvasEditError);
409
+ });
410
+
411
+ test('unknown artboard id throws', () => {
412
+ expect(() => applySetArtboardKind(CANVAS, canvas, 'nope', 'print')).toThrow(CanvasEditError);
413
+ });
414
+ });
415
+
416
+ // feature-1-artboard-kinds-foundation, T5 — generic layout guides write lane.
417
+ describe('canvas-edit / applySetArtboardGuides (replace-whole-prop)', () => {
418
+ const canvas = [
419
+ 'export default function Demo() {',
420
+ ' return (',
421
+ ' <DesignCanvas>',
422
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
423
+ ' <div>content</div>',
424
+ ' </DCArtboard>',
425
+ ' </DesignCanvas>',
426
+ ' );',
427
+ '}',
428
+ ].join('\n');
429
+
430
+ test('inserts guides as a {{...}} object-expression prop', () => {
431
+ const out = applySetArtboardGuides(CANVAS, canvas, 'home', {
432
+ columns: { count: 12, gutter: 24, margin: 80 },
433
+ });
434
+ expect(out.source).toContain('guides={{"columns":{"count":12,"gutter":24,"margin":80}}}');
435
+ expect(parses(out.source)).toBe(true);
436
+ });
437
+
438
+ test('a second write REPLACES the whole prop rather than merging', () => {
439
+ const first = applySetArtboardGuides(CANVAS, canvas, 'home', {
440
+ columns: { count: 12, gutter: 24, margin: 80 },
441
+ });
442
+ const out = applySetArtboardGuides(CANVAS, first.source, 'home', { grid: { size: 8 } });
443
+ expect(out.source).toContain('guides={{"grid":{"size":8}}}');
444
+ expect(out.source).not.toContain('columns');
445
+ expect(out.source.match(/\bguides=/g)?.length).toBe(1);
446
+ expect(parses(out.source)).toBe(true);
447
+ });
448
+
449
+ test('guides=null removes the prop', () => {
450
+ const written = applySetArtboardGuides(CANVAS, canvas, 'home', { grid: { size: 8 } });
451
+ const out = applySetArtboardGuides(CANVAS, written.source, 'home', null);
452
+ expect(out.source).not.toContain('guides=');
453
+ expect(parses(out.source)).toBe(true);
454
+ });
455
+
456
+ test('unknown artboard id throws', () => {
457
+ expect(() => applySetArtboardGuides(CANVAS, canvas, 'nope', { grid: { size: 8 } })).toThrow(
458
+ CanvasEditError
459
+ );
460
+ });
461
+ });
462
+
463
+ // feature-2-print-artboards T2 — print prop write lane (same replace-whole-
464
+ // prop shape as applySetArtboardGuides).
465
+ describe('canvas-edit / applySetArtboardPrint (replace-whole-prop)', () => {
466
+ const canvas = [
467
+ 'export default function Demo() {',
468
+ ' return (',
469
+ ' <DesignCanvas>',
470
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
471
+ ' <div>content</div>',
472
+ ' </DCArtboard>',
473
+ ' </DesignCanvas>',
474
+ ' );',
475
+ '}',
476
+ ].join('\n');
477
+
478
+ test('inserts print as a {{...}} object-expression prop', () => {
479
+ const out = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4', bleedMm: 3 });
480
+ expect(out.source).toContain('print={{"paper":"a4","bleedMm":3}}');
481
+ expect(parses(out.source)).toBe(true);
482
+ });
483
+
484
+ test('a second write REPLACES the whole prop rather than merging', () => {
485
+ const first = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4', bleedMm: 3 });
486
+ const out = applySetArtboardPrint(CANVAS, first.source, 'home', {
487
+ paper: 'letter',
488
+ orientation: 'landscape',
489
+ });
490
+ expect(out.source).toContain('print={{"paper":"letter","orientation":"landscape"}}');
491
+ expect(out.source).not.toContain('a4');
492
+ expect(out.source.match(/\bprint=/g)?.length).toBe(1);
493
+ expect(parses(out.source)).toBe(true);
494
+ });
495
+
496
+ test('print=null removes the prop', () => {
497
+ const written = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4' });
498
+ const out = applySetArtboardPrint(CANVAS, written.source, 'home', null);
499
+ expect(out.source).not.toContain('print=');
500
+ expect(parses(out.source)).toBe(true);
501
+ });
502
+
503
+ test('unknown artboard id throws', () => {
504
+ expect(() => applySetArtboardPrint(CANVAS, canvas, 'nope', { paper: 'a4' })).toThrow(
505
+ CanvasEditError
506
+ );
507
+ });
508
+ });
509
+
510
+ // feature-2-print-artboards T5 — the AST-only (no-eval) reader the PDF
511
+ // exporter uses to resolve an artboard's print geometry.
512
+ describe('canvas-edit / readArtboardPrintProp (no-eval AST read)', () => {
513
+ test('reads a JSON-shaped print prop on a kind="print" artboard (the picker\'s own write shape)', () => {
514
+ const canvas = [
515
+ 'export default function Demo() {',
516
+ ' return (',
517
+ ' <DesignCanvas>',
518
+ ' <DCArtboard id="home" label="Home" kind="print" width={816} height={1146} print={{"paper":"a4","bleedMm":3}}>',
519
+ ' <div>content</div>',
520
+ ' </DCArtboard>',
521
+ ' </DesignCanvas>',
522
+ ' );',
523
+ '}',
524
+ ].join('\n');
525
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toEqual({ paper: 'a4', bleedMm: 3 });
526
+ });
527
+
528
+ test('reads a hand-authored JS-literal print prop (unquoted keys, nested object)', () => {
529
+ const canvas = [
530
+ 'export default function Demo() {',
531
+ ' return (',
532
+ ' <DesignCanvas>',
533
+ ' <DCArtboard id="home" label="Home" kind="print" width={816} height={1146} print={{ paper: \'a4\', orientation: \'landscape\', marginsMm: { top: 10, left: 5 } }}>',
534
+ ' <div>content</div>',
535
+ ' </DCArtboard>',
536
+ ' </DesignCanvas>',
537
+ ' );',
538
+ '}',
539
+ ].join('\n');
540
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toEqual({
541
+ paper: 'a4',
542
+ orientation: 'landscape',
543
+ marginsMm: { top: 10, left: 5 },
544
+ });
545
+ });
546
+
547
+ test('no print prop → null', () => {
548
+ const canvas = [
549
+ 'export default function Demo() {',
550
+ ' return (',
551
+ ' <DesignCanvas>',
552
+ ' <DCArtboard id="home" label="Home" kind="print" width={1440} height={1024}>',
553
+ ' <div>content</div>',
554
+ ' </DCArtboard>',
555
+ ' </DesignCanvas>',
556
+ ' );',
557
+ '}',
558
+ ].join('\n');
559
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toBeNull();
560
+ });
561
+
562
+ test('unknown artboard id → null (not a throw — read-only, best-effort)', () => {
563
+ const canvas = [
564
+ 'export default function Demo() {',
565
+ ' return (',
566
+ ' <DesignCanvas>',
567
+ ' <DCArtboard id="home" label="Home" kind="print" width={1440} height={1024}>',
568
+ ' <div>content</div>',
569
+ ' </DCArtboard>',
570
+ ' </DesignCanvas>',
571
+ ' );',
572
+ '}',
573
+ ].join('\n');
574
+ expect(readArtboardPrintProp(CANVAS, canvas, 'nope')).toBeNull();
575
+ });
576
+
577
+ test('dogfood regression — a print prop present but kind NOT "print" → null (a stale prop from a prior kind switch must never leak into export)', () => {
578
+ const canvas = [
579
+ 'export default function Demo() {',
580
+ ' return (',
581
+ ' <DesignCanvas>',
582
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024} print={{"paper":"a4","bleedMm":3}}>',
583
+ ' <div>content</div>',
584
+ ' </DCArtboard>',
585
+ ' </DesignCanvas>',
586
+ ' );',
587
+ '}',
588
+ ].join('\n');
589
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toBeNull();
590
+ });
591
+
592
+ test('resolves a print prop shared via a top-level const reference (RCA issue-pdf-print-export-marks-missing)', () => {
593
+ const canvas = [
594
+ 'const A1_PRINT = { paper: "a1", orientation: "portrait", bleedMm: 3 } as const;',
595
+ 'export default function Demo() {',
596
+ ' return (',
597
+ ' <DesignCanvas>',
598
+ ' <DCArtboard id="acko-front" label="A" kind="print" width={816} height={1146} print={A1_PRINT}>',
599
+ ' <div>content</div>',
600
+ ' </DCArtboard>',
601
+ ' <DCArtboard id="acko-back" label="B" kind="print" width={816} height={1146} print={A1_PRINT as any}>',
602
+ ' <div>content</div>',
603
+ ' </DCArtboard>',
604
+ ' </DesignCanvas>',
605
+ ' );',
606
+ '}',
607
+ ].join('\n');
608
+ const expected = { paper: 'a1', orientation: 'portrait', bleedMm: 3 };
609
+ // Both the bare reference and the `as any`-cast reference (the shape
610
+ // AlligatorsAcko.tsx actually shipped, since a plain `print={A1_PRINT}`
611
+ // would fail TSX's own type check against the JSX prop's declared type)
612
+ // must resolve to the same object as an equivalent inline literal.
613
+ expect(readArtboardPrintProp(CANVAS, canvas, 'acko-front')).toEqual(expected);
614
+ expect(readArtboardPrintProp(CANVAS, canvas, 'acko-back')).toEqual(expected);
615
+ });
616
+
617
+ test('an unresolvable print reference (imported, not declared in this file) → null', () => {
618
+ const canvas = [
619
+ 'import { A1_PRINT } from "./print-specs.ts";',
620
+ 'export default function Demo() {',
621
+ ' return (',
622
+ ' <DesignCanvas>',
623
+ ' <DCArtboard id="home" label="Home" kind="print" width={816} height={1146} print={A1_PRINT}>',
624
+ ' <div>content</div>',
625
+ ' </DCArtboard>',
626
+ ' </DesignCanvas>',
627
+ ' );',
628
+ '}',
629
+ ].join('\n');
630
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toBeNull();
631
+ });
632
+
633
+ test('a genuinely computed print prop (function call) → null, never guessed', () => {
634
+ const canvas = [
635
+ 'function computePrint() { return { paper: "a4" }; }',
636
+ 'export default function Demo() {',
637
+ ' return (',
638
+ ' <DesignCanvas>',
639
+ ' <DCArtboard id="home" label="Home" kind="print" width={816} height={1146} print={computePrint()}>',
640
+ ' <div>content</div>',
641
+ ' </DCArtboard>',
642
+ ' </DesignCanvas>',
643
+ ' );',
644
+ '}',
645
+ ].join('\n');
646
+ expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toBeNull();
647
+ });
340
648
  });
341
649
 
342
650
  describe('canvas-edit / applyInsertArtboard (I4)', () => {
@@ -385,3 +693,58 @@ describe('canvas-edit / applyInsertArtboard (I4)', () => {
385
693
  ).toThrow(CanvasEditError);
386
694
  });
387
695
  });
696
+
697
+ describe('canvas-edit / applyDuplicateArtboard (feature-3-web-artboards T3)', () => {
698
+ const canvas = [
699
+ 'export default function Demo() {',
700
+ ' return (',
701
+ ' <DesignCanvas>',
702
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024} kind="web">',
703
+ ' <div>content</div>',
704
+ ' </DCArtboard>',
705
+ ' <DCArtboard id="other" label="Other" width={800} height={600}>',
706
+ ' <div>other</div>',
707
+ ' </DCArtboard>',
708
+ ' </DesignCanvas>',
709
+ ' );',
710
+ '}',
711
+ ].join('\n');
712
+
713
+ test('clones the artboard with a suffixed id/label + new width, right after the source', () => {
714
+ const out = applyDuplicateArtboard(CANVAS, canvas, 'home', 390);
715
+ expect(out.artboardId).toBe('home-390');
716
+ expect(out.source).toContain('id="home-390"');
717
+ expect(out.source).toContain('label="Home (390px)"');
718
+ expect(out.source).toContain('width={390}');
719
+ // Preserves kind + children verbatim.
720
+ expect(out.source).toContain('kind="web"');
721
+ const homeIdx = out.source.indexOf('id="home"');
722
+ const cloneIdx = out.source.indexOf('id="home-390"');
723
+ const otherIdx = out.source.indexOf('id="other"');
724
+ expect(cloneIdx).toBeGreaterThan(homeIdx);
725
+ // Lands right after the source — BEFORE the sibling that followed it — so
726
+ // it inherits the "beside the source" default-grid slot (DDR-027).
727
+ expect(cloneIdx).toBeLessThan(otherIdx);
728
+ expect((out.source.match(/<div>content<\/div>/g) ?? []).length).toBe(2);
729
+ expect(parses(out.source)).toBe(true);
730
+ });
731
+
732
+ test('de-dupes the new id when the same breakpoint is duplicated twice', () => {
733
+ const once = applyDuplicateArtboard(CANVAS, canvas, 'home', 390);
734
+ const twice = applyDuplicateArtboard(CANVAS, once.source, 'home', 390);
735
+ expect(twice.artboardId).toBe('home-390-2');
736
+ expect(twice.source).toContain('id="home-390-2"');
737
+ expect(parses(twice.source)).toBe(true);
738
+ });
739
+
740
+ test('height/other props are untouched — only id/label/width change', () => {
741
+ const out = applyDuplicateArtboard(CANVAS, canvas, 'home', 390);
742
+ // The clone keeps the SOURCE's height (390px is a width-only breakpoint
743
+ // change — height stays hug-driven per Design Decision 1).
744
+ expect(out.source).toContain('id="home-390" label="Home (390px)" width={390} height={1024}');
745
+ });
746
+
747
+ test('throws on an unknown artboard id', () => {
748
+ expect(() => applyDuplicateArtboard(CANVAS, canvas, 'nope', 390)).toThrow(CanvasEditError);
749
+ });
750
+ });
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
10
 
11
- import { clampScale, run } from '../../exporters/png.ts';
11
+ import { clampDpi, clampScale, resolveDeviceScale, run } from '../../exporters/png.ts';
12
12
 
13
13
  const CTX = {
14
14
  designRoot: '/tmp/.design',
@@ -53,3 +53,51 @@ describe('png clampScale — size presets (item 1)', () => {
53
53
  expect(clampScale(1.2)).toBe(1);
54
54
  });
55
55
  });
56
+
57
+ describe('png clampDpi — feature-2-print-artboards T4', () => {
58
+ test('undefined/null → undefined (falls back to scale)', () => {
59
+ expect(clampDpi(undefined)).toBeUndefined();
60
+ expect(clampDpi(null)).toBeUndefined();
61
+ });
62
+
63
+ test('non-numeric → undefined', () => {
64
+ expect(clampDpi('nonsense')).toBeUndefined();
65
+ });
66
+
67
+ test('exact presets pass through', () => {
68
+ expect(clampDpi(96)).toBe(96);
69
+ expect(clampDpi(150)).toBe(150);
70
+ expect(clampDpi(300)).toBe(300);
71
+ expect(clampDpi(600)).toBe(600);
72
+ });
73
+
74
+ test('snaps to the nearest preset', () => {
75
+ expect(clampDpi(200)).toBe(150);
76
+ expect(clampDpi(250)).toBe(300);
77
+ expect(clampDpi(1000)).toBe(600);
78
+ expect(clampDpi(0)).toBe(96);
79
+ });
80
+ });
81
+
82
+ describe('png resolveDeviceScale — dpi wins over scale', () => {
83
+ test('dpi=300 → 3.125× (300/96)', () => {
84
+ expect(resolveDeviceScale({ dpi: 300 })).toBeCloseTo(300 / 96, 10);
85
+ });
86
+
87
+ test('dpi=600 → 6.25× (300dpi ceiling raise, item T4)', () => {
88
+ expect(resolveDeviceScale({ dpi: 600 })).toBeCloseTo(600 / 96, 10);
89
+ });
90
+
91
+ test('dpi=96 → 1× (no-op)', () => {
92
+ expect(resolveDeviceScale({ dpi: 96 })).toBeCloseTo(1, 10);
93
+ });
94
+
95
+ test('no dpi → falls back to the legacy scale preset', () => {
96
+ expect(resolveDeviceScale({ scale: 3 })).toBe(3);
97
+ expect(resolveDeviceScale({})).toBe(2); // clampScale default
98
+ });
99
+
100
+ test('dpi present but not a number → falls back to scale', () => {
101
+ expect(resolveDeviceScale({ dpi: 'nope', scale: 1 })).toBe(1);
102
+ });
103
+ });
@@ -0,0 +1,160 @@
1
+ // grid-track-handles.ts — feature-3-web-artboards T5 (absorbed
2
+ // feature-grid-track-editor stub). Pure geometry for the on-canvas grid
3
+ // gutter drag-resize overlay: track list parse/serialize (fr round-trip),
4
+ // gutter placement, and per-unit drag math.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ computeGutterLines,
10
+ computeTrackDrag,
11
+ type GridTrack,
12
+ gutterTrackIndices,
13
+ parseTrackList,
14
+ serializeTrackList,
15
+ } from '../grid-track-handles.ts';
16
+
17
+ describe('grid-track-handles / parseTrackList', () => {
18
+ test('parses numeric tracks with mixed units', () => {
19
+ expect(parseTrackList('200px 1fr 50%')).toEqual([
20
+ { value: 200, unit: 'px' },
21
+ { value: 1, unit: 'fr' },
22
+ { value: 50, unit: '%' },
23
+ ]);
24
+ });
25
+
26
+ test('parses keyword tracks', () => {
27
+ expect(parseTrackList('auto min-content max-content')).toEqual([
28
+ { value: 0, unit: 'auto' },
29
+ { value: 0, unit: 'min-content' },
30
+ { value: 0, unit: 'max-content' },
31
+ ]);
32
+ });
33
+
34
+ test('mixed numeric + keyword tracks', () => {
35
+ expect(parseTrackList('120px auto 2fr')).toEqual([
36
+ { value: 120, unit: 'px' },
37
+ { value: 0, unit: 'auto' },
38
+ { value: 2, unit: 'fr' },
39
+ ]);
40
+ });
41
+
42
+ test('empty/whitespace-only string parses to []', () => {
43
+ expect(parseTrackList('')).toEqual([]);
44
+ expect(parseTrackList(' ')).toEqual([]);
45
+ expect(parseTrackList(null)).toEqual([]);
46
+ expect(parseTrackList(undefined)).toEqual([]);
47
+ });
48
+
49
+ test('unparseable tokens (repeat()/minmax()) bail to []', () => {
50
+ expect(parseTrackList('repeat(3, 1fr)')).toEqual([]);
51
+ expect(parseTrackList('minmax(100px, 1fr) 1fr')).toEqual([]);
52
+ });
53
+
54
+ test('a malformed multi-dot number (security-auditor correctness finding) bails to [] rather than NaN', () => {
55
+ expect(parseTrackList('1.2.3px')).toEqual([]);
56
+ expect(parseTrackList('200px 1.2.3fr')).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe('grid-track-handles / serializeTrackList', () => {
61
+ test('round-trips a mixed track list, including fr', () => {
62
+ const tracks: GridTrack[] = [
63
+ { value: 200, unit: 'px' },
64
+ { value: 1, unit: 'fr' },
65
+ { value: 2.5, unit: 'fr' },
66
+ ];
67
+ expect(serializeTrackList(tracks)).toBe('200px 1fr 2.5fr');
68
+ expect(parseTrackList(serializeTrackList(tracks))).toEqual(tracks);
69
+ });
70
+
71
+ test('keyword tracks serialize without a unit suffix', () => {
72
+ expect(
73
+ serializeTrackList([
74
+ { value: 0, unit: 'auto' },
75
+ { value: 100, unit: 'px' },
76
+ ])
77
+ ).toBe('auto 100px');
78
+ });
79
+ });
80
+
81
+ describe('grid-track-handles / computeGutterLines', () => {
82
+ const RECT = { x: 100, y: 200, w: 300, h: 150 };
83
+
84
+ test('fewer than 2 tracks → no gutters', () => {
85
+ expect(computeGutterLines(RECT, [], 0, 'col')).toEqual([]);
86
+ expect(computeGutterLines(RECT, [300], 0, 'col')).toEqual([]);
87
+ });
88
+
89
+ test('places gutters at cumulative track boundaries, column axis, no gap', () => {
90
+ const lines = computeGutterLines(RECT, [100, 100, 100], 0, 'col');
91
+ expect(lines).toEqual([
92
+ { index: 0, axis: 'x', x: 200, y: 275 }, // rect.x + 100
93
+ { index: 1, axis: 'x', x: 300, y: 275 }, // rect.x + 200
94
+ ]);
95
+ });
96
+
97
+ test('accounts for gap when placing the gutter at the gap midpoint', () => {
98
+ const lines = computeGutterLines(RECT, [100, 100], 20, 'col');
99
+ // First track ends at x=200; gutter sits at the 20px gap's midpoint (210).
100
+ expect(lines[0]).toEqual({ index: 0, axis: 'x', x: 210, y: 275 });
101
+ });
102
+
103
+ test('row axis places gutters vertically', () => {
104
+ const lines = computeGutterLines(RECT, [50, 50], 0, 'row');
105
+ expect(lines).toEqual([{ index: 0, axis: 'y', x: 250, y: 250 }]); // rect.y + 50
106
+ });
107
+ });
108
+
109
+ describe('grid-track-handles / computeTrackDrag', () => {
110
+ test('px is additive (1 world px = 1 unit), like padding drag', () => {
111
+ expect(computeTrackDrag({ value: 100, unit: 'px' }, 100, 20, 0, 1, 'x')).toBe(120);
112
+ expect(computeTrackDrag({ value: 100, unit: 'px' }, 100, -30, 0, 1, 'x')).toBe(70);
113
+ });
114
+
115
+ test('px clamps at 0', () => {
116
+ expect(computeTrackDrag({ value: 10, unit: 'px' }, 10, -100, 0, 1, 'x')).toBe(0);
117
+ });
118
+
119
+ test('em uses the same additive math as px', () => {
120
+ expect(computeTrackDrag({ value: 5, unit: 'em' }, 80, 16, 0, 1, 'x')).toBe(21);
121
+ });
122
+
123
+ test('fr scales proportionally from the resolved-px ratio', () => {
124
+ // Track authored as 1fr currently resolves to 100px; dragging +50 screen
125
+ // px targets 150px → ratio 1.5 → new fr = 1.5.
126
+ expect(computeTrackDrag({ value: 1, unit: 'fr' }, 100, 50, 0, 1, 'x')).toBe(1.5);
127
+ });
128
+
129
+ test('fr floor prevents collapsing to zero share', () => {
130
+ expect(computeTrackDrag({ value: 1, unit: 'fr' }, 100, -1000, 0, 1, 'x')).toBe(0.1);
131
+ });
132
+
133
+ test('percent scales proportionally with a 1% floor', () => {
134
+ expect(computeTrackDrag({ value: 50, unit: '%' }, 150, 30, 0, 1, 'x')).toBe(60);
135
+ expect(computeTrackDrag({ value: 50, unit: '%' }, 150, -1000, 0, 1, 'x')).toBe(1);
136
+ });
137
+
138
+ test('keyword units are returned unchanged (not draggable)', () => {
139
+ expect(computeTrackDrag({ value: 0, unit: 'auto' }, 100, 50, 0, 1, 'x')).toBe(0);
140
+ });
141
+
142
+ test('divides screen delta by zoom (world units)', () => {
143
+ expect(computeTrackDrag({ value: 100, unit: 'px' }, 100, 40, 0, 2, 'x')).toBe(120); // 40/2=20
144
+ });
145
+
146
+ test('row axis reads dyScreen instead of dxScreen', () => {
147
+ expect(computeTrackDrag({ value: 100, unit: 'px' }, 100, 999, 20, 1, 'y')).toBe(120);
148
+ });
149
+ });
150
+
151
+ describe('grid-track-handles / gutterTrackIndices', () => {
152
+ test('plain drag touches only the track before the gutter', () => {
153
+ expect(gutterTrackIndices(0, false)).toEqual([0]);
154
+ expect(gutterTrackIndices(2, false)).toEqual([2]);
155
+ });
156
+ test('shift touches both neighboring tracks', () => {
157
+ expect(gutterTrackIndices(0, true)).toEqual([0, 1]);
158
+ expect(gutterTrackIndices(2, true)).toEqual([2, 3]);
159
+ });
160
+ });
@@ -15,6 +15,7 @@ import {
15
15
  filterComponentsCss,
16
16
  filterTokensCss,
17
17
  type RegistryItem,
18
+ resolveCanvasKind,
18
19
  stripDataCdId,
19
20
  writeRegistryItem,
20
21
  } from '../handoff.ts';
@@ -186,6 +187,34 @@ describe('handoff / filterTokensCss', () => {
186
187
  });
187
188
  });
188
189
 
190
+ describe('handoff / resolveCanvasKind', () => {
191
+ test('no explicit kind resolves to digital', () => {
192
+ const src = `<DesignCanvas><DCArtboard id="a" label="A" width={1440} height={900}>x</DCArtboard></DesignCanvas>`;
193
+ expect(resolveCanvasKind(src)).toBe('digital');
194
+ });
195
+
196
+ test('single explicit kind resolves to that kind', () => {
197
+ const src = `<DCArtboard id="a" label="A" width={1280} height={800} kind="web">x</DCArtboard>`;
198
+ expect(resolveCanvasKind(src)).toBe('web');
199
+ });
200
+
201
+ test('multiple distinct kinds resolve to mixed', () => {
202
+ const src = `
203
+ <DCArtboard id="a" label="A" width={1280} height={800} kind="web">x</DCArtboard>
204
+ <DCArtboard id="b" label="B" width={2480} height={3508} kind="print">y</DCArtboard>
205
+ `;
206
+ expect(resolveCanvasKind(src)).toBe('mixed');
207
+ });
208
+
209
+ test('repeated identical kind across artboards is not mixed', () => {
210
+ const src = `
211
+ <DCArtboard id="a" label="A" width={1440} height={900} kind="web">x</DCArtboard>
212
+ <DCArtboard id="b" label="B" width={390} height={844} kind="web">y</DCArtboard>
213
+ `;
214
+ expect(resolveCanvasKind(src)).toBe('web');
215
+ });
216
+ });
217
+
189
218
  describe('handoff / emitRegistryItem end-to-end', () => {
190
219
  let tmpDir = '';
191
220
  beforeAll(() => {
@@ -278,4 +307,23 @@ describe('handoff / emitRegistryItem end-to-end', () => {
278
307
  expect(item.files[0]?.type).toBe('registry:component');
279
308
  expect(item.cssVars).toBeUndefined();
280
309
  });
310
+
311
+ test('emit resolves meta.kind from the canvas source (feature-3-web-artboards T6)', async () => {
312
+ const canvasAbs = path.join(tmpDir, 'WebCanvas.tsx');
313
+ await Bun.write(
314
+ canvasAbs,
315
+ `export default function WebCanvas() {
316
+ return <DCArtboard id="a" label="A" width={1280} height={800} kind="web"><div>x</div></DCArtboard>;
317
+ }`
318
+ );
319
+ const item = await emitRegistryItem({ canvasAbsPath: canvasAbs });
320
+ expect(item.meta?.kind).toBe('web');
321
+ });
322
+
323
+ test('emit defaults meta.kind to digital when no artboard declares one', async () => {
324
+ const canvasAbs = path.join(tmpDir, 'DigitalCanvas.tsx');
325
+ await Bun.write(canvasAbs, 'export default function DigitalCanvas() { return <div>x</div>; }');
326
+ const item = await emitRegistryItem({ canvasAbsPath: canvasAbs });
327
+ expect(item.meta?.kind).toBe('digital');
328
+ });
281
329
  });