duaer-spec 0.27.0 → 0.27.2

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 (72) hide show
  1. package/.claude/rules/agents-workflow.md +2 -0
  2. package/.claude/rules/duaer-spec.md +1 -0
  3. package/.cursor/rules/agents-workflow.mdc +2 -0
  4. package/.cursor/rules/duaer-spec.mdc +1 -0
  5. package/.duaer/active-job.json +3 -3
  6. package/.duaer/specs/270-patch-version/delivery.json +7 -0
  7. package/.duaer/specs/270-patch-version/spec.md +19 -0
  8. package/.duaer/specs/270-patch-version/tasks.md +4 -0
  9. package/.duaer/specs/271-fde04-compat/delivery.json +9 -0
  10. package/.duaer/specs/271-fde04-compat/spec.md +25 -0
  11. package/.duaer/specs/271-fde04-compat/tasks.md +5 -0
  12. package/.duaer/specs/272-fde03-env-check/delivery.json +9 -0
  13. package/.duaer/specs/272-fde03-env-check/spec.md +34 -0
  14. package/.duaer/specs/272-fde03-env-check/tasks.md +5 -0
  15. package/.duaer/specs/273-fde06-data-precheck/delivery.json +9 -0
  16. package/.duaer/specs/273-fde06-data-precheck/spec.md +37 -0
  17. package/.duaer/specs/273-fde06-data-precheck/tasks.md +5 -0
  18. package/.duaer/specs/274-fde05-external-deps/delivery.json +9 -0
  19. package/.duaer/specs/274-fde05-external-deps/spec.md +37 -0
  20. package/.duaer/specs/274-fde05-external-deps/tasks.md +5 -0
  21. package/.duaer/specs/275-release-0271/delivery.json +9 -0
  22. package/.duaer/specs/275-release-0271/spec.md +13 -0
  23. package/.duaer/specs/275-release-0271/tasks.md +4 -0
  24. package/.duaer/specs/276-fde08-perf-budget/delivery.json +9 -0
  25. package/.duaer/specs/276-fde08-perf-budget/spec.md +38 -0
  26. package/.duaer/specs/276-fde08-perf-budget/tasks.md +5 -0
  27. package/.duaer/specs/277-project-dir-reuse/delivery.json +9 -0
  28. package/.duaer/specs/277-project-dir-reuse/spec.md +31 -0
  29. package/.duaer/specs/277-project-dir-reuse/tasks.md +5 -0
  30. package/.duaer/specs/278-project-create-unique/delivery.json +9 -0
  31. package/.duaer/specs/278-project-create-unique/spec.md +27 -0
  32. package/.duaer/specs/278-project-create-unique/tasks.md +5 -0
  33. package/.duaer/specs/279-arch-side-detour/delivery.json +8 -0
  34. package/.duaer/specs/279-arch-side-detour/spec.md +28 -0
  35. package/.duaer/specs/279-arch-side-detour/tasks.md +5 -0
  36. package/.duaer/specs/280-arch-bottom-via-label/delivery.json +8 -0
  37. package/.duaer/specs/280-arch-bottom-via-label/spec.md +22 -0
  38. package/.duaer/specs/280-arch-bottom-via-label/tasks.md +5 -0
  39. package/.duaer/specs/281-release-0272/spec.md +13 -0
  40. package/.duaer/specs/281-release-0272/tasks.md +4 -0
  41. package/AGENTS.md +5 -2
  42. package/CHANGELOG.md +69 -0
  43. package/bin/duaer-live.mjs +202 -23
  44. package/bin/live-archify.mjs +382 -26
  45. package/bin/live-deliverables.mjs +16 -0
  46. package/bin/live-modules.mjs +81 -1
  47. package/bin/live-project-chat.mjs +8 -0
  48. package/bin/live-repo-path.mjs +91 -2
  49. package/docs/agent/branching-and-release.md +16 -0
  50. package/docs/agent/e2e-test-plan.md +10 -0
  51. package/docs/agent/live-desk.md +34 -4
  52. package/docs/agent/workflow.md +2 -0
  53. package/package.json +1 -1
  54. package/web/live-dev/app.js +95 -2
  55. package/web/live-dev/baseline.mjs +4 -0
  56. package/web/live-dev/compat-matrix.mjs +38 -0
  57. package/web/live-dev/confirm-card.mjs +4 -0
  58. package/web/live-dev/data-precheck.mjs +35 -0
  59. package/web/live-dev/env-check.mjs +41 -0
  60. package/web/live-dev/external-deps.mjs +35 -0
  61. package/web/live-dev/i18n-de.js +25 -10
  62. package/web/live-dev/i18n-es.js +25 -10
  63. package/web/live-dev/i18n-fr.js +25 -10
  64. package/web/live-dev/i18n-ja.js +25 -10
  65. package/web/live-dev/i18n-ko.js +25 -10
  66. package/web/live-dev/i18n-pt-BR.js +25 -10
  67. package/web/live-dev/i18n-ru.js +25 -10
  68. package/web/live-dev/i18n-vi.js +25 -10
  69. package/web/live-dev/i18n-zh-TW.js +25 -10
  70. package/web/live-dev/i18n.js +50 -20
  71. package/web/live-dev/index.html +33 -9
  72. package/web/live-dev/perf-budget.mjs +38 -0
