Skip to content

Commit 4e4c4e8

Browse files
committed
feat: emit per-Placement subsection in components/<slug>.md
In generateComponentFiles, after the Placements table and before the Spec block, iterate non-canonical placements and emit a "### Placement: <name>" subsection for each instance with non-empty content. The block includes: - Per-hotspot detail via the shared renderHotspotDetailBlock renderer, so canonical and placement hotspot details look identical to the AI agent. - Screen-level outgoing connections (no hotspotId) listed as "- → <target.name> (<action>)". Skip the canonical placement — its spec is already emitted by the Spec block. Skip instances with no local hotspots and no screen-level outgoing connections. Tests cover: emission of the Placement section for an instance with a local hotspot, omission for the canonical itself, omission when the instance has no local content, and regression that screens.md keeps its one-line instance stub. Update userGuide.md (and therefore the #/docs page, which imports the markdown raw) with a new "Instance-specific hotspots" subsection explaining the additive semantics and the auto-promotion merge. Soften the prior TIP that called instance hotspots read-only. Update MCP create_hotspot and list_hotspots tool descriptions to clarify canonical-vs-instance hotspot semantics. No logic change.
1 parent f9fcef8 commit 4e4c4e8

4 files changed

Lines changed: 148 additions & 4 deletions

File tree

mcp-server/src/tools/hotspot-tools.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const accessibilitySchema = {
2323
export const hotspotTools = [
2424
{
2525
name: "create_hotspot",
26-
description: "Add a hotspot (tap area) to a screen. Coordinates are percentages (0-100) of the screen image dimensions. If action is 'navigate' or 'modal' and targetScreenId is provided, a connection is automatically created.",
26+
description: "Add a hotspot (tap area) to a screen. Coordinates are percentages (0-100) of the screen image dimensions. If action is 'navigate' or 'modal' and targetScreenId is provided, a connection is automatically created. Hotspots on a canonical component apply to every placement; hotspots on an instance are placement-specific additive interactions that do not modify or hide the canonical's hotspots.",
2727
inputSchema: {
2828
type: "object",
2929
properties: {
@@ -120,7 +120,7 @@ export const hotspotTools = [
120120
},
121121
{
122122
name: "list_hotspots",
123-
description: "List all hotspots on a specific screen.",
123+
description: "List all hotspots on a specific screen. On a canonical component, this returns the hotspots that apply to every placement. On an instance, this returns only the placement-specific (additive) hotspots — to see the canonical's hotspots, call this against the canonical screen.",
124124
inputSchema: {
125125
type: "object",
126126
properties: {

src/pages/docs/userGuide.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,22 @@ You can also right-click the screen and choose **Mark as reusable component** fr
300300
1. Select a different screen that should be the same component.
301301
2. In the **Reusable Component** section, use the **Instance of…** dropdown to pick the canonical component you just created.
302302
3. The screen's spec fields (description, code reference, acceptance criteria, roles) collapse — the spec lives on the canonical screen. The screen gets a `↗ Instance` badge.
303-
4. The instance's image and hotspots on the canvas mirror the canonical's automatically. Edit the canonical and every instance updates.
303+
4. The instance's image and dimensions on the canvas mirror the canonical's automatically. Edit the canonical and every instance updates.
304304

305305
> [!TIP]
306-
> Hotspot positions, dimensions, and the `+ Link` button are read-only on instances — the canonical owns the visual spec. To edit a hotspot or replace the image, jump to the canonical via the **Open canonical →** link in the sidebar.
306+
> The image is owned by the canonical — to replace it, jump to the canonical via the **Open canonical →** link in the sidebar. Hotspots and connections are different: see *Instance-specific hotspots* below.
307+
308+
### Instance-specific hotspots
309+
310+
Hotspots and connections on a canonical component apply to every placement where it appears. Sometimes a single placement also needs an extra tap area or navigation link that doesn't belong on the component itself.
311+
312+
You can draw hotspots and create connections directly on an instance. These are **additive** — they do not modify or hide the canonical's hotspots. At export time, instance-local hotspots appear in the component file under "Placement: <screen name>" alongside the canonical spec.
313+
314+
> [!NOTE]
315+
> Hotspots drawn on the canvas of an instance live only on that placement. If you need a hotspot to apply to every instance, edit the canonical instead.
316+
317+
> [!TIP]
318+
> If you delete a canonical that has instances, the first instance becomes the new canonical. Its existing local hotspots are merged with the deleted canonical's, so nothing is lost. Connections that referenced the canonical's hotspots are re-pointed to the promoted instance automatically.
307319
308320
### Unlinking and conversions
309321

src/utils/generateInstructionFiles.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,46 @@ function generateComponentFiles(screens, connections, images, documents, compone
710710
}
711711
md += `\n`;
712712

713+
// Per-placement subsection: emit only for non-canonical placements that
714+
// carry placement-specific content (local hotspots and/or screen-level
715+
// outgoing connections). Hotspots on an instance are additive locals;
716+
// canonical hotspots are NOT inherited at storage time and are already
717+
// covered by the Spec block below.
718+
for (const placement of instances) {
719+
const localHotspots = placement.hotspots || [];
720+
const localHotspotIds = new Set(localHotspots.map((h) => h.id));
721+
const hotspotConns = connections.filter(
722+
(c) =>
723+
c.fromScreenId === placement.id &&
724+
c.hotspotId &&
725+
localHotspotIds.has(c.hotspotId)
726+
);
727+
const screenLevelConns = connections.filter(
728+
(c) => c.fromScreenId === placement.id && !c.hotspotId
729+
);
730+
if (
731+
localHotspots.length === 0 &&
732+
hotspotConns.length === 0 &&
733+
screenLevelConns.length === 0
734+
) {
735+
continue;
736+
}
737+
md += `### Placement: ${placement.name}\n\n`;
738+
md += `*Placement-specific interactions (in addition to the canonical spec below).*\n\n`;
739+
for (const h of localHotspots) {
740+
md += `#### ${h.label || "Hotspot"}\n\n`;
741+
const detail = renderHotspotDetailBlock(h, screens, documents);
742+
if (detail) md += detail;
743+
}
744+
for (const c of screenLevelConns) {
745+
const target = screens.find((s) => s.id === c.toScreenId);
746+
const targetName = target?.name ?? "(unknown)";
747+
const action = c.action || "navigate";
748+
md += `- → ${targetName} (${action})\n`;
749+
}
750+
md += `\n`;
751+
}
752+
713753
md += `## Spec\n\n`;
714754
md += generateScreenDetailMd(canonical, screens, connections, images, documents);
715755

src/utils/generateInstructionFiles.test.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,4 +401,96 @@ describe("generateInstructionFiles - reusable components", () => {
401401
expect(componentFile.content).toContain("instance");
402402
expect(componentFile.content).toContain("Home");
403403
});
404+
405+
it("emits a 'Placement: <name>' subsection in components/<slug>.md for an instance with a local hotspot", () => {
406+
const localHotspot = {
407+
id: "hI1",
408+
label: "Skip",
409+
elementType: "button",
410+
interactionType: "tap",
411+
action: "navigate",
412+
x: 10, y: 10, w: 30, h: 10,
413+
};
414+
const screens = [
415+
{ ...minimalScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" },
416+
{
417+
...minimalScreen,
418+
id: "s2",
419+
name: "Card on Onboarding",
420+
x: 400,
421+
componentId: "c1",
422+
componentRole: "instance",
423+
hotspots: [localHotspot],
424+
},
425+
];
426+
const result = generateInstructionFiles(screens, [], opts);
427+
const componentFile = result.files.find((f) => f.name === "components/card.md");
428+
expect(componentFile.content).toContain("### Placement: Card on Onboarding");
429+
expect(componentFile.content).toContain(
430+
"*Placement-specific interactions (in addition to the canonical spec below).*"
431+
);
432+
expect(componentFile.content).toContain("#### Skip");
433+
});
434+
435+
it("does not emit a 'Placement:' subsection for the canonical itself (its spec is the Spec block)", () => {
436+
const screens = [
437+
{
438+
...minimalScreen,
439+
id: "s1",
440+
name: "Card",
441+
hotspots: [{
442+
id: "hC1",
443+
label: "Tap",
444+
elementType: "button",
445+
interactionType: "tap",
446+
action: "navigate",
447+
x: 10, y: 10, w: 80, h: 15,
448+
}],
449+
componentId: "c1",
450+
componentRole: "canonical",
451+
},
452+
{ ...minimalScreen, id: "s2", name: "Home", x: 400, componentId: "c1", componentRole: "instance" },
453+
];
454+
const result = generateInstructionFiles(screens, [], opts);
455+
const componentFile = result.files.find((f) => f.name === "components/card.md");
456+
expect(componentFile.content).not.toContain("### Placement: Card\n");
457+
});
458+
459+
it("does not emit a Placement subsection for an instance with no local content (regression)", () => {
460+
const screens = [
461+
{ ...minimalScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" },
462+
{ ...minimalScreen, id: "s2", name: "Home", x: 400, componentId: "c1", componentRole: "instance", hotspots: [] },
463+
];
464+
const result = generateInstructionFiles(screens, [], opts);
465+
const componentFile = result.files.find((f) => f.name === "components/card.md");
466+
expect(componentFile.content).not.toContain("### Placement:");
467+
});
468+
469+
it("screens.md instance entry remains a one-line stub (regression)", () => {
470+
const screens = [
471+
{ ...minimalScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" },
472+
{
473+
...minimalScreen,
474+
id: "s2",
475+
name: "Card on Home",
476+
x: 400,
477+
componentId: "c1",
478+
componentRole: "instance",
479+
hotspots: [{
480+
id: "hI1",
481+
label: "Skip",
482+
elementType: "button",
483+
interactionType: "tap",
484+
action: "navigate",
485+
x: 10, y: 10, w: 30, h: 10,
486+
}],
487+
},
488+
];
489+
const result = generateInstructionFiles(screens, [], opts);
490+
const screensFile = result.files.find((f) => f.name === "screens.md");
491+
expect(screensFile.content).toContain("Instance of [Card](components/card.md)");
492+
// The instance stub must not duplicate the placement-specific hotspot detail
493+
// — that lives in components/card.md only.
494+
expect(screensFile.content).not.toContain("### Placement: Card on Home");
495+
});
404496
});

0 commit comments

Comments
 (0)