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.
- package/.duaer/active-job.json +3 -3
- package/.duaer/specs/275-release-0271/delivery.json +9 -0
- package/.duaer/specs/275-release-0271/tasks.md +1 -1
- package/.duaer/specs/276-fde08-perf-budget/delivery.json +9 -0
- package/.duaer/specs/276-fde08-perf-budget/spec.md +38 -0
- package/.duaer/specs/276-fde08-perf-budget/tasks.md +5 -0
- package/.duaer/specs/277-project-dir-reuse/delivery.json +9 -0
- package/.duaer/specs/277-project-dir-reuse/spec.md +31 -0
- package/.duaer/specs/277-project-dir-reuse/tasks.md +5 -0
- package/.duaer/specs/278-project-create-unique/delivery.json +9 -0
- package/.duaer/specs/278-project-create-unique/spec.md +27 -0
- package/.duaer/specs/278-project-create-unique/tasks.md +5 -0
- package/.duaer/specs/279-arch-side-detour/delivery.json +8 -0
- package/.duaer/specs/279-arch-side-detour/spec.md +28 -0
- package/.duaer/specs/279-arch-side-detour/tasks.md +5 -0
- package/.duaer/specs/280-arch-bottom-via-label/delivery.json +8 -0
- package/.duaer/specs/280-arch-bottom-via-label/spec.md +22 -0
- package/.duaer/specs/280-arch-bottom-via-label/tasks.md +5 -0
- package/.duaer/specs/281-release-0272/spec.md +13 -0
- package/.duaer/specs/281-release-0272/tasks.md +4 -0
- package/CHANGELOG.md +34 -0
- package/bin/duaer-live.mjs +93 -19
- package/bin/live-archify.mjs +382 -26
- package/bin/live-deliverables.mjs +4 -0
- package/bin/live-modules.mjs +17 -0
- package/bin/live-project-chat.mjs +2 -0
- package/bin/live-repo-path.mjs +91 -2
- package/docs/agent/e2e-test-plan.md +5 -0
- package/docs/agent/live-desk.md +15 -3
- package/package.json +1 -1
- package/web/live-dev/app.js +28 -2
- package/web/live-dev/baseline.mjs +1 -0
- package/web/live-dev/confirm-card.mjs +1 -0
- package/web/live-dev/i18n-de.js +14 -8
- package/web/live-dev/i18n-es.js +14 -8
- package/web/live-dev/i18n-fr.js +14 -8
- package/web/live-dev/i18n-ja.js +14 -8
- package/web/live-dev/i18n-ko.js +14 -8
- package/web/live-dev/i18n-pt-BR.js +14 -8
- package/web/live-dev/i18n-ru.js +14 -8
- package/web/live-dev/i18n-vi.js +14 -8
- package/web/live-dev/i18n-zh-TW.js +14 -8
- package/web/live-dev/i18n.js +28 -16
- package/web/live-dev/index.html +13 -7
- package/web/live-dev/perf-budget.mjs +38 -0
package/bin/live-archify.mjs
CHANGED
|
@@ -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 = -
|
|
360
|
+
e.labelDy = -36;
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
return ir;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
/**
|
|
367
|
-
* Detour
|
|
368
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
|
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 =
|
|
427
|
-
e.toSide =
|
|
428
|
-
e.via =
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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: "进行中",
|
package/bin/live-modules.mjs
CHANGED
|
@@ -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
|
|
package/bin/live-repo-path.mjs
CHANGED
|
@@ -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,
|
|
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` |
|