duaer-spec 0.27.1 → 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 (45) hide show
  1. package/.duaer/active-job.json +3 -3
  2. package/.duaer/specs/275-release-0271/delivery.json +9 -0
  3. package/.duaer/specs/275-release-0271/tasks.md +1 -1
  4. package/.duaer/specs/276-fde08-perf-budget/delivery.json +9 -0
  5. package/.duaer/specs/276-fde08-perf-budget/spec.md +38 -0
  6. package/.duaer/specs/276-fde08-perf-budget/tasks.md +5 -0
  7. package/.duaer/specs/277-project-dir-reuse/delivery.json +9 -0
  8. package/.duaer/specs/277-project-dir-reuse/spec.md +31 -0
  9. package/.duaer/specs/277-project-dir-reuse/tasks.md +5 -0
  10. package/.duaer/specs/278-project-create-unique/delivery.json +9 -0
  11. package/.duaer/specs/278-project-create-unique/spec.md +27 -0
  12. package/.duaer/specs/278-project-create-unique/tasks.md +5 -0
  13. package/.duaer/specs/279-arch-side-detour/delivery.json +8 -0
  14. package/.duaer/specs/279-arch-side-detour/spec.md +28 -0
  15. package/.duaer/specs/279-arch-side-detour/tasks.md +5 -0
  16. package/.duaer/specs/280-arch-bottom-via-label/delivery.json +8 -0
  17. package/.duaer/specs/280-arch-bottom-via-label/spec.md +22 -0
  18. package/.duaer/specs/280-arch-bottom-via-label/tasks.md +5 -0
  19. package/.duaer/specs/281-release-0272/spec.md +13 -0
  20. package/.duaer/specs/281-release-0272/tasks.md +4 -0
  21. package/CHANGELOG.md +34 -0
  22. package/bin/duaer-live.mjs +93 -19
  23. package/bin/live-archify.mjs +382 -26
  24. package/bin/live-deliverables.mjs +4 -0
  25. package/bin/live-modules.mjs +17 -0
  26. package/bin/live-project-chat.mjs +2 -0
  27. package/bin/live-repo-path.mjs +91 -2
  28. package/docs/agent/e2e-test-plan.md +5 -0
  29. package/docs/agent/live-desk.md +15 -3
  30. package/package.json +1 -1
  31. package/web/live-dev/app.js +28 -2
  32. package/web/live-dev/baseline.mjs +1 -0
  33. package/web/live-dev/confirm-card.mjs +1 -0
  34. package/web/live-dev/i18n-de.js +14 -8
  35. package/web/live-dev/i18n-es.js +14 -8
  36. package/web/live-dev/i18n-fr.js +14 -8
  37. package/web/live-dev/i18n-ja.js +14 -8
  38. package/web/live-dev/i18n-ko.js +14 -8
  39. package/web/live-dev/i18n-pt-BR.js +14 -8
  40. package/web/live-dev/i18n-ru.js +14 -8
  41. package/web/live-dev/i18n-vi.js +14 -8
  42. package/web/live-dev/i18n-zh-TW.js +14 -8
  43. package/web/live-dev/i18n.js +28 -16
  44. package/web/live-dev/index.html +13 -7
  45. 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),
@@ -221,6 +221,7 @@ function cardBlock(card, labels) {
221
221
  [labels.envChecklist, c.envChecklist, "list"],
222
222
  [labels.dataPrecheck, c.dataPrecheck, "list"],
223
223
  [labels.externalDeps, c.externalDeps, "list"],
224
+ [labels.perfBudget, c.perfBudget, "list"],
224
225
  ].filter(([, v]) => String(v || "").trim());
225
226
  if (!rows.length) return `<p class="empty">${esc(labels.emptyCard)}</p>`;