@@ -355,17 +355,19 @@ export function repairArchitectureGeometry(ir) {
355
355
  e.label = truncateToUnits(String(e.label).trim(), MAX_EDGE_LABEL_UNITS);
356
356
  if (!e.label) delete e.label;
357
357
  }
358
- if (e.label && e.labelAt == null) {
358
+ if (e.label && e.labelAt == null && e.labelDx == null && e.labelDy == null) {
359
359
  // Pull labels above the edge so they clear horizontally adjacent boxes.
360
- e.labelDy = -28;
360
+ e.labelDy = -36;
361
361
  }
362
362
  }
363
363
  return ir;
364
364
  }
365
365
 
366
366
  /**
367
- * Detour long same-row edges that would pass through unrelated components.
368
- * Archify clean-flow rejects those as edge-through-node.
367
+ * Detour edges that would pass through unrelated components.
368
+ * Same-row long edges prefer a bottom/top via under/over the floor.
369
+ * Diagonal / stacked edges pick a corridor whose segments stay clear
370
+ * of unrelated boxes (Archify clean-flow edge-through-node).
369
371
  * @param {object} ir
370
372
  */
371
373
  export function routeCrossingEdges(ir) {
@@ -375,11 +377,15 @@ export function routeCrossingEdges(ir) {
375
377
  if (components.length < 3 || !connections.length) return ir;
376
378
 
377
379
  const byId = new Map();
380
+ let maxRight = 0;
381
+ let minLeft = Infinity;
382
+ let floorY = 0;
383
+ let ceilY = Infinity;
378
384
  for (const c of components) {
379
385
  if (!c?.id || !hasFinitePos(c)) continue;
380
386
  const w = hasFiniteSize(c) ? c.size[0] : 140;
381
387
  const h = hasFiniteSize(c) ? c.size[1] : 64;
382
- byId.set(c.id, {
388
+ const box = {
383
389
  id: c.id,
384
390
  x: c.pos[0],
385
391
  y: c.pos[1],
@@ -389,48 +395,396 @@ export function routeCrossingEdges(ir) {
389
395
  cy: c.pos[1] + h / 2,
390
396
  bottom: c.pos[1] + h,
391
397
  top: c.pos[1],
392
- });
398
+ right: c.pos[0] + w,
399
+ left: c.pos[0],
400
+ };
401
+ byId.set(c.id, box);
402
+ maxRight = Math.max(maxRight, box.right);
403
+ minLeft = Math.min(minLeft, box.left);
404
+ floorY = Math.max(floorY, box.bottom);
405
+ ceilY = Math.min(ceilY, box.top);
393
406
  }
394
407
  if (byId.size < 3) return ir;
395
408
 
396
- let floorY = 0;
397
- for (const b of byId.values()) floorY = Math.max(floorY, b.bottom);
409
+ const CLEAR = 8;
410
+
411
+ /** Axis-aligned segment vs box with clearance (Archify uses ~2px). */
412
+ function segHitsBox(x1, y1, x2, y2, box) {
413
+ const minX = Math.min(x1, x2);
414
+ const maxX = Math.max(x1, x2);
415
+ const minY = Math.min(y1, y2);
416
+ const maxY = Math.max(y1, y2);
417
+ return (
418
+ maxX >= box.left - CLEAR &&
419
+ minX <= box.right + CLEAR &&
420
+ maxY >= box.top - CLEAR &&
421
+ minY <= box.bottom + CLEAR
422
+ );
423
+ }
424
+
425
+ function pathClear(pts, skipA, skipB) {
426
+ for (let i = 0; i < pts.length - 1; i++) {
427
+ const [x1, y1] = pts[i];
428
+ const [x2, y2] = pts[i + 1];
429
+ for (const other of byId.values()) {
430
+ if (other.id === skipA || other.id === skipB) continue;
431
+ if (segHitsBox(x1, y1, x2, y2, other)) return false;
432
+ }
433
+ }
434
+ return true;
435
+ }
436
+
437
+ /** Port point on a box side. */
438
+ function port(box, side) {
439
+ switch (side) {
440
+ case "left":
441
+ return [box.left, box.cy];
442
+ case "right":
443
+ return [box.right, box.cy];
444
+ case "top":
445
+ return [box.cx, box.top];
446
+ case "bottom":
447
+ return [box.cx, box.bottom];
448
+ default:
449
+ return [box.cx, box.cy];
450
+ }
451
+ }
452
+
453
+ function tryRoute(a, b, fromSide, toSide, via) {
454
+ const pts = [port(a, fromSide), ...via, port(b, toSide)];
455
+ if (!pathClear(pts, a.id, b.id)) return null;
456
+ return { fromSide, toSide, via };
457
+ }
458
+
459
+ /** Vertical gutters between sorted unique column centers. */
460
+ function gutterXs() {
461
+ const cols = [...byId.values()]
462
+ .map((b) => b.cx)
463
+ .sort((x, y) => x - y);
464
+ const uniq = [];
465
+ for (const x of cols) {
466
+ if (!uniq.length || Math.abs(uniq[uniq.length - 1] - x) > 40) uniq.push(x);
467
+ }
468
+ const gutters = [Math.round(minLeft - 56), Math.round(maxRight + 56)];
469
+ for (let i = 0; i < uniq.length - 1; i++) {
470
+ const leftBoxes = [...byId.values()].filter((b) => Math.abs(b.cx - uniq[i]) < 48);
471
+ const rightBoxes = [...byId.values()].filter(
472
+ (b) => Math.abs(b.cx - uniq[i + 1]) < 48,
473
+ );
474
+ const rightEdge = Math.max(...leftBoxes.map((b) => b.right));
475
+ const leftEdge = Math.min(...rightBoxes.map((b) => b.left));
476
+ if (leftEdge - rightEdge > 40) {
477
+ gutters.push(Math.round((rightEdge + leftEdge) / 2));
478
+ }
479
+ }
480
+ return gutters;
481
+ }
482
+
398
483
  let detourIndex = 0;
399
484
 
400
485
  for (const e of connections) {
401
486
  if (!e || typeof e !== "object") continue;
402
- if (Array.isArray(e.via) && e.via.length) continue;
403
487
  const a = byId.get(e.from);
404
488
  const b = byId.get(e.to);
405
489
  if (!a || !b) continue;
406
- const span = Math.abs(a.cx - b.cx);
407
- if (span < 160) continue;
408
490
 
409
- const minX = Math.min(a.cx, b.cx);
410
- const maxX = Math.max(a.cx, b.cx);
411
- const bandTop = Math.min(a.top, b.top) - 12;
412
- const bandBot = Math.max(a.bottom, b.bottom) + 12;
491
+ const horizSpan = Math.abs(a.cx - b.cx);
492
+ const vertSpan = Math.abs(a.cy - b.cy);
493
+ if (horizSpan < 40 && vertSpan < 80) continue;
494
+
495
+ const boxMinX = Math.min(a.left, b.left);
496
+ const boxMaxX = Math.max(a.right, b.right);
497
+ const boxMinY = Math.min(a.top, b.top);
498
+ const boxMaxY = Math.max(a.bottom, b.bottom);
499
+
413
500
  let blocked = false;
414
501
  for (const other of byId.values()) {
415
502
  if (other.id === a.id || other.id === b.id) continue;
416
- if (other.x + other.w <= minX + 12 || other.x >= maxX - 12) continue;
417
- if (other.y < bandBot && other.y + other.h > bandTop) {
503
+ const inBox =
504
+ other.right > boxMinX + 8 &&
505
+ other.left < boxMaxX - 8 &&
506
+ other.bottom > boxMinY + 8 &&
507
+ other.top < boxMaxY - 8;
508
+ if (inBox) {
418
509
  blocked = true;
419
510
  break;
420
511
  }
421
512
  }
513
+
514
+ if (!blocked && Array.isArray(e.via) && e.via.length) {
515
+ const viaY = Number(e.via[0]?.[1]);
516
+ if (
517
+ Number.isFinite(viaY) &&
518
+ (e.fromSide === "bottom" || e.toSide === "bottom")
519
+ ) {
520
+ for (const other of byId.values()) {
521
+ if (other.id === a.id || other.id === b.id) continue;
522
+ const underA =
523
+ Math.abs(other.cx - a.cx) < 48 &&
524
+ other.top >= a.bottom - 4 &&
525
+ other.top < viaY;
526
+ const underB =
527
+ Math.abs(other.cx - b.cx) < 48 &&
528
+ other.top >= b.bottom - 4 &&
529
+ other.top < viaY;
530
+ if (underA || underB) {
531
+ blocked = true;
532
+ break;
533
+ }
534
+ }
535
+ }
536
+ }
537
+
422
538
  if (!blocked) continue;
423
539
 
424
- const viaY = floorY + 48 + detourIndex * 28;
540
+ const sameRow = vertSpan < 80 && horizSpan >= 160;
541
+ const candidates = [];
542
+
543
+ if (sameRow) {
544
+ const viaYBot = floorY + 48 + detourIndex * 28;
545
+ const viaYTop = ceilY - 48 - detourIndex * 28;
546
+ candidates.push(
547
+ tryRoute(a, b, "bottom", "bottom", [
548
+ [Math.round(a.cx), Math.round(viaYBot)],
549
+ [Math.round(b.cx), Math.round(viaYBot)],
550
+ ]),
551
+ );
552
+ candidates.push(
553
+ tryRoute(a, b, "top", "top", [
554
+ [Math.round(a.cx), Math.round(viaYTop)],
555
+ [Math.round(b.cx), Math.round(viaYTop)],
556
+ ]),
557
+ );
558
+ // Side corridors for same-row when floor/ceil are blocked
559
+ for (const side of ["right", "left"]) {
560
+ const viaX =
561
+ side === "right"
562
+ ? Math.round(maxRight + 56 + detourIndex * 36)
563
+ : Math.round(minLeft - 56 - detourIndex * 36);
564
+ candidates.push(
565
+ tryRoute(a, b, side, side, [
566
+ [viaX, Math.round(a.cy)],
567
+ [viaX, Math.round(b.cy)],
568
+ ]),
569
+ );
570
+ }
571
+ } else {
572
+ const gutters = gutterXs();
573
+ const ceil = ceilY - 48 - detourIndex * 20;
574
+ const floor = floorY + 48 + detourIndex * 20;
575
+ for (const gx of gutters) {
576
+ // Approach destination from the gutter side
577
+ const toSide = b.cx < gx ? "right" : "left";
578
+ const fromSide = a.cx < gx ? "right" : "left";
579
+ // Top wrap into gutter then into target
580
+ candidates.push(
581
+ tryRoute(a, b, "top", toSide, [
582
+ [Math.round(a.cx), Math.round(ceil)],
583
+ [Math.round(gx), Math.round(ceil)],
584
+ [Math.round(gx), Math.round(b.cy)],
585
+ ]),
586
+ );
587
+ candidates.push(
588
+ tryRoute(a, b, fromSide, toSide, [
589
+ [Math.round(gx), Math.round(a.cy)],
590
+ [Math.round(gx), Math.round(b.cy)],
591
+ ]),
592
+ );
593
+ candidates.push(
594
+ tryRoute(a, b, fromSide, "bottom", [
595
+ [Math.round(gx), Math.round(a.cy)],
596
+ [Math.round(gx), Math.round(floor)],
597
+ [Math.round(b.cx), Math.round(floor)],
598
+ ]),
599
+ );
600
+ candidates.push(
601
+ tryRoute(a, b, "bottom", toSide, [
602
+ [Math.round(a.cx), Math.round(floor)],
603
+ [Math.round(gx), Math.round(floor)],
604
+ [Math.round(gx), Math.round(b.cy)],
605
+ ]),
606
+ );
607
+ }
608
+ // Prefer right outer when mid is leftish (matches stacked fixtures)
609
+ const preferRight =
610
+ (a.cx + b.cx) / 2 <= (minLeft + maxRight) / 2 + (maxRight - minLeft) * 0.1;
611
+ const orderedSides = preferRight ? ["right", "left"] : ["left", "right"];
612
+ for (const side of orderedSides) {
613
+ const viaX =
614
+ side === "right"
615
+ ? Math.round(maxRight + 56 + detourIndex * 36)
616
+ : Math.round(minLeft - 56 - detourIndex * 36);
617
+ candidates.push(
618
+ tryRoute(a, b, side, side, [
619
+ [viaX, Math.round(a.cy)],
620
+ [viaX, Math.round(b.cy)],
621
+ ]),
622
+ );
623
+ }
624
+ }
625
+
626
+ const chosen = candidates.find(Boolean);
627
+ if (!chosen) continue;
628
+
425
629
  detourIndex += 1;
426
- e.fromSide = "bottom";
427
- e.toSide = "bottom";
428
- e.via = [
429
- [Math.round(a.cx), Math.round(viaY)],
430
- [Math.round(b.cx), Math.round(viaY)],
431
- ];
432
- if (e.label && e.labelAt == null && e.labelDy == null) {
433
- e.labelDy = -28;
630
+ e.fromSide = chosen.fromSide;
631
+ e.toSide = chosen.toSide;
632
+ e.via = chosen.via;
633
+ if (e.label && e.labelAt == null) {
634
+ // Final placement is repaired by repairEdgeLabelPlacement; leave
635
+ // bottom/top vias unmarked so that helper can pin labelAt on the via.
636
+ if (chosen.fromSide === "bottom" || chosen.toSide === "bottom") {
637
+ delete e.labelDx;
638
+ delete e.labelDy;
639
+ } else if (chosen.fromSide === "top" || chosen.toSide === "top") {
640
+ delete e.labelDx;
641
+ delete e.labelDy;
642
+ } else {
643
+ e.labelDx = chosen.fromSide === "right" ? -36 : 36;
644
+ delete e.labelDy;
645
+ }
646
+ }
647
+ }
648
+ return ir;
649
+ }
650
+
651
+ /**
652
+ * Keep edge labels off component boxes.
653
+ * Bottom vias must place the label *below* the via (labelDy > 0) — a negative
654
+ * dy pulls the label up into nodes that sit above the detour.
655
+ * Vertical stacked edges get labelAt in the inter-box gap.
656
+ * @param {object} ir
657
+ */
658
+ export function repairEdgeLabelPlacement(ir) {
659
+ if (!ir || typeof ir !== "object") return ir;
660
+ const components = Array.isArray(ir.components) ? ir.components : [];
661
+ const connections = Array.isArray(ir.connections) ? ir.connections : [];
662
+ const boxes = [];
663
+ const byId = new Map();
664
+ for (const c of components) {
665
+ if (!c?.id || !hasFinitePos(c)) continue;
666
+ const w = hasFiniteSize(c) ? c.size[0] : 140;
667
+ const h = hasFiniteSize(c) ? c.size[1] : 64;
668
+ const box = {
669
+ id: c.id,
670
+ cx: c.pos[0] + w / 2,
671
+ cy: c.pos[1] + h / 2,
672
+ left: c.pos[0],
673
+ right: c.pos[0] + w,
674
+ top: c.pos[1],
675
+ bottom: c.pos[1] + h,
676
+ };
677
+ byId.set(c.id, box);
678
+ boxes.push(box);
679
+ }
680
+
681
+ const LABEL_W = 36;
682
+ const LABEL_H = 16;
683
+
684
+ function overlapsAny(lx, ly) {
685
+ const left = lx - LABEL_W / 2;
686
+ const right = lx + LABEL_W / 2;
687
+ const top = ly - LABEL_H / 2;
688
+ const bottom = ly + LABEL_H / 2;
689
+ for (const box of boxes) {
690
+ if (
691
+ right > box.left &&
692
+ left < box.right &&
693
+ bottom > box.top &&
694
+ top < box.bottom
695
+ ) {
696
+ return box;
697
+ }
698
+ }
699
+ return null;
700
+ }
701
+
702
+ function edgeMid(e, a, b) {
703
+ if (Array.isArray(e.via) && e.via.length >= 2) {
704
+ const first = e.via[0];
705
+ const last = e.via[e.via.length - 1];
706
+ return [
707
+ (Number(first[0]) + Number(last[0])) / 2,
708
+ (Number(first[1]) + Number(last[1])) / 2,
709
+ ];
710
+ }
711
+ return [(a.cx + b.cx) / 2, (a.cy + b.cy) / 2];
712
+ }
713
+
714
+ for (const e of connections) {
715
+ if (!e?.label) continue;
716
+ const a = byId.get(e.from);
717
+ const b = byId.get(e.to);
718
+ if (!a || !b) continue;
719
+
720
+ const via = Array.isArray(e.via) ? e.via : null;
721
+ const bottomVia =
722
+ via &&
723
+ via.length >= 2 &&
724
+ (e.fromSide === "bottom" || e.toSide === "bottom");
725
+ const topVia =
726
+ via &&
727
+ via.length >= 2 &&
728
+ (e.fromSide === "top" || e.toSide === "top") &&
729
+ !bottomVia;
730
+
731
+ if (bottomVia) {
732
+ // Stay under the floor detour — never pull up into co-row / mid-row boxes
733
+ const midX = (Number(via[0][0]) + Number(via[via.length - 1][0])) / 2;
734
+ const viaY = Number(via[0][1]);
735
+ e.labelAt = [Math.round(midX), Math.round(viaY + 18)];
736
+ delete e.labelDx;
737
+ delete e.labelDy;
738
+ continue;
739
+ }
740
+ if (topVia) {
741
+ const midX = (Number(via[0][0]) + Number(via[via.length - 1][0])) / 2;
742
+ const viaY = Number(via[0][1]);
743
+ e.labelAt = [Math.round(midX), Math.round(viaY - 18)];
744
+ delete e.labelDx;
745
+ delete e.labelDy;
746
+ continue;
747
+ }
748
+
749
+ if (e.labelAt != null) {
750
+ const hit = overlapsAny(e.labelAt[0], e.labelAt[1]);
751
+ if (!hit) continue;
752
+ // Nudge below the overlapping box
753
+ e.labelAt = [Math.round(hit.cx), Math.round(hit.bottom + 18)];
754
+ continue;
755
+ }
756
+
757
+ const horizSpan = Math.abs(a.cx - b.cx);
758
+ const vertSpan = Math.abs(a.cy - b.cy);
759
+ if (vertSpan >= 60 && horizSpan < 80) {
760
+ const upper = a.cy <= b.cy ? a : b;
761
+ const lower = a.cy <= b.cy ? b : a;
762
+ const gapY = (upper.bottom + lower.top) / 2;
763
+ e.labelAt = [Math.round(a.cx + 56), Math.round(gapY)];
764
+ delete e.labelDx;
765
+ delete e.labelDy;
766
+ continue;
767
+ }
768
+
769
+ if (e.labelDy == null && e.labelDx == null) {
770
+ e.labelDy = -36;
771
+ }
772
+
773
+ // If the offset mid still lands on a box, pin labelAt clear of it
774
+ const [mx, my] = edgeMid(e, a, b);
775
+ const lx = mx + (Number(e.labelDx) || 0);
776
+ const ly = my + (Number(e.labelDy) || 0);
777
+ const hit = overlapsAny(lx, ly);
778
+ if (hit) {
779
+ const below = hit.bottom + 18;
780
+ const above = hit.top - 18;
781
+ const preferBelow = Math.abs(below - ly) <= Math.abs(above - ly);
782
+ e.labelAt = [
783
+ Math.round(hit.cx),
784
+ Math.round(preferBelow ? below : above),
785
+ ];
786
+ delete e.labelDx;
787
+ delete e.labelDy;
434
788
  }
435
789
  }
436
790
  return ir;
@@ -477,6 +831,7 @@ export function layoutArchitectureIr(raw) {
477
831
  }
478
832
  repairArchitectureGeometry(ir);
479
833
  routeCrossingEdges(ir);
834
+ repairEdgeLabelPlacement(ir);
480
835
  recomputeViewBox(ir);
481
836
  return ir;
482
837
  }
@@ -546,6 +901,7 @@ export function layoutArchitectureIr(raw) {
546
901
  }
547
902
  repairArchitectureGeometry(ir);
548
903
  routeCrossingEdges(ir);
904
+ repairEdgeLabelPlacement(ir);
549
905
  const maxX = originX + (layers.length || 1) * colW + 80;
550
906
  ir.meta.viewBox = [
551
907
  Math.max(320, maxX),
@@ -218,6 +218,10 @@ function cardBlock(card, labels) {
218
218
  [labels.paths, c.criticalPaths, "list"],
219
219
  [labels.exceptions, c.exceptionCases, "list"],
220
220
  [labels.apiContract, c.apiContract, "list"],
221
+ [labels.envChecklist, c.envChecklist, "list"],
222
+ [labels.dataPrecheck, c.dataPrecheck, "list"],
223
+ [labels.externalDeps, c.externalDeps, "list"],
224
+ [labels.perfBudget, c.perfBudget, "list"],
221
225
  ].filter(([, v]) => String(v || "").trim());
222
226
  if (!rows.length) return `<p class="empty">${esc(labels.emptyCard)}</p>`;
223
227
  return `<dl class="card-dl">${rows
@@ -578,6 +582,10 @@ function labelsFor(lang) {
578
582
  paths: "Critical paths",
579
583
  exceptions: "Exception cases",
580
584
  apiContract: "API contract",
585
+ envChecklist: "Environment checklist",
586
+ dataPrecheck: "Data precheck",
587
+ externalDeps: "External dependencies",
588
+ perfBudget: "Performance budget",
581
589
  baseline: "Baseline sign-off",
582
590
  statusDone: "Ready",
583
591
  statusPartial: "In progress",
@@ -614,6 +622,10 @@ function labelsFor(lang) {
614
622
  paths: "重要パス",
615
623
  exceptions: "異常系",
616
624
  apiContract: "API契約",
625
+ envChecklist: "環境チェック",
626
+ dataPrecheck: "データ事前検査",
627
+ externalDeps: "外部依存",
628
+ perfBudget: "性能予算",
617
629
  baseline: "ベースライン署名",
618
630
  statusDone: "完了",
619
631
  statusPartial: "進行中",
@@ -649,6 +661,10 @@ function labelsFor(lang) {
649
661
  paths: "关键路径",
650
662
  exceptions: "异常态",
651
663
  apiContract: "接口契约",
664
+ envChecklist: "环境检查",
665
+ dataPrecheck: "数据预检",
666
+ externalDeps: "外部依赖",
667
+ perfBudget: "性能预算",
652
668
  baseline: "基线签署",
653
669
  statusDone: "已产出",
654
670
  statusPartial: "进行中",
@@ -12,6 +12,23 @@ import {
12
12
  modulesNeedApiContractTasks,
13
13
  } from "../web/live-dev/api-contract.mjs";
14
14
  import { missingUatCases } from "../web/live-dev/uat-pack.mjs";
15
+ import { missingCompatMatrix } from "../web/live-dev/compat-matrix.mjs";
16
+ import {
17
+ envChecklistNeedsProbe,
18
+ missingEnvChecklist,
19
+ } from "../web/live-dev/env-check.mjs";
20
+ import {
21
+ dataPrecheckNeedsTask,
22
+ missingDataPrecheck,
23
+ } from "../web/live-dev/data-precheck.mjs";
24
+ import {
25
+ externalDepsNeedsTask,
26
+ missingExternalDeps,
27
+ } from "../web/live-dev/external-deps.mjs";
28
+ import {
29
+ missingPerfBudget,
30
+ perfBudgetNeedsTask,
31
+ } from "../web/live-dev/perf-budget.mjs";
15
32
 
16
33
  function clipCard(card) {
17
34
  if (!card || typeof card !== "object") {
@@ -24,6 +41,10 @@ function clipCard(card) {
24
41
  criticalPaths: "",
25
42
  exceptionCases: "",
26
43
  apiContract: "",
44
+ envChecklist: "",
45
+ dataPrecheck: "",
46
+ externalDeps: "",
47
+ perfBudget: "",
27
48
  };
28
49
  }
29
50
  return {
@@ -35,6 +56,10 @@ function clipCard(card) {
35
56
  criticalPaths: String(card.criticalPaths || "").slice(0, 4000),
36
57
  exceptionCases: String(card.exceptionCases || "").slice(0, 4000),
37
58
  apiContract: String(card.apiContract || "").slice(0, 4000),
59
+ envChecklist: String(card.envChecklist || "").slice(0, 4000),
60
+ dataPrecheck: String(card.dataPrecheck || "").slice(0, 4000),
61
+ externalDeps: String(card.externalDeps || "").slice(0, 4000),
62
+ perfBudget: String(card.perfBudget || "").slice(0, 4000),
38
63
  };
39
64
  }
40
65
 
@@ -372,10 +397,56 @@ export function buildTaskPoolFromModules(modules, { deployNeeded = false, deploy
372
397
 
373
398
  for (let i = 0; i < list.length; i += 1) {
374
399
  const m = list[i];
400
+ const envDeps = [];
401
+ if (envChecklistNeedsProbe(m.card?.envChecklist)) {
402
+ const envGap = missingEnvChecklist(m.card?.envChecklist);
403
+ const envNote = envGap.length ? `; still missing ${envGap.join("/")}` : "";
404
+ const env = push({
405
+ moduleId: m.id,
406
+ title: `FDE-03: env probe «${m.title}» — DNS, TLS, CORS, auth, third-party; block joint debug until green${envNote}`,
407
+ dependsOn: [],
408
+ role: EMPLOYEE_ROLES.VERIFY_L3,
409
+ });
410
+ envDeps.push(env.id);
411
+ }
412
+ let beforeImpl = envDeps;
413
+ if (dataPrecheckNeedsTask(m.card?.dataPrecheck)) {
414
+ const dataGap = missingDataPrecheck(m.card?.dataPrecheck);
415
+ const dataNote = dataGap.length ? `; still missing ${dataGap.join("/")}` : "";
416
+ const data = push({
417
+ moduleId: m.id,
418
+ title: `FDE-06: data precheck «${m.title}» — field mapping, import failure list, export for cleanup${dataNote}`,
419
+ dependsOn: envDeps,
420
+ role: EMPLOYEE_ROLES.VERIFY_L3,
421
+ });
422
+ beforeImpl = [data.id];
423
+ }
424
+ if (externalDepsNeedsTask(m.card?.externalDeps)) {
425
+ const extGap = missingExternalDeps(m.card?.externalDeps);
426
+ const extNote = extGap.length ? `; still missing ${extGap.join("/")}` : "";
427
+ const ext = push({
428
+ moduleId: m.id,
429
+ title: `FDE-05: external deps «${m.title}» — blocker, SLA, mock, parallel path${extNote}`,
430
+ dependsOn: beforeImpl,
431
+ role: EMPLOYEE_ROLES.VERIFY_L3,
432
+ });
433
+ beforeImpl = [ext.id];
434
+ }
435
+ if (perfBudgetNeedsTask(m.card?.perfBudget)) {
436
+ const perfGap = missingPerfBudget(m.card?.perfBudget);
437
+ const perfNote = perfGap.length ? `; still missing ${perfGap.join("/")}` : "";
438
+ const perf = push({
439
+ moduleId: m.id,
440
+ title: `FDE-08: perf budget «${m.title}» — LCP, INP, bundle, virtual list, weak net, large data${perfNote}`,
441
+ dependsOn: beforeImpl,
442
+ role: EMPLOYEE_ROLES.VERIFY_L3,
443
+ });
444
+ beforeImpl = [perf.id];
445
+ }
375
446
  const impl = push({
376
447
  moduleId: m.id,
377
448
  title: `Implement module «${m.title}»: ${truncate(m.card.goal, 100)}`,
378
- dependsOn: [],
449
+ dependsOn: beforeImpl,
379
450
  role: EMPLOYEE_ROLES.IMPLEMENT,
380
451
  });
381
452
  const acceptLines = splitAcceptanceLines(m.card.acceptance);
@@ -434,6 +505,15 @@ export function buildTaskPoolFromModules(modules, { deployNeeded = false, deploy
434
505
  role: EMPLOYEE_ROLES.VERIFY_L3,
435
506
  });
436
507
  uatIds.push(uat.id);
508
+ const compatGap = missingCompatMatrix(m.card?.deviceMatrix);
509
+ const compatNote = compatGap.length ? `; still missing ${compatGap.join("/")}` : "";
510
+ const compat = push({
511
+ moduleId: m.id,
512
+ title: `FDE-04: compat evidence «${m.title}» — named browsers, screenshot/cloud run, polyfill/fallback${compatNote}`,
513
+ dependsOn: [uat.id],
514
+ role: EMPLOYEE_ROLES.VERIFY_L3,
515
+ });
516
+ uatIds.push(compat.id);
437
517
  }
438
518
  let gate = uatIds.length ? uatIds : verifyDeps;
439
519
  if (modulesNeedApiContractTasks(list)) {
@@ -37,6 +37,10 @@ function clipCard(card) {
37
37
  criticalPaths: "",
38
38
  exceptionCases: "",
39
39
  apiContract: "",
40
+ envChecklist: "",
41
+ dataPrecheck: "",
42
+ externalDeps: "",
43
+ perfBudget: "",
40
44
  };
41
45
  }
42
46
  return {
@@ -48,6 +52,10 @@ function clipCard(card) {
48
52
  criticalPaths: String(card.criticalPaths || "").slice(0, 4000),
49
53
  exceptionCases: String(card.exceptionCases || "").slice(0, 4000),
50
54
  apiContract: String(card.apiContract || "").slice(0, 4000),
55
+ envChecklist: String(card.envChecklist || "").slice(0, 4000),
56
+ dataPrecheck: String(card.dataPrecheck || "").slice(0, 4000),
57
+ externalDeps: String(card.externalDeps || "").slice(0, 4000),
58
+ perfBudget: String(card.perfBudget || "").slice(0, 4000),
51
59
  };
52
60
  }
53
61