diff --git a/apps/graphs/test/__snapshots__/pages.test.ts.snap b/apps/graphs/test/__snapshots__/pages.test.ts.snap index cd02e3d..76d9d60 100644 --- a/apps/graphs/test/__snapshots__/pages.test.ts.snap +++ b/apps/graphs/test/__snapshots__/pages.test.ts.snap @@ -1402,7 +1402,7 @@ pre.prompt, pre.hash {

Star on GitHub Edit a copy

-
hello-graphNodes in order: implementer, triage, tests, gate. Ensembles: reviewers.REVIEWERSPARALLEL · 1/212implementer (agent)implementertriage (router)triagegate (gate)gatetests (check)tests⚡
+
hello-graphNodes in order: implementer, triage, tests, gate. Ensembles: reviewers.REVIEWERSPARALLEL · 1/212implementer (agent)implementertriage (router)triagegate (gate)gatetests (check)tests⚡
Kind
graph
Name
hello-graph
Ref
main
Stars
2 stars
Nodes
agent 1 · router 1 · check 1 · gate 1
Edges
8 edges · 1 fail
Ensembles
reviewers (parallel, 2 members)
Agent nodes
implementer
Install
canopy graph install acme/things/flow

Review "What this runs" first.

Report
Report
Report the repository to GitHub
Report the repository to GitHub
@@ -2136,7 +2136,7 @@ pre.prompt, pre.hash {

Five nodes with one fail edge, for the drawing test.

Star on GitHub Edit a copy

-
hello-graphNodes in order: build, test, gate, triage, docs.build (agent)buildtest (check)testgate (gate)gatetriage (router)triagedocs (agent)docs✓ done
+
hello-graphNodes in order: build, test, gate, triage, docs.build (agent)buildtest (check)testgate (gate)gatetriage (router)triagedocs (agent)docs✓ done
Kind
graph
Name
hello-graph
Stars
7 stars
Nodes
agent 2 · check 1 · gate 1 · router 1
Edges
5 edges · 1 fail
Agent nodes
build, docs
Install
canopy graph install hello-graph
Report
Report

README

@@ -4340,7 +4340,7 @@ pre.prompt, pre.hash {

Five nodes with one fail edge, for the drawing test.

Star on GitHub Edit a copy

-
hello-graphNodes in order: build, test, gate, triage, docs.build (agent)buildtest (check)testgate (gate)gatetriage (router)triagedocs (agent)docs✓ done
+
hello-graphNodes in order: build, test, gate, triage, docs.build (agent)buildtest (check)testgate (gate)gatetriage (router)triagedocs (agent)docs✓ done
Kind
graph
Name
hello-graph
Stars
7 stars
Nodes
agent 2 · check 1 · gate 1 · router 1
Edges
5 edges · 1 fail
Agent nodes
build, docs
Install
canopy graph install hello-graph
Report
Report

README

diff --git a/apps/graphs/test/__snapshots__/plate.test.ts.snap b/apps/graphs/test/__snapshots__/plate.test.ts.snap index 1f588b9..4c0b1a1 100644 --- a/apps/graphs/test/__snapshots__/plate.test.ts.snap +++ b/apps/graphs/test/__snapshots__/plate.test.ts.snap @@ -1,20 +1,20 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`plate invariants > draws a round-robin group's third subtitle shape 1`] = `"round-robinNodes in order: before, after. Ensembles: rotators.ROTATORSROUND ROBIN · 2 members12before (agent)beforeafter (agent)after✓ done"`; +exports[`plate invariants > draws a round-robin group's third subtitle shape 1`] = `"round-robinNodes in order: before, after. Ensembles: rotators.ROTATORSROUND ROBIN · 2 members12before (agent)beforeafter (agent)after✓ done"`; -exports[`plate snapshots > draws canopy-tui-polish as a plate 1`] = `"canopy-tui-polishNodes in order: resilience, design, implement, gates, review, Commit. Ensembles: proposers, fallbacks.PROPOSERSPARALLEL · 2/31·codexcodex2·geminigemini3·copilotcopilotFALLBACKSCASCADE · 21·kirokiro2·mimomimodesign (agent)designimplement (agent)implementgates (check)gatesreview (agent)reviewCommit (check)Commitresilience (agent)resilience⚡✓ done"`; +exports[`plate snapshots > draws canopy-tui-polish as a plate 1`] = `"canopy-tui-polishNodes in order: resilience, design, implement, gates, review, Commit. Ensembles: proposers, fallbacks.PROPOSERSPARALLEL · 2/31·codexcodex2·geminigemini3·copilotcopilotFALLBACKSCASCADE · 21·kirokiro2·mimomimodesign (agent)designimplement (agent)implementgates (check)gatesreview (agent)reviewCommit (check)Commitresilience (agent)resilience⚡✓ done"`; -exports[`plate snapshots > draws hello-graph as a plate 1`] = `"hello-graphNodes in order: implementer, triage, tests, gate. Ensembles: reviewers.REVIEWERSPARALLEL · 1/212implementer (agent)implementertriage (router)triagegate (gate)gatetests (check)tests⚡"`; +exports[`plate snapshots > draws hello-graph as a plate 1`] = `"hello-graphNodes in order: implementer, triage, tests, gate. Ensembles: reviewers.REVIEWERSPARALLEL · 1/212implementer (agent)implementertriage (router)triagegate (gate)gatetests (check)tests⚡"`; -exports[`plate snapshots > draws quality-bubble as a plate 1`] = `"quality-bubbleNodes in order: implementer, test-run, test-size-cap, anti-cheat-reviewer.implementer (agent)implementertest-run (check)test-runtest-size-cap (check)test-size-capanti-cheat-reviewer (agent)anti-cheat-revi…✓ done"`; +exports[`plate snapshots > draws quality-bubble as a plate 1`] = `"quality-bubbleNodes in order: implementer, test-run, test-size-cap, anti-cheat-reviewer.implementer (agent)implementertest-run (check)test-runtest-size-cap (check)test-size-capanti-cheat-reviewer (agent)anti-cheat-revi…✓ done"`; -exports[`plate snapshots > draws resilient-members as a plate 1`] = `"resilient-membersNodes in order: start, resilience, gate, review. Ensembles: implementers.IMPLEMENTERSCASCADE · 212start (check)startresilience (agent)resiliencegate (check)gatereview (agent)review✓ done"`; +exports[`plate snapshots > draws resilient-members as a plate 1`] = `"resilient-membersNodes in order: start, resilience, gate, review. Ensembles: implementers.IMPLEMENTERSCASCADE · 212start (check)startresilience (agent)resiliencegate (check)gatereview (agent)review✓ done"`; -exports[`plate snapshots > draws review-panel as a plate 1`] = `"review-panelNodes in order: implement, gate, commit. Ensembles: review-panel.REVIEW-PANELPARALLEL · 4/41234implement (agent)implementgate (check)gatecommit (agent)commit✓ done"`; +exports[`plate snapshots > draws review-panel as a plate 1`] = `"review-panelNodes in order: implement, gate, commit. Ensembles: review-panel.REVIEW-PANELPARALLEL · 4/41234implement (agent)implementgate (check)gatecommit (agent)commit✓ done"`; -exports[`plate snapshots > draws single-committer as a plate 1`] = `"single-committerNodes in order: start-check, lock-repo, implementer, full-gates, committer, commit-check, unlock, human-verdict. Ensembles: reviewers.REVIEWERSPARALLEL · 2/212start-check (check)start-checklock-repo (check)lock-repoimplementer (agent)implementerfull-gates (check)full-gatescommitter (agent)committercommit-check (check)commit-checkunlock (check)unlockhuman-verdict (check)human-verdict✓ done"`; +exports[`plate snapshots > draws single-committer as a plate 1`] = `"single-committerNodes in order: start-check, lock-repo, implementer, full-gates, committer, commit-check, unlock, human-verdict. Ensembles: reviewers.REVIEWERSPARALLEL · 2/212start-check (check)start-checklock-repo (check)lock-repoimplementer (agent)implementerfull-gates (check)full-gatescommitter (agent)committercommit-check (check)commit-checkunlock (check)unlockhuman-verdict (check)human-verdict✓ done"`; -exports[`plate snapshots > draws spec-loop as a plate 1`] = `"spec-loopNodes in order: start-check, lock-repo, implementer, full-gates, reviewer, commit-check, unlock, human-verdict.start-check (check)start-checklock-repo (check)lock-repoimplementer (agent)implementerfull-gates (check)full-gatesreviewer (agent)reviewercommit-check (check)commit-checkunlock (check)unlockhuman-verdict (check)human-verdict✓ done"`; +exports[`plate snapshots > draws spec-loop as a plate 1`] = `"spec-loopNodes in order: start-check, lock-repo, implementer, full-gates, reviewer, commit-check, unlock, human-verdict.start-check (check)start-checklock-repo (check)lock-repoimplementer (agent)implementerfull-gates (check)full-gatesreviewer (agent)reviewercommit-check (check)commit-checkunlock (check)unlockhuman-verdict (check)human-verdict✓ done"`; exports[`the node plate > draws an ensemble node as a group of numbered member chips 1`] = `"ensemble-proposersEnsembles: ensemble-proposers.ENSEMBLE-PROPOSERSPARALLEL · 3/3123"`; diff --git a/apps/graphs/test/plate-lane.test.ts b/apps/graphs/test/plate-lane.test.ts index 58910ef..06b451d 100644 --- a/apps/graphs/test/plate-lane.test.ts +++ b/apps/graphs/test/plate-lane.test.ts @@ -275,10 +275,11 @@ describe("no-handler plates", () => { it("keeps a handler-free svg byte-identical", async () => { // Re-pinned when the vertical layout went away and the marker ids lost // their orientation prefix, then again when the walk's unit twins joined - // the catalogue plate. + // the catalogue plate, then when channel edges left from the top middle + // and the twins started handing off mid-leg with the token. const svg = renderPlate(qualityBubble as unknown as GraphDocument); const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(svg)); const hex = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); - expect(hex).toBe("c954baa22c0d109fefd430e1d67ccccf54b30f1170437c88635db556e24a1130"); + expect(hex).toBe("f4c722422178fad13866e9d9c588de258499c5171a40e92dcd3095d32afc2966"); }); }); diff --git a/apps/graphs/test/plate-state.test.ts b/apps/graphs/test/plate-state.test.ts index 71f85b3..2146f8d 100644 --- a/apps/graphs/test/plate-state.test.ts +++ b/apps/graphs/test/plate-state.test.ts @@ -122,6 +122,11 @@ describe("renderPlate with a state", () => { expect(svg).toContain(`class="state-badge"`); // The token stops on the running member instead of walking the chain. expect(svg).not.toContain(`]*>/); + expect(twin).toContain(`data-name="${label}"`); }); it("draws one static twin for the running unit and no SMIL", () => { diff --git a/apps/graphs/test/plate-walk.test.ts b/apps/graphs/test/plate-walk.test.ts index 08bd00b..9db6609 100644 --- a/apps/graphs/test/plate-walk.test.ts +++ b/apps/graphs/test/plate-walk.test.ts @@ -251,15 +251,26 @@ describe("the lit units", () => { return pattern.exec(svg)?.[1]?.split(";") ?? []; } - it("lights a twin only on the legs that touch its unit", () => { + it("lights a twin only on the half-legs that touch its unit", () => { // Every draw dips: the eight-leg walk loops gate↔fix three times, so - // fix touches legs 1–6, start only leg 0 and done only the final leg. + // fix leaves legs 2, 4, 6 and reaches legs 1, 3, 5; start only leaves + // leg 0 and done only reaches the final leg. A step leaving a unit + // lights the leg's first half, reaching it the second half, so the + // glow hands off mid-leg as the token passes. const svg = renderPlate(LOOP, undefined, () => 0); expect(svg.match(/class="unit-lit"/g) ?? []).toHaveLength(4); - expect(twinValues(svg, "fix")).toEqual(["0", "1", "1", "1", "1", "1", "1", "0", "0"]); - expect(twinValues(svg, "gate")).toEqual(["1", "1", "1", "1", "1", "1", "1", "1", "0"]); - expect(twinValues(svg, "start")).toEqual(["1", "0", "0", "0", "0", "0", "0", "0", "0"]); - expect(twinValues(svg, "done")).toEqual(["0", "0", "0", "0", "0", "0", "0", "1", "0"]); + expect(twinValues(svg, "fix")).toEqual( + ["0", "0", "0", "1", "1", "0", "0", "1", "1", "0", "0", "1", "1", "0", "0", "0", "0"], + ); + expect(twinValues(svg, "gate")).toEqual( + ["0", "1", "1", "0", "0", "1", "1", "0", "0", "1", "1", "0", "0", "1", "1", "0", "0"], + ); + expect(twinValues(svg, "start")).toEqual( + ["1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0"], + ); + expect(twinValues(svg, "done")).toEqual( + ["0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "1", "0"], + ); expect(svg).toContain( `@media (prefers-reduced-motion:reduce){.plate-svg .unit-lit{display:none}}`, ); diff --git a/apps/graphs/test/plate.test.ts b/apps/graphs/test/plate.test.ts index ef717ad..9bdf4e9 100644 --- a/apps/graphs/test/plate.test.ts +++ b/apps/graphs/test/plate.test.ts @@ -724,8 +724,9 @@ describe("the pattern figures", () => { expect(layout).not.toBeNull(); const fail = layout?.edges.find((edge) => edge.from === "review-panel" && edge.to === "implement"); expect(fail?.cls).toBe("fail"); - // A channel: only straight runs and rounded corners, from the source's - // top corner to the target's top corner, topping out above the row. + // A channel: only straight runs and rounded corners, from the middle + // of the source's top edge to the middle of the target's, topping + // out above the row. for (const letter of commands(fail?.d ?? "")) { expect("MLQ").toContain(letter); } @@ -738,14 +739,8 @@ describe("the pattern figures", () => { } const box = nodeBox(implement); const round = (value: number): number => Math.round(value * 100) / 100; - expect([ - [round(panel.x - panel.w / 2), round(panel.y - panel.h / 2)], - [round(panel.x + panel.w / 2), round(panel.y - panel.h / 2)], - ]).toContainEqual(pathStart(fail.d)); - expect([ - [round(implement.x - box.hw), round(implement.y - box.hh)], - [round(implement.x + box.hw), round(implement.y - box.hh)], - ]).toContainEqual(pathEnd(fail.d)); + expect(pathStart(fail.d)).toEqual([round(panel.x), round(panel.y - panel.h / 2)]); + expect(pathEnd(fail.d)).toEqual([round(implement.x), round(implement.y - box.hh)]); const rowTop = Math.min( ...(layout?.nodes ?? []) .filter((node) => node.lane === "main") diff --git a/packages/plate/layout.ts b/packages/plate/layout.ts index bafb49d..11908ff 100644 --- a/packages/plate/layout.ts +++ b/packages/plate/layout.ts @@ -402,9 +402,9 @@ export function layoutPlate(document: unknown, random: () => number = happyRando // Edge endpoints on the unit borders, by drawn name. A forward edge leaves // the source border facing its target at the source height and enters the // target border facing its source at the target height, so the bend sits - // at the midpoint of the rank gap. A channel edge leaves the source's top - // corner facing its target and enters the target's top corner facing its - // source, clearing the units stacked above. + // at the midpoint of the rank gap. A channel edge leaves the middle of + // the source's top edge and enters the middle of the target's top edge, + // clearing the units stacked above. const groupByName = new Map(groups.map((group) => [group.name, group])); const displayName = drawnName; /** Whether an edge draws along a channel above the flow. */ @@ -430,10 +430,9 @@ export function layoutPlate(document: unknown, random: () => number = happyRando b: { x: num(to.x + to.hw), y: to.y }, }; const channelEnds = (from: Extent, to: Extent): { a: Pt; b: Pt } => { - const dir = to.x < from.x ? -1 : 1; return { - a: { x: num(from.x + dir * from.hw), y: num(from.y - from.hh) }, - b: { x: num(to.x - dir * to.hw), y: num(to.y - to.hh) }, + a: { x: num(from.x), y: num(from.y - from.hh) }, + b: { x: num(to.x), y: num(to.y - to.hh) }, }; }; diff --git a/packages/plate/render.ts b/packages/plate/render.ts index 174121c..f514d84 100644 --- a/packages/plate/render.ts +++ b/packages/plate/render.ts @@ -357,8 +357,8 @@ function twinOpen(twin: UnitTwin): string { /** * The catalogue's lit unit: the unit's own rect behind it, riding a - * discrete SMIL opacity that turns on exactly on the walk's legs that - * touch it. `prefers-reduced-motion` hides it whole. + * discrete SMIL opacity that turns on exactly on the walk's half-legs + * that touch it. `prefers-reduced-motion` hides it whole. */ function renderLitUnit(twin: UnitTwin, legs: number[], total: number, seconds: number): string { const { values, keyTimes } = litTiming(legs, total); @@ -375,32 +375,53 @@ function renderStaticLitUnit(twin: UnitTwin): string { return `${twinOpen(twin)}/>`; } -/** The legs of the walk that reach or leave one unit. */ -function touchingLegs(steps: WalkStep[], name: string): number[] { - const legs: number[] = []; +/** + * The half-legs of the walk that touch one unit: a step leaving it lights + * the first half of its leg, a step reaching it the second half — the glow + * hands off mid-leg, as the token passes the midpoint, instead of lighting + * both ends for the whole leg. + */ +function touchingSlots(steps: WalkStep[], name: string): number[] { + const slots: number[] = []; for (const [index, step] of steps.entries()) { - if (step.from === name || step.to === name) { - legs.push(index); + if (step.from === name) { + slots.push(index * 2); + } + if (step.to === name) { + slots.push(index * 2 + 1); } } - return legs; + return slots; } /** The catalogue's glow: one lit twin per unit the walk visits, behind them. */ function renderLits(layout: PlateLayout): string { - const total = layout.walkSteps.length; + const total = layout.walkSteps.length * 2; if (total === 0) { return ""; } const units = [ - ...layout.nodes.map((node) => ({ twin: nodeTwin(node), legs: touchingLegs(layout.walkSteps, node.name) })), - ...layout.groups.map((group) => ({ twin: groupTwin(group), legs: touchingLegs(layout.walkSteps, group.name) })), + ...layout.nodes.map((node) => ({ twin: nodeTwin(node), legs: touchingSlots(layout.walkSteps, node.name) })), + ...layout.groups.map((group) => ({ twin: groupTwin(group), legs: touchingSlots(layout.walkSteps, group.name) })), ].filter((unit) => unit.legs.length > 0); return `${units .map((unit) => renderLitUnit(unit.twin, unit.legs, total, layout.walkSeconds)) .join("")}`; } +/** The glow twin of a running ensemble member: its own chip, where the token sits. */ +function memberTwin(groupName: string, member: PlacedMember, index: number): UnitTwin { + return { + name: member.label === "" ? `${groupName}#${index + 1}` : member.label, + x: member.x, + y: member.y, + w: MEMBER_GEOM.w, + h: MEMBER_GEOM.h, + rx: 1, + rotate: 0, + }; +} + function renderMember( groupName: string, member: PlacedMember, @@ -415,6 +436,7 @@ function renderMember( const title = mark === null ? "" : `${escape(member.label)} — ${STATE_WORD[mark.status]}`; + const twin = mark?.lit === true ? renderStaticLitUnit(memberTwin(groupName, member, index)) : ""; const ring = mark?.lit === true ? renderRing(member.x, member.y, Math.max(MEMBER_GEOM.w, MEMBER_GEOM.h) / 2 + 1.7) : ""; @@ -427,6 +449,7 @@ function renderMember( title + `` + + twin + ring + `${escape(member.label)}` + harness + @@ -435,16 +458,16 @@ function renderMember( ); } -/** Whether a running key lights the whole ensemble. */ +/** + * Whether the ensemble's own key runs: then the whole box glows. A running + * member lights its own chip instead (renderMember), where the token sits, + * so the glow never floats apart from the dot. + */ function groupRunning(state: PlateState | undefined, group: PlacedGroup): boolean { if (state === undefined) { return false; } - const keys = [ - group.name, - ...group.members.flatMap((member, index) => [member.label, `${group.name}#${index + 1}`]), - ]; - return keys.some((key) => statusOf(state, key) === "running"); + return statusOf(state, group.name) === "running"; } function renderGroups(layout: PlateLayout, state: PlateState | undefined): string {