226
227
  return `<dl class="card-dl">${rows
@@ -584,6 +585,7 @@ function labelsFor(lang) {
584
585
  envChecklist: "Environment checklist",
585
586
  dataPrecheck: "Data precheck",
586
587
  externalDeps: "External dependencies",
588
+ perfBudget: "Performance budget",
587
589
  baseline: "Baseline sign-off",
588
590
  statusDone: "Ready",
589
591
  statusPartial: "In progress",
@@ -623,6 +625,7 @@ function labelsFor(lang) {
623
625
  envChecklist: "環境チェック",
624
626
  dataPrecheck: "データ事前検査",
625
627
  externalDeps: "外部依存",
628
+ perfBudget: "性能予算",
626
629
  baseline: "ベースライン署名",
627
630
  statusDone: "完了",
628
631
  statusPartial: "進行中",
@@ -661,6 +664,7 @@ function labelsFor(lang) {
661
664
  envChecklist: "环境检查",
662
665
  dataPrecheck: "数据预检",
663
666
  externalDeps: "外部依赖",
667
+ perfBudget: "性能预算",
664
668
  baseline: "基线签署",
665
669
  statusDone: "已产出",
666
670
  statusPartial: "进行中",
@@ -25,6 +25,10 @@ import {
25
25
  externalDepsNeedsTask,
26
26
  missingExternalDeps,
27
27
  } from "../web/live-dev/external-deps.mjs";
28
+ import {
29
+ missingPerfBudget,
30
+ perfBudgetNeedsTask,
31
+ } from "../web/live-dev/perf-budget.mjs";
28
32
 
29
33
  function clipCard(card) {
30
34
  if (!card || typeof card !== "object") {
@@ -40,6 +44,7 @@ function clipCard(card) {
40
44
  envChecklist: "",
41
45
  dataPrecheck: "",
42
46
  externalDeps: "",
47
+ perfBudget: "",
43
48
  };
44
49
  }
45
50
  return {
@@ -54,6 +59,7 @@ function clipCard(card) {
54
59
  envChecklist: String(card.envChecklist || "").slice(0, 4000),
55
60
  dataPrecheck: String(card.dataPrecheck || "").slice(0, 4000),
56
61
  externalDeps: String(card.externalDeps || "").slice(0, 4000),
62
+ perfBudget: String(card.perfBudget || "").slice(0, 4000),
57
63
  };
58
64
  }
59
65
 
@@ -426,6 +432,17 @@ export function buildTaskPoolFromModules(modules, { deployNeeded = false, deploy
426
432
  });
427
433
  beforeImpl = [ext.id];
428
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
+ }
429
446
  const impl = push({
430
447
  moduleId: m.id,
431
448
  title: `Implement module «${m.title}»: ${truncate(m.card.goal, 100)}`,
@@ -40,6 +40,7 @@ function clipCard(card) {
40
40
  envChecklist: "",
41
41
  dataPrecheck: "",
42
42
  externalDeps: "",
43
+ perfBudget: "",
43
44
  };
44
45
  }
45
46
  return {
@@ -54,6 +55,7 @@ function clipCard(card) {
54
55
  envChecklist: String(card.envChecklist || "").slice(0, 4000),
55
56
  dataPrecheck: String(card.dataPrecheck || "").slice(0, 4000),
56
57
  externalDeps: String(card.externalDeps || "").slice(0, 4000),
58
+ perfBudget: String(card.perfBudget || "").slice(0, 4000),
57
59
  };
58
60
  }
59
61
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { existsSync, mkdirSync, statSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
- import { basename, isAbsolute, join, parse, resolve } from "node:path";
7
+ import { basename, isAbsolute, parse, resolve } from "node:path";
8
8
 
9
9
  /**
10
10
  * @param {string} repoPath
@@ -53,9 +53,98 @@ export function resolveProductRepoPath(repoPath, opts = {}) {
53
53
  return resolve(parent, raw);
54
54
  }
55
55
 
56
+ /**
57
+ * Create mode: only a single short folder name under the product parent.
58
+ * Absolute paths and nested paths are refused so parent + name stay one story.
59
+ * @param {string} folderName
60
+ * @param {{ projectsRoot?: string, home?: string }} [opts]
61
+ */
62
+ export function resolveCreateProductRepoPath(folderName, opts = {}) {
63
+ const raw = String(folderName || "")
64
+ .trim()
65
+ .replace(/[\\/]+$/, "");
66
+ if (!raw) {
67
+ const e = new Error("请填写要新建的目录短名");
68
+ e.code = "EMPTY_PATH";
69
+ throw e;
70
+ }
71
+ if (isAbsolute(raw) || /[\\/]/.test(raw) || raw === "." || raw === "..") {
72
+ const e = new Error(
73
+ "新建只能填目录短名(如 my-app),不要填绝对路径。目录将建在产品父目录下;已有项目请从下方列表打开或点「浏览…」",
74
+ );
75
+ e.code = "NEED_SHORT_NAME";
76
+ throw e;
77
+ }
78
+ const projectsRoot = String(opts.projectsRoot || "").trim();
79
+ if (!projectsRoot) {
80
+ const e = new Error("新建前请先选择产品父目录");
81
+ e.code = "NEED_PROJECTS_ROOT";
82
+ throw e;
83
+ }
84
+ return resolveProductRepoPath(raw, opts);
85
+ }
86
+
87
+ /**
88
+ * Create mode must not reuse an on-disk folder (would clobber the prior project).
89
+ * @param {string} absPath
90
+ */
91
+ export function assertProductDirFreeForCreate(absPath) {
92
+ const abs = resolve(String(absPath || ""));
93
+ if (!abs) {
94
+ const e = new Error("请填写仓库绝对路径,或项目名(需先设置产品父目录)");
95
+ e.code = "EMPTY_PATH";
96
+ throw e;
97
+ }
98
+ if (existsSync(abs)) {
99
+ const e = new Error(
100
+ `项目目录已存在:${abs}。请换一个目录名,或从「已有项目」打开。`,
101
+ );
102
+ e.code = "PATH_EXISTS";
103
+ e.path = abs;
104
+ throw e;
105
+ }
106
+ return abs;
107
+ }
108
+
109
+ /**
110
+ * Create mode must not reuse a registered project path or display title.
111
+ * @param {{ path: string, title?: string }} want
112
+ * @param {Array<{ path?: string, title?: string, name?: string }>} repos
113
+ */
114
+ export function assertProjectIdentityFreeForCreate(want, repos = []) {
115
+ const abs = resolve(String(want?.path || ""));
116
+ const title = String(want?.title || "")
117
+ .trim()
118
+ .toLowerCase();
119
+ const list = Array.isArray(repos) ? repos : [];
120
+ for (const r of list) {
121
+ const p = resolve(String(r?.path || ""));
122
+ if (p && abs && p === abs) {
123
+ const e = new Error(
124
+ `项目已存在:${abs}。请换一个目录名,或从「已有项目」打开。`,
125
+ );
126
+ e.code = "PROJECT_EXISTS";
127
+ e.path = abs;
128
+ throw e;
129
+ }
130
+ const other = String(r?.title || r?.name || "")
131
+ .trim()
132
+ .toLowerCase();
133
+ if (title && other && title === other) {
134
+ const e = new Error(
135
+ `已有同名项目「${r.title || r.name}」。请换一个项目名称,或从「已有项目」打开。`,
136
+ );
137
+ e.code = "TITLE_EXISTS";
138
+ e.path = p || undefined;
139
+ throw e;
140
+ }
141
+ }
142
+ return abs;
143
+ }
144
+
56
145
  /**
57
146
  * Create missing directory (recursive). Refuses FS root and $HOME itself.
58
- * @returns {{ path: string, created: boolean }}
147
+ * @returns {{ path: string, created: boolean, name?: string }}
59
148
  */
60
149
  export function ensureProductDir(absPath, opts = {}) {
61
150
  const abs = resolve(String(absPath || ""));
@@ -180,6 +180,11 @@ the table for their app.
180
180
  | E2E-208 | FDE-03: environment checklist needs DNS, TLS, CORS, auth, and third-party reachability passed, or a no-customer opt-out; a failed probe blocks confirm; kickoff adds a probe task before implement | `npm test` + `npm run test:live` |
181
181
  | E2E-209 | FDE-06: data precheck needs a field mapping, an import failure list, and an export, or a no-import opt-out; kickoff adds the precheck before implement | `npm test` + `npm run test:live` |
182
182
  | E2E-210 | FDE-05: external dependencies need a blocker, SLA, backup mock, and parallel path, or a no-dependency opt-out; kickoff adds the board before implement | `npm test` + `npm run test:live` |
183
+ | E2E-211 | FDE-08: performance budget needs LCP, INP, bundle size, virtualized long lists, a weak-network check, and a large-data check, or a no-page-performance opt-out; a vague speed line fails; kickoff adds the budget before implement | `npm test` + `npm run test:live` |
184
+ | E2E-212 | Creating a new project refuses when the target directory already exists; open from the project list still works | `npm test` + `npm run test:live` |
185
+ | E2E-213 | Create project accepts only a short folder name under the parent; absolute paths, duplicate folders, and duplicate titles are refused | `npm test` + `npm run test:live` |
186
+ | E2E-214 | Architecture edges that would cross unrelated boxes get a clear corridor via; vertical labels use gap `labelAt`; Archify render succeeds | `npm test` |
187
+ | E2E-215 | Bottom-via edge labels stay under the floor (`labelAt` on via) so they do not overlap mid-row boxes (e.g. 改稿 / artifact) | `npm test` |
183
188
 
184
189
  | E2E-156 | Accepted chat bubble preview URL is a clickable `<a>` (http(s) or /api/…) | `npm test` + `npm run test:live` |
185
190
  | E2E-157 | `http://host/api/result/…` chat links render as one clean `<a>` (no nested HTML garble) | `npm test` |