From 57dca31f6844fcd863c93870a772c94ec725262b Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 29 Sep 2026 17:35:31 -0500 Subject: [PATCH 1/5] Add Wizard canvas designer setup dialog and advisory browser checks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/wizard-e2e.yml | 77 +++++ .gitignore | 1 + .../speckit-wizard-canvas/README.md | 32 ++ .../speckit-wizard-canvas/catalog/bundles.mjs | 30 ++ .../speckit-wizard-canvas/catalog/shared.mjs | 1 + .../speckit-wizard-canvas/catalog/sources.mjs | 1 + .../e2e/canvas-designer.spec.mjs | 56 ++++ .../speckit-wizard-canvas/e2e/server.mjs | 57 ++++ .../speckit-wizard-canvas/extension.mjs | 10 +- .../speckit-wizard-canvas/package-lock.json | 48 +++ .../speckit-wizard-canvas/package.json | 6 +- .../playwright.config.mjs | 21 ++ .../speckit-wizard-canvas/server.mjs | 25 ++ .../test/canvas-designer-dialog.test.mjs | 314 ++++++++++++++++++ .../test/catalog.test.mjs | 43 +++ .../test/modals.test.mjs | 81 ++++- .../test/server-integration.test.mjs | 38 +++ .../ui/canvas-designer-dialog.js | 212 ++++++++++++ .../speckit-wizard-canvas/ui/index.html | 7 +- .../speckit-wizard-canvas/ui/modals.js | 53 ++- .../speckit-wizard-canvas/ui/phase-runtime.js | 14 +- .../ui/styles/catalog.css | 12 +- .../ui/styles/overlays.css | 40 +++ .../ui/styles/pipeline.css | 8 +- 24 files changed, 1154 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/wizard-e2e.yml create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/e2e/canvas-designer.spec.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/e2e/server.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/playwright.config.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/canvas-designer-dialog.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/canvas-designer-dialog.js diff --git a/.github/workflows/wizard-e2e.yml b/.github/workflows/wizard-e2e.yml new file mode 100644 index 00000000..c98738c4 --- /dev/null +++ b/.github/workflows/wizard-e2e.yml @@ -0,0 +1,77 @@ +name: Wizard E2E + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: read + +jobs: + changes: + name: Detect Wizard changes + runs-on: ubuntu-latest + outputs: + wizard: ${{ steps.detect.outputs.wizard }} + steps: + - name: Detect changed Wizard files + id: detect + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + files=$(gh api "repos/$GH_REPO/pulls/$PR_NUMBER/files" --paginate --jq '.[] | .filename, (.previous_filename // empty)') + if grep -q '^plugins/spec-kit-copilot-wizard/' <<< "$files"; then + echo "wizard=true" >> "$GITHUB_OUTPUT" + else + echo "wizard=false" >> "$GITHUB_OUTPUT" + fi + + browser: + name: Wizard browser tests + needs: changes + if: needs.changes.outputs.wizard == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: wizard-e2e-results + path: plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test-results/ + retention-days: 7 + + gate: + name: Wizard E2E gate + needs: [changes, browser] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check browser result for Wizard changes + env: + DETECTION: ${{ needs.changes.result }} + WIZARD: ${{ needs.changes.outputs.wizard }} + BROWSER: ${{ needs.browser.result }} + run: | + if [[ "$DETECTION" != "success" ]]; then + echo "::error::Could not determine whether Wizard files changed" + exit 1 + fi + if [[ "$WIZARD" == "true" && "$BROWSER" != "success" ]]; then + echo "::error::Wizard browser tests did not pass ($BROWSER)" + exit 1 + fi + echo "Wizard E2E check complete (changed: $WIZARD)" diff --git a/.gitignore b/.gitignore index b4513c4d..82212038 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store node_modules/ .playwright-mcp/ +plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test-results/ # Session artifacts (canvas runtime state, spec-kit init output, css coverage snapshots) .speckit-wizard/ diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md index abe76675..20193e69 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md @@ -50,6 +50,38 @@ artifacts, provide input, and run the matching `speckit-*` skill. ![Phases page](../../../../docs/images/wizard-phases.png) +### Canvas designer setup (first increment) + +The **Generate canvas** button in the Phases pipeline header opens the +**Canvas designer setup** dialog. It offers separate Presets, Extensions, +and Bundles tabs with source badges and checkboxes for entries tagged +`canvas-design` in the Copilot and Community catalogs. No eligible items +are expected in this first increment, so the tabs show empty states until +those catalogs publish design-time customizations. Selections reset when +the dialog closes and do not install anything in the current project. +Community selections use the same prominent confirmation warning as +adding an item from the Catalogs page. +Selecting a bundle inspects its components without installing it and checks +matching presets and extensions already listed in the standalone catalogs. +Those checkboxes remain editable and show which bundle includes them; +components absent from the standalone catalogs are not listed. Removing a +bundle releases its selections unless another selected bundle or a direct +selection still requires them; an inspection failure is shown in the dialog. + +**Launch designer** is deliberately disabled. The dialog does not yet +create a nested session or dispatch a launch request; this will be wired +up separately. The Catalogs page continues to install items as before. + +### Browser tests + +From this extension directory, run `npm ci`, `npx playwright install chromium`, +then `npm run test:e2e`. The tests start a local Wizard server with fixed +catalog data; no `specify` installation or live catalog is required. +`.github/workflows/wizard-e2e.yml` runs them on PRs targeting `main` only +when the Wizard plugin changes. The check is advisory until branch protection +is configured separately; its always-present gate can later be made required +without blocking unrelated PRs on a skipped workflow. + ## Quickstart > This is a **canvas extension** — it opens in the **GitHub Copilot app** diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/bundles.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/bundles.mjs index 8ab17a4c..ffff0b92 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/bundles.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/bundles.mjs @@ -11,6 +11,36 @@ import { hydrateFromCatalogSources, specifyRun } from "./shared.mjs"; +export async function inspectBundleMembers(id, workspacePath) { + const stdout = await specifyRun(["bundle", "info", id, "--json"], workspacePath); + if (!stdout?.trim()) throw new Error(`Cannot inspect bundle ${id}; check that it is available to the Specify CLI.`); + let info; + try { + info = JSON.parse(stdout); + } catch { + throw new Error(`Specify returned invalid bundle metadata for ${id}.`); + } + return bundleSelectionMembers(info, id); +} + +export function bundleSelectionMembers(info, id) { + if (info?.id !== id || !Array.isArray(info.components)) { + throw new Error(`Specify returned incomplete bundle metadata for ${id}.`); + } + const members = info.components.filter((component) => + ["presets", "extensions"].includes(component.kind)).map((component) => ({ + kind: component.kind, + id: component.id, + })); + if (info.components.some((component) => + !["presets", "extensions", "workflows"].includes(component.kind)) + || members.some(({ id: memberId }) => + typeof memberId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(memberId))) { + throw new Error(`Specify returned unsupported bundle members for ${id}.`); + } + return { source: info.source, members }; +} + // Query `specify bundle list` for installed bundles. Tolerant of the // subcommand being absent — returns empty sets on any error so bundle // rendering degrades gracefully. diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs index 4c4e7b34..08a82152 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs @@ -112,6 +112,7 @@ export async function hydrateFromCatalogSources(inst, sources, cfg) { source: src.name, version: raw?.version ?? null, description: raw?.description ?? "", + tags: Array.isArray(raw?.tags) ? raw.tags.filter((tag) => typeof tag === "string") : [], active: !!installedId, downloadUrl: raw?.download_url ?? null, installAllowed: src.installAllowed !== false, diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/sources.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/sources.mjs index 1ce51d74..c22c071d 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/sources.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/sources.mjs @@ -34,6 +34,7 @@ export const PRESET_CATALOG_URL = { // ` and no CLI catalog registration is used. export const EXTENSION_CATALOG_URL = { default: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", + copilot: "https://raw.githubusercontent.com/github/spec-kit-copilot/main/spec-kit-extensions/catalog.json", community: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", }; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/e2e/canvas-designer.spec.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/e2e/canvas-designer.spec.mjs new file mode 100644 index 00000000..91fd8d26 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/e2e/canvas-designer.spec.mjs @@ -0,0 +1,56 @@ +import { test, expect } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/?token=e2e-token"); + await page.getByRole("tab", { name: "Phases" }).click(); + await page.getByRole("button", { name: "Generate canvas" }).click(); +}); + +test("opens a design-only dialog without enabling launch", async ({ page }) => { + const dialog = page.getByRole("dialog", { name: "Canvas designer setup" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("checkbox", { name: /Design preset/ })).toBeVisible(); + await expect(dialog.getByText("Other preset")).toHaveCount(0); + await expect(dialog.getByRole("button", { name: /Launch designer/ })).toBeDisabled(); + await dialog.getByRole("tab", { name: "Bundles" }).click(); + await expect(dialog.getByRole("checkbox", { name: /Design bundle/ })).toBeVisible(); + await expect(dialog.getByText("Other bundle")).toHaveCount(0); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toHaveCount(0); + await page.getByRole("button", { name: "Generate canvas" }).click(); + await expect(page.getByRole("dialog", { name: "Canvas designer setup" }) + .getByRole("checkbox", { name: /Design preset/ })).not.toBeChecked(); +}); + +test("confirms community selection and checks only listed design bundle members", async ({ page }) => { + const writes = []; + page.on("request", (request) => { + if (request.method() !== "GET") writes.push(request.url()); + }); + const dialog = page.getByRole("dialog", { name: "Canvas designer setup" }); + await dialog.getByRole("tab", { name: "Bundles" }).click(); + const community = dialog.getByRole("checkbox", { name: /Community bundle/ }); + await community.check(); + const warning = page.getByRole("dialog", { name: "Select community bundle?" }); + await expect(warning.getByText(/not reviewed, audited, or endorsed/)).toBeVisible(); + await warning.getByRole("button", { name: "Cancel" }).click(); + await expect(community).not.toBeChecked(); + await community.check(); + await warning.getByRole("button", { name: "Select anyway" }).click(); + await expect(community).toBeChecked(); + + await dialog.getByRole("checkbox", { name: /Design bundle/ }).check(); + await dialog.getByRole("tab", { name: "Presets" }).click(); + const presets = dialog.getByRole("tabpanel", { name: "Presets" }); + const preset = presets.getByRole("checkbox", { name: /Design preset/ }); + await expect(preset).toBeChecked(); + await expect(presets.getByText("Included by bundle: Design bundle")).toBeVisible(); + await expect(presets.getByText("Unlisted preset")).toHaveCount(0); + await preset.uncheck(); + await expect(preset).not.toBeChecked(); + await dialog.getByRole("tab", { name: "Extensions" }).click(); + await expect(dialog.getByRole("checkbox", { name: /Design extension/ })).toBeChecked(); + await expect(dialog.getByText("Unlisted extension")).toHaveCount(0); + await expect(dialog.getByRole("button", { name: /Launch designer/ })).toBeDisabled(); + expect(writes).toEqual([]); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/e2e/server.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/e2e/server.mjs new file mode 100644 index 00000000..8b023a00 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/e2e/server.mjs @@ -0,0 +1,57 @@ +import { createServer } from "node:http"; +import { createHandler } from "../server.mjs"; + +const snapshot = { + workspacePath: process.cwd(), + currentPhase: "constitution", + setup: { + pluginInstalled: true, + cliInstalled: true, + projectInitialized: true, + skillsReloaded: true, + }, + boot: { phase: "ready", steps: [] }, + phases: {}, + commands: [], + catalog: { + presets: [ + { id: "design-preset", name: "Design preset", source: "copilot", tags: ["canvas-design"] }, + { id: "other-preset", name: "Other preset", source: "copilot", tags: ["other"] }, + { id: "unlisted-preset", name: "Unlisted preset", source: "copilot" }, + ], + extensions: [ + { id: "design-extension", name: "Design extension", source: "copilot", tags: ["canvas-design"] }, + { id: "unlisted-extension", name: "Unlisted extension", source: "copilot", tags: ["other"] }, + ], + bundles: [ + { id: "design-bundle", name: "Design bundle", source: "copilot", tags: ["canvas-design"] }, + { id: "community-bundle", name: "Community bundle", source: "community", tags: ["canvas-design"] }, + { id: "other-bundle", name: "Other bundle", source: "copilot", tags: ["design"] }, + ], + }, +}; + +const members = { + "design-bundle": [ + { kind: "presets", id: "design-preset" }, + { kind: "presets", id: "unlisted-preset" }, + { kind: "extensions", id: "design-extension" }, + ], + "community-bundle": [], +}; + +const handler = createHandler({ + token: "e2e-token", + session: { send: async () => { throw new Error("E2E fixture must not dispatch a session"); } }, + log: async (message) => { console.error(message); }, + getState: async () => snapshot, + getInstance: () => ({ workspacePath: process.cwd() }), + broadcast: () => {}, + registerSse: (_req, res) => { res.on("close", () => {}); }, + inspectBundle: async (id) => ({ + source: id === "community-bundle" ? "community" : "copilot", + members: members[id] ?? [], + }), +}); + +createServer((req, res) => { void handler(req, res); }).listen(4177, "127.0.0.1"); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs index c097f3ab..3d26212c 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs @@ -282,13 +282,21 @@ async function hydrateCatalogs(inst) { builtin: true, priority: 1, }, + { + name: "copilot", + url: EXTENSION_CATALOG_URL.copilot, + description: "Copilot-specific Spec Kit extensions", + installAllowed: true, + builtin: true, + priority: 2, + }, { name: "community", url: EXTENSION_CATALOG_URL.community, description: "Community-contributed extensions", installAllowed: false, builtin: true, - priority: 2, + priority: 3, }, ]; inst.cachedExtensionCatalogSources = extBootstrap; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json index 0ebc7dac..995a4b35 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json @@ -9,6 +9,25 @@ "version": "2.0.0", "dependencies": { "js-yaml": "^5.2.3" + }, + "devDependencies": { + "@playwright/test": "^1.56.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha1-gIAgxG6La5H51ijNDry7Mr39i2w=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" } }, "node_modules/argparse": { @@ -38,6 +57,35 @@ "bin": { "js-yaml": "bin/js-yaml.mjs" } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha1-mbVvn2mxtwxE8Av4Sy/lI0iuJRE=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha1-5XZlvDKEbCE6w5oeTVvGIo52s3Y=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } } } } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package.json b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package.json index 1176be98..2deb16b4 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package.json +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package.json @@ -6,9 +6,13 @@ "description": "Copilot canvas extension: dynamic, preset-driven Spec Kit wizard.", "main": "extension.mjs", "scripts": { - "test": "node --test ./test/*.test.mjs" + "test": "node --test ./test/*.test.mjs", + "test:e2e": "playwright test" }, "dependencies": { "js-yaml": "^5.2.3" + }, + "devDependencies": { + "@playwright/test": "^1.56.1" } } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/playwright.config.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/playwright.config.mjs new file mode 100644 index 00000000..ec5a6dea --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/playwright.config.mjs @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = 4177; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + ...devices["Desktop Chrome"], + baseURL: `http://127.0.0.1:${port}`, + trace: "retain-on-failure", + }, + webServer: { + command: "node ./e2e/server.mjs", + url: `http://127.0.0.1:${port}/api/state?token=e2e-token`, + reuseExistingServer: false, + timeout: 15_000, + }, +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server.mjs index 91b71e2b..19aca9b6 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server.mjs @@ -49,6 +49,7 @@ import { } from "./server/handlers-ops.mjs"; import { handleNpmDiagnose, handleNpmRetry } from "./server/handlers-deps.mjs"; import { ensureEnvProbe } from "./env/probe-cache.mjs"; +import { inspectBundleMembers } from "./catalog/bundles.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_UI_DIR = join(__dirname, "ui"); @@ -83,6 +84,7 @@ export function createHandler(deps) { uiDir = DEFAULT_UI_DIR, sharedDir = DEFAULT_SHARED_DIR, token, + inspectBundle = inspectBundleMembers, } = deps; if (!token) throw new Error("createHandler requires deps.token"); @@ -149,6 +151,29 @@ export function createHandler(deps) { return jsonRes(res, 200, snapshot); } + if (method === "GET" && url.pathname === "/api/designer/bundle-members") { + const id = url.searchParams.get("id"); + const source = url.searchParams.get("source"); + if (!id || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id) + || !["community", "copilot"].includes(source)) { + return jsonError(res, 400, "invalid bundle id or source"); + } + const snapshot = await getState(); + if (!snapshot?.catalog?.bundles?.some((item) => item.id === id && item.source === source)) { + return jsonError(res, 404, "bundle not in the designer catalog"); + } + const cwd = getInstance()?.workspacePath; + if (!cwd) return jsonError(res, 400, "workspace path unavailable"); + try { + const info = await inspectBundle(id, cwd); + if (info.source !== source) throw new Error(`Bundle ${id} resolved from a different catalog.`); + return jsonRes(res, 200, { members: info.members }); + } catch (err) { + if (log) await log(`bundle inspection failed: ${err.message}`, "error"); + return jsonError(res, 502, err.message); + } + } + if (method === "GET" && url.pathname === "/api/events") { res.writeHead(200, { "Content-Type": "text/event-stream", diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/canvas-designer-dialog.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/canvas-designer-dialog.test.mjs new file mode 100644 index 00000000..eb9d1b46 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/canvas-designer-dialog.test.mjs @@ -0,0 +1,314 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { + canvasDesignEntries, + currentCanvasDesignerSelections, + freshCanvasDesignerSelections, + openCanvasDesignerDialog, +} from "../ui/canvas-designer-dialog.js"; +import { renderPipelineBanner } from "../ui/phase-runtime.js"; +import { state } from "../ui/state.js"; + +test("only Copilot and Community items with the exact canvas-design tag are offered", () => { + for (const kind of ["presets", "extensions", "bundles"]) { + const candidates = [ + { id: "wrong-case", source: "copilot", tags: ["Canvas-Design"] }, + { id: "partial", source: "copilot", tags: ["canvas-design-extra"] }, + { id: "string-tags", source: "community", tags: "canvas-design" }, + { id: "untagged", source: "community" }, + { id: "design", source: "community", tags: ["design"] }, + ...["copilot", "community"].map((source) => ({ + id: `${source}-design`, source, name: source, tags: ["canvas-design"], + })), + ]; + const snapshot = { catalog: { [kind]: [ + { id: "built-in", source: "default", tags: ["canvas-design"] }, + { source: "copilot", tags: ["canvas-design"] }, + null, + ...candidates, + ] } }; + const before = structuredClone(snapshot); + assert.deepEqual(canvasDesignEntries(snapshot, kind), candidates.slice(-2)); + assert.deepEqual(snapshot, before); + assert.deepEqual(canvasDesignEntries({}, kind), []); + assert.deepEqual(canvasDesignEntries({ catalog: { [kind]: null } }, kind), []); + } + assert.deepEqual(freshCanvasDesignerSelections(), { presets: [], extensions: [], bundles: [] }); +}); + +function fakeElement(dataset = {}) { + const handlers = {}; + return { + dataset, checked: false, disabled: false, hidden: false, textContent: "", + isConnected: true, classList: { toggle() {} }, + addEventListener(type, callback) { handlers[type] = callback; }, + click() { handlers.click?.({ target: this, currentTarget: this }); }, + change() { return handlers.change?.({ target: this }); }, + keydown(key) { handlers.keydown?.({ key, preventDefault() {} }); }, + setAttribute() {}, + focus() { globalThis.document.activeElement = this; }, + }; +} + +function fakeDialogDocument() { + const trigger = fakeElement(); + const root = { + html: "", nodes: new Map(), inputs: [], + set innerHTML(value) { + this.html = value; + this.nodes = new Map([".designer-modal", ".wizard-modal-close", ".wizard-modal-cancel", + ".designer-backdrop", ".designer-error"].map((selector) => [selector, fakeElement()])); + this.inputs = [...value.matchAll(/data-designer-kind="([^"]+)" data-designer-index="(\d+)"/g)] + .map(([, kind, index]) => { + const input = fakeElement({ designerKind: kind, designerIndex: index }); + input.note = fakeElement(); + input.parentElement = { querySelector: () => input.note }; + return input; + }); + this.tabs = ["presets", "extensions", "bundles"].map((kind) => fakeElement({ designerTab: kind })); + this.panels = this.tabs.map((tab) => fakeElement({ designerPanel: tab.dataset.designerTab })); + this.nodes.get(".designer-modal").querySelectorAll = () => [ + this.nodes.get(".wizard-modal-close"), ...this.tabs, ...this.inputs, + this.nodes.get(".wizard-modal-cancel"), + ]; + }, + get innerHTML() { return this.html; }, + querySelector(selector) { + return this.html ? this.nodes.get(selector) ?? null : null; + }, + querySelectorAll(selector) { + if (selector === "[data-designer-tab]") return this.tabs; + if (selector === "[data-designer-panel]") return this.panels; + if (selector === "[data-designer-kind]") return this.inputs; + const kind = selector.match(/^\[data-designer-kind="([^"]+)"\]$/)?.[1]; + if (kind) return this.inputs.filter((input) => input.dataset.designerKind === kind); + return []; + }, + replaceChildren() { this.innerHTML = ""; }, + }; + root.innerHTML = ""; + const document = { + activeElement: trigger, + querySelector(selector) { return selector === ".designer-modal" ? root.querySelector(selector) : null; }, + getElementById(id) { return id === "wizard-modal-root" ? root : null; }, + }; + return { root, document, trigger }; +} + +test("dialog shows empty states while never launching", () => { + const previousDocument = globalThis.document; + const previousSnapshot = state.snapshot; + const { root, document, trigger } = fakeDialogDocument(); + globalThis.document = document; + state.snapshot = { catalog: { presets: [], extensions: [], bundles: [] } }; + try { + openCanvasDesignerDialog(); + assert.match(root.innerHTML, /Canvas designer setup/); + assert.match(root.innerHTML, /settings and generation behavior/); + assert.match(root.innerHTML, /leaving the wizard's configuration unchanged/); + assert.doesNotMatch(root.innerHTML, /leaving this project's workflow configuration unchanged/); + assert.doesNotMatch(root.innerHTML, /settings, appearance, and generation behavior/); + assert.match(root.innerHTML, /No presets available in the Copilot or Community catalogs/); + assert.match(root.innerHTML, /No extensions available in the Copilot or Community catalogs/); + assert.match(root.innerHTML, /No bundles available in the Copilot or Community catalogs/); + assert.match(root.innerHTML, /designer-submit" disabled aria-label="Launch designer \(not available yet\)"/); + assert.doesNotMatch(root.innerHTML, /Canvas generator \(required\)|data-designer-kind=/); + assert.deepEqual(currentCanvasDesignerSelections(), freshCanvasDesignerSelections()); + openCanvasDesignerDialog(); + assert.equal(document.activeElement, root.querySelector(".wizard-modal-close")); + root.querySelector(".wizard-modal-cancel").click(); + assert.equal(root.innerHTML, ""); + assert.equal(document.activeElement, trigger); + assert.equal(currentCanvasDesignerSelections(), null); + openCanvasDesignerDialog(); + assert.deepEqual(currentCanvasDesignerSelections(), freshCanvasDesignerSelections()); + root.querySelector(".designer-modal").keydown("Escape"); + assert.equal(currentCanvasDesignerSelections(), null); + } finally { + root.replaceChildren(); + globalThis.document = previousDocument; + state.snapshot = previousSnapshot; + } +}); + +test("bundles check only listed members without locking them; direct choices and overlaps survive removal", async () => { + const previousDocument = globalThis.document; + const previousFetch = globalThis.fetch; + const previousWindow = globalThis.window; + const previousSnapshot = state.snapshot; + const { root, document } = fakeDialogDocument(); + globalThis.document = document; + globalThis.window = { confirm: () => true }; + state.snapshot = { catalog: { + presets: [ + { id: "shared", name: "Shared", source: "community", tags: ["canvas-design"] }, + { id: "hidden", name: "Hidden", source: "copilot" }, + ], + extensions: [], + bundles: [ + { id: "one", name: "First", source: "copilot", tags: ["canvas-design"] }, + { id: "two", name: "Second", source: "copilot", tags: ["canvas-design"] }, + ], + } }; + globalThis.fetch = async (url) => ({ + ok: true, + json: async () => ({ members: url.includes("id=one") + ? [{ kind: "presets", id: "shared" }, { kind: "presets", id: "hidden" }, + { kind: "extensions", id: "extra" }] + : [{ kind: "presets", id: "shared" }] }), + }); + try { + openCanvasDesignerDialog(); + const [preset, one, two] = root.inputs; + one.checked = true; + await one.change(); + assert.equal(preset.checked, true); + assert.equal(preset.disabled, false); + assert.deepEqual(currentCanvasDesignerSelections().presets, + [{ id: "shared", source: "community", approved: true }]); + assert.deepEqual(currentCanvasDesignerSelections().extensions, []); + assert.doesNotMatch(root.innerHTML, /data-designer-included-kind|extra|Hidden/); + one.checked = false; + await one.change(); + assert.equal(preset.checked, false); + assert.equal(preset.disabled, false); + preset.checked = true; + await preset.change(); + one.checked = true; + await one.change(); + assert.equal(preset.checked, true); + assert.equal(preset.disabled, false); + assert.equal(preset.note.textContent, "Included by bundle: First"); + preset.checked = false; + await preset.change(); + assert.deepEqual(currentCanvasDesignerSelections().presets, []); + assert.equal(preset.checked, false); + assert.equal(preset.note.textContent, "Included by bundle: First"); + two.checked = true; + await two.change(); + assert.equal(preset.checked, true); + assert.equal(preset.note.textContent, "Included by bundle: First, Second"); + preset.checked = true; + await preset.change(); + one.checked = false; + await one.change(); + assert.equal(preset.disabled, false); + assert.equal(preset.note.textContent, "Included by bundle: Second"); + assert.deepEqual(currentCanvasDesignerSelections().extensions, []); + two.checked = false; + await two.change(); + assert.equal(preset.disabled, false); + assert.equal(preset.checked, true); + assert.deepEqual(currentCanvasDesignerSelections().presets, + [{ id: "shared", source: "community", approved: true }]); + preset.checked = false; + await preset.change(); + assert.deepEqual(currentCanvasDesignerSelections(), freshCanvasDesignerSelections()); + } finally { + root.replaceChildren(); + globalThis.document = previousDocument; + globalThis.fetch = previousFetch; + globalThis.window = previousWindow; + state.snapshot = previousSnapshot; + } +}); + +test("failed bundle inspection leaves selections unchanged and shows an error", async () => { + const previousDocument = globalThis.document; + const previousFetch = globalThis.fetch; + const previousSnapshot = state.snapshot; + const { root, document } = fakeDialogDocument(); + globalThis.document = document; + state.snapshot = { catalog: { + presets: [], extensions: [], bundles: [{ id: "broken", source: "copilot", tags: ["canvas-design"] }], + } }; + globalThis.fetch = async () => ({ ok: false, json: async () => ({ error: "not found" }) }); + try { + openCanvasDesignerDialog(); + const [bundle] = root.inputs; + bundle.checked = true; + await bundle.change(); + assert.equal(bundle.checked, false); + assert.equal(bundle.disabled, false); + assert.deepEqual(currentCanvasDesignerSelections(), freshCanvasDesignerSelections()); + assert.match(root.querySelector(".designer-error").textContent, /not found/); + assert.equal(root.querySelector(".designer-error").hidden, false); + } finally { + root.replaceChildren(); + globalThis.document = previousDocument; + globalThis.fetch = previousFetch; + state.snapshot = previousSnapshot; + } +}); + +test("selection stays local, community confirmation can cancel, and reopen resets choices", async () => { + const previousDocument = globalThis.document; + const previousWindow = globalThis.window; + const previousSnapshot = state.snapshot; + const { root, document } = fakeDialogDocument(); + let approved = false; + globalThis.document = document; + globalThis.window = { confirm: () => approved }; + state.snapshot = { catalog: { + presets: [{ id: "copilot-style", name: "Style", source: "copilot", tags: ["canvas-design"] }], + extensions: [{ id: "community-style", name: "Extension", source: "community", + tags: ["canvas-design"], installAllowed: false }], + bundles: [], + } }; + try { + openCanvasDesignerDialog(); + assert.match(root.innerHTML, /Copilot/); + const [preset, extension] = root.inputs; + preset.checked = true; + await preset.change(); + assert.deepEqual(currentCanvasDesignerSelections().presets, + [{ id: "copilot-style", source: "copilot", approved: true }]); + extension.checked = true; + await extension.change(); + assert.equal(extension.checked, false); + assert.deepEqual(currentCanvasDesignerSelections().extensions, []); + approved = true; + extension.checked = true; + await extension.change(); + assert.deepEqual(currentCanvasDesignerSelections().extensions, + [{ id: "community-style", source: "community", approved: true }]); + assert.match(root.innerHTML, /designer-submit" disabled/); + extension.checked = false; + await extension.change(); + assert.deepEqual(currentCanvasDesignerSelections().extensions, []); + root.querySelector(".wizard-modal-close").click(); + openCanvasDesignerDialog(); + assert.deepEqual(currentCanvasDesignerSelections(), freshCanvasDesignerSelections()); + assert.equal(root.inputs.every((input) => !input.checked), true); + } finally { + root.replaceChildren(); + globalThis.document = previousDocument; + globalThis.window = previousWindow; + state.snapshot = previousSnapshot; + } +}); + +test("Phases header exposes Generate canvas even without steps", () => { + const previousDocument = globalThis.document; + const previousTab = state.activeTab; + const previousSnapshot = state.snapshot; + const banner = { hidden: true, innerHTML: "", querySelector: () => null }; + globalThis.document = { getElementById: (id) => id === "pipeline-banner" ? banner : null }; + state.activeTab = "phases"; + try { + state.snapshot = { pipeline: [] }; + renderPipelineBanner(); + assert.equal(banner.hidden, false); + assert.match(banner.innerHTML, /pipeline-generate" aria-label="Generate canvas"[^>]*>Generate canvas<\/button>/); + assert.match(banner.innerHTML, /pipeline-clear" data-action="clear" disabled/); + assert.match(banner.innerHTML, /pipeline-reset" data-action="reset"/); + const dialogSource = readFileSync(new URL("../ui/canvas-designer-dialog.js", import.meta.url), "utf8"); + assert.doesNotMatch(dialogSource, /\/api\/generator\/launch|submitGeneratorLaunch|create_session/); + assert.match(dialogSource, /designerSession: true/); + } finally { + globalThis.document = previousDocument; + state.activeTab = previousTab; + state.snapshot = previousSnapshot; + } +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs index 018cff07..bd5e0c79 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs @@ -5,6 +5,49 @@ import { loadPresetGraph, parseCommandFile } from "../composition/preset-loader. import { orderPresetsByCliList, parsePresetListOutput } from "../composition/preset-order.mjs"; import { resolveHooksForCommand } from "../pipeline/active-artifacts.mjs"; import { parseClarifications } from "../pipeline/canonical.mjs"; +import { hydrateFromCatalogSources } from "../catalog/shared.mjs"; +import { EXTENSION_CATALOG_URL } from "../catalog/sources.mjs"; +import { bundleSelectionMembers } from "../catalog/bundles.mjs"; + +test("bundle selection mirrors preset and extension components without treating workflows as choices", () => { + const info = { id: "architecture", source: "community", components: [ + { kind: "extensions", id: "rules" }, + { kind: "presets", id: "gate" }, + { kind: "workflows", id: "architect" }, + ] }; + assert.deepEqual(bundleSelectionMembers(info, "architecture"), { + source: "community", members: [ + { kind: "extensions", id: "rules" }, + { kind: "presets", id: "gate" }, + ], + }); + assert.throws(() => bundleSelectionMembers({ + ...info, components: [{ kind: "presets", id: "bad&command" }], + }, "architecture"), /unsupported bundle members/); +}); + +test("catalog hydration keeps only string tags for every item kind", async (t) => { + const entries = { + tagged: { tags: ["canvas-design", "appearance"] }, + missing: {}, + malformed: { tags: "canvas-design" }, + mixed: { tags: [null, 42, "canvas-design"] }, + }; + t.mock.method(globalThis, "fetch", async () => ({ + ok: true, + json: async () => ({ presets: entries, extensions: entries, bundles: entries }), + })); + for (const kind of ["preset", "extension", "bundle"]) { + const inst = {}; + await hydrateFromCatalogSources(inst, [{ name: "copilot", url: "https://example.test/catalog.json" }], { + kind, dataKey: `${kind}s`, outputField: "items", + }); + assert.deepEqual(inst.items.map((item) => item.tags), + [["canvas-design", "appearance"], [], [], ["canvas-design"]]); + } + assert.equal(EXTENSION_CATALOG_URL.copilot, + "https://raw.githubusercontent.com/github/spec-kit-copilot/main/spec-kit-extensions/catalog.json"); +}); describe("preset-loader", () => { // Tests for preset-loader.mjs — disk read + YAML parse + fallback. diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs index e2df6962..82ec6b6b 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { beforeEach, describe, test } from "node:test"; -import { flushClarifications, setViewersDeps } from "../ui/modals.js"; +import { flushClarifications, openCommunityInstallModal, setViewersDeps } from "../ui/modals.js"; import { clearClarifications, clearPhaseRunning, @@ -19,13 +19,90 @@ function installLocalStorage() { }; } -describe("modal clarification flushing", () => { +describe("wizard modals", () => { beforeEach(() => { installLocalStorage(); clearClarifications("speckit.plan"); clearPhaseRunning("speckit.plan"); }); + test("community warning preserves Catalogs install copy and supports designer selection", () => { + const previousDocument = globalThis.document; + const previousWindow = globalThis.window; + const trigger = { isConnected: true, focus() { document.activeElement = this; } }; + const nodes = new Map(); + const listeners = new Map(); + const element = () => { + const handlers = new Map(); + return { + hidden: false, + cloneNode() { return element(); }, + replaceWith(replacement) { + for (const [id, node] of nodes) if (node === this) nodes.set(id, replacement); + }, + addEventListener(type, handler) { handlers.set(type, handler); }, + click() { handlers.get("click")?.(); }, + focus() { document.activeElement = this; }, + }; + }; + for (const id of ["#cim-title-text", "#cim-preset-name", "#cim-kind-word", + "#cim-learn-link", "#cim-action", "#cim-destination", "#cim-confirm"]) { + nodes.set(id, element()); + } + const cancel = element(); + const close = element(); + const modal = { + hidden: true, + querySelector: (selector) => nodes.get(selector), + querySelectorAll: (selector) => selector === "[data-modal-close]" + ? [cancel, close] : [nodes.get("#cim-confirm"), cancel, close, nodes.get("#cim-learn-link")], + }; + const document = { + activeElement: trigger, + getElementById: (id) => id === "community-install-modal" ? modal : null, + addEventListener(type, handler) { listeners.set(type, handler); }, + removeEventListener(type) { listeners.delete(type); }, + }; + globalThis.document = document; + globalThis.window = { confirm: () => false }; + let confirmed = 0; + let cancelled = 0; + try { + openCommunityInstallModal({ + displayName: "Design extension", kind: "extension", designerSession: true, + onConfirm: () => confirmed++, onCancel: () => cancelled++, + }); + assert.equal(modal.hidden, false); + assert.equal(nodes.get("#cim-title-text").textContent, "Select community extension?"); + assert.equal(nodes.get("#cim-action").textContent, "You are about to select"); + assert.equal(nodes.get("#cim-destination").textContent, + "This selection will be installed in the launched Canvas designer session."); + assert.equal(nodes.get("#cim-confirm").textContent, "Select anyway"); + assert.equal(nodes.get("#cim-learn-link").href, + "https://github.com/github/spec-kit/blob/main/extensions/README.md"); + listeners.get("keydown")({ key: "Escape", preventDefault() {} }); + assert.equal(cancelled, 1); + assert.equal(modal.hidden, true); + assert.equal(document.activeElement, trigger); + assert.equal(listeners.size, 0); + + openCommunityInstallModal({ + displayName: "Catalog bundle", kind: "bundle", onConfirm: () => confirmed++, + }); + assert.equal(nodes.get("#cim-title-text").textContent, "Install community bundle?"); + assert.equal(nodes.get("#cim-destination").hidden, true); + assert.equal(nodes.get("#cim-action").textContent, "You are about to install"); + assert.equal(nodes.get("#cim-confirm").textContent, "Install anyway"); + nodes.get("#cim-confirm").click(); + assert.equal(confirmed, 1); + assert.equal(modal.hidden, true); + assert.equal(listeners.size, 0); + } finally { + globalThis.document = previousDocument; + globalThis.window = previousWindow; + } + }); + test("preserves answers added or edited while flush is in flight", async () => { const postedBodies = []; setViewersDeps({ diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs index 961e08ee..9d5246d4 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs @@ -133,6 +133,44 @@ test("createHandler throws without token", () => { assert.throws(() => createHandler({}), /token/); }); +test("designer bundle inspection validates catalog identity and returns members without dispatch", async () => { + const calls = []; + const deps = baseDeps({ + getState: async () => ({ catalog: { bundles: [{ id: "design-kit", source: "community" }] } }), + inspectBundle: async (id, cwd) => { + calls.push({ id, cwd }); + return { source: "community", members: [{ kind: "presets", id: "design-style" }] }; + }, + }); + const handler = createHandler(deps); + for (const query of ["id=bad%26command&source=community", "id=design-kit&source=copilot"]) { + const res = mockRes(); + await handler(mockReq({ url: `/api/designer/bundle-members?token=secret-token&${query}` }), res); + assert.ok(res.statusCode >= 400); + } + const res = mockRes(); + await handler(mockReq({ + url: "/api/designer/bundle-members?token=secret-token&id=design-kit&source=community", + }), res); + assert.equal(res.statusCode, 200); + assert.deepEqual(JSON.parse(res.body), { members: [{ kind: "presets", id: "design-style" }] }); + assert.deepEqual(calls, [{ id: "design-kit", cwd: "/proj" }]); + assert.deepEqual(deps._sessionCalls, []); +}); + +test("designer bundle inspection rejects mismatched CLI source explicitly", async () => { + const handler = createHandler(baseDeps({ + getState: async () => ({ catalog: { bundles: [{ id: "design-kit", source: "copilot" }] } }), + inspectBundle: async () => ({ source: "community", members: [] }), + })); + const res = mockRes(); + await handler(mockReq({ + url: "/api/designer/bundle-members?token=secret-token&id=design-kit&source=copilot", + }), res); + assert.equal(res.statusCode, 502); + assert.match(JSON.parse(res.body).error, /different catalog/); +}); + test("returns 401 when token is missing", async () => { const h = createHandler(baseDeps()); const req = mockReq({ method: "GET", url: "/api/state" }); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/canvas-designer-dialog.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/canvas-designer-dialog.js new file mode 100644 index 00000000..0149021a --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/canvas-designer-dialog.js @@ -0,0 +1,212 @@ +import { escapeHtml } from "./client.js"; +import { state, TOKEN } from "./state.js"; +import { openCommunityInstallModal } from "./modals.js"; + +const KINDS = [["presets", "Presets"], ["extensions", "Extensions"], ["bundles", "Bundles"]]; +let confirming = false; +let selections = null; +let bundleMembers = new Map(); +let deselectedMembers = new Set(); +let restoreFocus = null; + +export function canvasDesignEntries(snapshot, kind) { + const items = snapshot?.catalog?.[kind]; + return (Array.isArray(items) ? items : []).filter((item) => + item?.id && ["community", "copilot"].includes(item.source) + && Array.isArray(item.tags) && item.tags.includes("canvas-design")); +} + +export function freshCanvasDesignerSelections() { + return { presets: [], extensions: [], bundles: [] }; +} + +export function currentCanvasDesignerSelections() { + if (!selections) return null; + const result = structuredClone(selections); + for (const { members } of bundleMembers.values()) { + for (const { kind, id, source } of members) { + if (!deselectedMembers.has(`${kind}:${source}:${id}`) + && !result[kind].some((item) => item.id === id && item.source === source)) { + result[kind].push({ id, source, approved: true }); + } + } + } + return result; +} + +function closeDialog() { + document.getElementById("wizard-modal-root")?.replaceChildren(); + selections = null; + bundleMembers = new Map(); + deselectedMembers = new Set(); + if (restoreFocus?.isConnected) restoreFocus.focus(); + restoreFocus = null; +} + +function renderChoices(snapshot, kind, label) { + const items = canvasDesignEntries(snapshot, kind); + return `
+ ${items.length ? items.map((item, index) => ``).join("") : `

No ${label.toLowerCase()} available in the Copilot or Community catalogs.

`} +
`; +} + +function includedBy(kind, id, source) { + return [...bundleMembers.values()] + .filter(({ members }) => members.some((member) => + member.kind === kind && member.id === id && member.source === source)) + .map(({ bundle }) => bundle.name ?? bundle.id); +} + +function refreshBundleChoices(root, snapshot) { + for (const kind of ["presets", "extensions"]) { + const catalog = canvasDesignEntries(snapshot, kind); + root.querySelectorAll(`[data-designer-kind="${kind}"]`).forEach((input) => { + const item = catalog[Number(input.dataset.designerIndex)]; + const names = includedBy(kind, item.id, item.source); + const note = input.parentElement.querySelector(".designer-included-by"); + note.textContent = names.length ? `Included by bundle: ${names.join(", ")}` : ""; + note.hidden = !names.length; + input.title = note.textContent; + input.checked = selections[kind].some((entry) => + entry.id === item.id && entry.source === item.source) + || (!!names.length && !deselectedMembers.has(`${kind}:${item.source}:${item.id}`)); + }); + } +} + +async function inspectBundle(item) { + const params = new URLSearchParams({ id: item.id, source: item.source, token: TOKEN }); + const response = await fetch(`/api/designer/bundle-members?${params}`); + if (!response.ok) { + const body = await response.json(); + throw new Error(body.error ?? `Bundle inspection failed (${response.status}).`); + } + const { members } = await response.json(); + if (!Array.isArray(members)) throw new Error("Bundle inspection returned no member list."); + return members; +} + +export function openCanvasDesignerDialog() { + if (document.querySelector(".designer-modal")) return; + const snapshot = state.snapshot; + const root = document.getElementById("wizard-modal-root"); + if (!root) return; + restoreFocus = document.activeElement; + selections = freshCanvasDesignerSelections(); + bundleMembers = new Map(); + deselectedMembers = new Set(); + root.innerHTML = `
+
`; + const dialog = root.querySelector(".designer-modal"); + root.querySelector(".wizard-modal-close").addEventListener("click", closeDialog); + root.querySelector(".wizard-modal-cancel").addEventListener("click", closeDialog); + root.querySelector(".designer-backdrop").addEventListener("click", (event) => { + if (event.target === event.currentTarget) closeDialog(); + }); + root.querySelectorAll("[data-designer-tab]").forEach((tab) => tab.addEventListener("click", () => { + root.querySelectorAll("[data-designer-tab]").forEach((entry) => { + const active = entry === tab; + entry.classList.toggle("is-active", active); + entry.setAttribute("aria-selected", String(active)); + }); + root.querySelectorAll("[data-designer-panel]").forEach((panel) => { + panel.hidden = panel.dataset.designerPanel !== tab.dataset.designerTab; + }); + })); + root.querySelectorAll("[data-designer-kind]").forEach((input) => input.addEventListener("change", async () => { + if (confirming || input.disabled) return; + const dialogSelections = selections; + const kind = input.dataset.designerKind; + const item = canvasDesignEntries(snapshot, kind)[Number(input.dataset.designerIndex)]; + if (!item) return; + const error = root.querySelector(".designer-error"); + error.hidden = true; + error.textContent = ""; + if (input.checked && (item.installAllowed === false || item.source === "community")) { + confirming = true; + let approved; + try { + approved = await new Promise((resolve) => openCommunityInstallModal({ + displayName: item.name ?? item.id, + kind: kind.slice(0, -1), + designerSession: true, + onConfirm: () => resolve(true), + onCancel: () => resolve(false), + })); + } finally { + confirming = false; + } + if (selections !== dialogSelections) return; + if (!approved) { input.checked = false; input.focus(); return; } + } + if (kind === "bundles") { + const key = `${item.source}:${item.id}`; + if (input.checked) { + input.disabled = true; + try { + const members = await inspectBundle(item); + if (selections !== dialogSelections) return; + bundleMembers.set(key, { bundle: item, members: members.flatMap((member) => { + const match = canvasDesignEntries(snapshot, member.kind).find((candidate) => + candidate.id === member.id && candidate.source === item.source) + ?? canvasDesignEntries(snapshot, member.kind).find((candidate) => + candidate.id === member.id); + return match ? [{ ...member, source: match.source }] : []; + }) }); + for (const member of bundleMembers.get(key).members) { + deselectedMembers.delete(`${member.kind}:${member.source}:${member.id}`); + } + } catch (err) { + if (selections !== dialogSelections) return; + input.checked = false; + error.textContent = `Could not inspect ${item.name ?? item.id}: ${err.message}`; + error.hidden = false; + input.focus(); + return; + } finally { + input.disabled = false; + } + } else { + bundleMembers.delete(key); + } + } else { + const key = `${kind}:${item.source}:${item.id}`; + if (input.checked) deselectedMembers.delete(key); + else if (includedBy(kind, item.id, item.source).length) deselectedMembers.add(key); + } + selections[kind] = selections[kind].filter((entry) => + entry.id !== item.id || entry.source !== item.source); + if (input.checked) selections[kind].push({ + id: item.id, source: item.source, approved: true, + }); + if (kind === "bundles") refreshBundleChoices(root, snapshot); + })); + dialog.addEventListener("keydown", (event) => { + if (event.key === "Escape") { event.preventDefault(); closeDialog(); } + if (event.key !== "Tab") return; + const focusable = [...dialog.querySelectorAll("button:not([disabled]), input:not([disabled])")]; + if (!focusable.length) return; + if (event.shiftKey && document.activeElement === focusable[0]) { + event.preventDefault(); focusable.at(-1).focus(); + } else if (!event.shiftKey && document.activeElement === focusable.at(-1)) { + event.preventDefault(); focusable[0].focus(); + } + }); + root.querySelector(".wizard-modal-close").focus(); +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/index.html b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/index.html index 442b2ae6..422bc472 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/index.html +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/index.html @@ -254,12 +254,13 @@

Layers