From ba7f3c8891a49f58e3523943ba707e67cbc54b12 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 17 Jul 2026 01:37:20 +0500 Subject: [PATCH] Add browser questionnaire foundation Prepared with AI assistance. --- .gitignore | 9 + package.json | 6 +- picker/astro.config.mjs | 20 ++ picker/layouts/Picker.astro | 30 +++ picker/pages/index.astro | 171 +++++++++++++++++ picker/styles/picker.css | 327 ++++++++++++++++++++++++++++++++ scripts/build-picker.mjs | 37 ++++ scripts/lib/utils.js | 7 +- scripts/release.mjs | 42 +++- scripts/test-suites.mjs | 5 +- skill/reference/document.md | 2 +- skill/reference/visual-cues.md | 12 +- skill/scripts/picker-server.mjs | 250 ++++++++++++++++++++++++ tests/build.test.js | 19 ++ tests/picker-server.test.mjs | 221 +++++++++++++++++++++ 15 files changed, 1140 insertions(+), 18 deletions(-) create mode 100644 picker/astro.config.mjs create mode 100644 picker/layouts/Picker.astro create mode 100644 picker/pages/index.astro create mode 100644 picker/styles/picker.css create mode 100644 scripts/build-picker.mjs create mode 100644 skill/scripts/picker-server.mjs create mode 100644 tests/picker-server.test.mjs diff --git a/.gitignore b/.gitignore index 4d3762678..f187251a5 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,12 @@ tmp/ # PNGs, the old card backup). The canonical generator is `bun run og-image` # (scripts/generate-og-image.js); this dir is throwaway and safe to delete. .og-build/ + +# Working plans for agent-executed features (browser questionnaire etc.). +# Local planning scratch between the planning agent and executing agents. +plan/ + +# Intermediate and generated output for the picker build. Release builds sync +# the generated picker into the tracked provider harness directories. +build-picker/ +skill/scripts/picker/ diff --git a/package.json b/package.json index 6cdb56656..a09f2ae30 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,12 @@ "LICENSE" ], "scripts": { + "build:picker": "node scripts/build-picker.mjs", "build:skills": "bun run scripts/build.js --skip-root-sync", "build:skills:release": "bun run scripts/build.js", - "build": "bun run build:skills && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist", - "build:release": "bun run build:skills:release && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist", + "build:site": "npx astro build", + "build": "bun run build:picker && bun run build:skills && bun run build:site && cp -R dist build/_data/dist", + "build:release": "bun run build:picker && bun run build:skills:release && bun run build:site && cp -R dist build/_data/dist", "build:browser": "node scripts/build-browser-detector.js", "build:extension": "node scripts/build-extension.js", "clean": "rm -rf dist build", diff --git a/picker/astro.config.mjs b/picker/astro.config.mjs new file mode 100644 index 000000000..93269ac59 --- /dev/null +++ b/picker/astro.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + srcDir: './picker', + outDir: './build-picker', + output: 'static', + build: { + assets: 'assets', + assetsPrefix: '.', + format: 'directory', + }, + devToolbar: { + enabled: false, + }, + vite: { + build: { + assetsInlineLimit: 0, + }, + }, +}); diff --git a/picker/layouts/Picker.astro b/picker/layouts/Picker.astro new file mode 100644 index 000000000..c7e6a01ad --- /dev/null +++ b/picker/layouts/Picker.astro @@ -0,0 +1,30 @@ +--- +import '../styles/picker.css'; + +interface Props { + title?: string; + description?: string; +} + +const { + title = 'Impeccable design interview', + description = 'Choose the design direction your agent will use.', +} = Astro.props; +--- + + + + + + + + {title} + + + + + + + + + diff --git a/picker/pages/index.astro b/picker/pages/index.astro new file mode 100644 index 000000000..b01a7da31 --- /dev/null +++ b/picker/pages/index.astro @@ -0,0 +1,171 @@ +--- +import Picker from '../layouts/Picker.astro'; +--- + + +
+ + +
+
+
+
+

Let's build your
design system.

+ +
+ +
+ +
+ +

Arrow keys to move

+
+
+
+
+ + + +
+
+ + + + +
diff --git a/picker/styles/picker.css b/picker/styles/picker.css new file mode 100644 index 000000000..2ba7a3942 --- /dev/null +++ b/picker/styles/picker.css @@ -0,0 +1,327 @@ +@import "../../site/styles/kinpaku-tokens.css"; +@import "../../site/styles/kinpaku-kit.css"; + +/* Scoped subset of the reset imported by site/styles/main.css through + site/styles/tokens.css (lines 12-27). */ +.picker-page, +.picker-page *, +.picker-page *::before, +.picker-page *::after { + box-sizing: border-box; +} + +.picker-page * { + margin: 0; +} + +.picker-page :is(button, input, textarea, select) { + font-family: inherit; +} + +body.picker-page { + margin: 0; + min-height: 100vh; + min-height: 100dvh; + background: var(--ks-lacquer); + color: var(--ks-text); + font-family: var(--ks-font); + font-size: var(--ks-type-body-size); + line-height: var(--ks-type-body-line); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* Copied from site/styles/light-mode.css lines 189-194. Light mode remaps + --ks-lacquer-deep to paper, so the primary button needs its dark ink value. */ +html.light .ks-button.ks-button-primary, +html.light .ks-button.ks-button-primary:hover, +html.light .ks-button.ks-button-primary:active { + color: oklch(14% 0.018 95); +} + +.picker-shell { + position: relative; + min-height: 100vh; + min-height: 100svh; + overflow: hidden; +} + +.picker-hero-art { + position: absolute; + inset: 0; + z-index: 0; + background-position: left center; + background-size: cover; + background-repeat: no-repeat; + filter: saturate(1.12) contrast(1.04); + pointer-events: none; +} + +#picker-form { + position: relative; + z-index: 1; +} + +.picker-screen { + min-height: 100vh; + min-height: 100svh; + display: grid; + align-items: center; +} + +.picker-screen:not([data-active]) { + display: none; +} + +/* Mirrors site/styles/home-rebuild.css's .hero-rebuild-container width math. */ +.picker-container { + position: relative; + width: 100%; + max-width: 1500px; + margin: 0 auto; + padding-inline: 56px; +} + +.picker-start { + max-width: 620px; + display: grid; + justify-items: start; + gap: 28px; +} + +.picker-title { + color: var(--ks-champagne); + font-family: var(--ks-font-display); + font-size: var(--ks-type-display-size); + font-weight: var(--ks-type-display-weight); + line-height: var(--ks-type-display-line); + letter-spacing: var(--ks-type-display-track); + text-wrap: balance; +} + +.picker-actions { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 4px; +} + +.picker-key-hint { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + margin-top: 12px; + color: var(--ks-text-muted); +} + +.picker-key-cluster { + display: grid; + gap: 4px; +} + +.picker-key-row { + display: flex; + gap: 4px; +} + +.picker-key-row-top { + justify-content: center; +} + +.picker-keycap { + min-width: 26px; + height: 26px; + display: inline-grid; + place-items: center; + padding-inline: 6px; + background: var(--ks-lacquer-raised); + border: 1px solid var(--ks-rule); + border-radius: 2px; + color: var(--ks-champagne); + font-family: var(--ks-mono); + font-size: var(--ks-type-mono-size); + line-height: 1; +} + +.picker-key-caption { + color: var(--ks-text-muted); + font-size: var(--ks-type-mono-size); + line-height: 1.5; +} + +.picker-placeholder { + display: grid; + justify-items: start; + gap: 28px; + color: var(--ks-text-muted); +} + +.picker-width-gate { + display: none; +} + +@media (max-width: 1199px) { + .picker-width-gate { + position: fixed; + inset: 0; + z-index: 10; + display: grid; + place-items: center; + padding: 40px; + background: var(--ks-lacquer); + color: var(--ks-text); + text-align: center; + } + + .picker-width-gate > div { + max-width: 560px; + display: grid; + gap: 12px; + justify-items: center; + } + + .picker-width-gate h2 { + color: var(--ks-champagne); + font-family: var(--ks-font-display); + font-size: var(--ks-type-headline-size); + font-weight: var(--ks-type-headline-weight); + line-height: var(--ks-type-headline-line); + } + + .picker-width-gate p { + color: var(--ks-text-muted); + } + + .picker-copy-link { + display: flex; + align-items: center; + gap: 14px; + width: min(100%, 480px); + min-width: 0; + margin-top: 14px; + padding: 13px 12px 13px 18px; + background: var(--ks-lacquer-raised); + border: 1px solid var(--ks-rule); + border-radius: 2px; + text-align: left; + } + + .picker-copy-link code { + flex: 1 1 auto; + min-width: 18ch; + overflow-x: auto; + color: var(--ks-link-on-paper, var(--ks-champagne)); + font-family: var(--ks-mono); + font-size: var(--ks-type-body-size); + letter-spacing: 0; + white-space: nowrap; + background: transparent; + border: 0; + padding: 0; + user-select: all; + } + + .picker-copy-link-button { + position: relative; + flex: none; + display: inline-grid; + place-items: center; + width: 40px; + height: 40px; + padding: 0; + border: 1px solid var(--ks-rule); + border-radius: 2px; + background: transparent; + color: var(--ks-link-on-paper, var(--ks-champagne)); + cursor: pointer; + transition: + background 180ms var(--ks-ease), + border-color 180ms var(--ks-ease), + color 180ms var(--ks-ease), + transform 120ms var(--ks-ease); + } + + .picker-copy-link-button:hover, + .picker-copy-link-button.is-copied { + border-color: currentColor; + background: var(--ks-lacquer); + } + + .picker-copy-link-button:active { + transform: scale(0.96); + } + + .picker-copy-link-button:focus-visible { + outline: 2px solid var(--ks-patina); + outline-offset: 3px; + } + + .picker-copy-link-icon, + .picker-copy-link-check { + grid-area: 1 / 1; + transition: + opacity 140ms var(--ks-ease), + transform 180ms var(--ks-ease); + } + + .picker-copy-link-icon { + opacity: 1; + transform: scale(1); + } + + .picker-copy-link-check { + opacity: 0; + transform: scale(0.86); + } + + .picker-copy-link-button.is-copied .picker-copy-link-icon { + opacity: 0; + transform: scale(0.82); + } + + .picker-copy-link-button.is-copied .picker-copy-link-check { + opacity: 1; + transform: scale(1); + } + + .picker-copy-link-button span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .picker-copy-status { + min-height: 1.5em; + font-size: var(--ks-type-mono-size); + } + + @media (max-width: 520px) { + .picker-width-gate { + padding-inline: 24px; + } + + .picker-copy-link { + gap: 10px; + padding-left: 14px; + } + + .picker-copy-link code { + overflow-x: visible; + overflow-wrap: anywhere; + white-space: normal; + } + } + + @media (prefers-reduced-motion: reduce) { + .picker-copy-link-button, + .picker-copy-link-icon, + .picker-copy-link-check { + transition: none; + } + } +} diff --git a/scripts/build-picker.mjs b/scripts/build-picker.mjs new file mode 100644 index 000000000..1b5265f77 --- /dev/null +++ b/scripts/build-picker.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { cp, copyFile, mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const buildDir = path.join(root, 'build-picker'); +const outputDir = path.join(root, 'skill/scripts/picker'); +const faviconSource = path.join(root, 'site/public/favicon.svg'); +const faviconOutput = path.join(outputDir, 'favicon.svg'); +const heroSource = path.join( + root, + 'site/public/assets/neo-kinpaku/candidates/finalists/m-01-v2-01-light.png', +); +const heroOutput = path.join(outputDir, 'assets/hero-light.jpg'); + +await rm(buildDir, { recursive: true, force: true }); +execFileSync( + 'bun', + ['x', 'astro', 'build', '--config', 'picker/astro.config.mjs'], + { cwd: root, stdio: 'inherit' }, +); + +await rm(outputDir, { recursive: true, force: true }); +await cp(buildDir, outputDir, { recursive: true }); +await mkdir(path.dirname(heroOutput), { recursive: true }); +await copyFile(faviconSource, faviconOutput); +await sharp(heroSource) + .resize({ width: 1536, withoutEnlargement: true }) + .jpeg({ quality: 80, mozjpeg: true }) + .toFile(heroOutput); +await rm(buildDir, { recursive: true, force: true }); + +console.log(`Built ${path.relative(root, outputDir)}/`); diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index d96eff378..d7577962b 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -109,9 +109,11 @@ function readSkillScripts(scriptsDir) { if (PER_PROJECT_SCRIPT_ARTIFACTS.has(entry.name)) continue; const relPath = path.relative(scriptsDir, entryPath).split(path.sep).join('/'); + const isBinary = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.woff', '.woff2'] + .includes(path.extname(entry.name).toLowerCase()); scripts.push({ name: relPath, - content: fs.readFileSync(entryPath, 'utf-8'), + content: fs.readFileSync(entryPath, isBinary ? undefined : 'utf-8'), filePath: entryPath, }); } @@ -699,7 +701,8 @@ export function replacePlaceholders(content, provider, commandNames = [], allSki * their command prefix from lib/provider.mjs, whose declaration is replaced * here by an exact string match. */ -export function replaceScriptProviderMarker(content, provider, buildProvider = provider) { +export function replaceScriptProviderMarker(content, provider) { + if (Buffer.isBuffer(content)) return content; const placeholders = PROVIDER_PLACEHOLDERS[provider] || PROVIDER_PLACEHOLDERS.cursor; const commandPrefix = placeholders.command_prefix || '/'; const prefixMarker = "export const IMPECCABLE_COMMAND_PREFIX = '/'; // @impeccable-provider-command-prefix"; diff --git a/scripts/release.mjs b/scripts/release.mjs index b5d1bc9f6..b5aca12c6 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -8,7 +8,8 @@ // For the skill component, also reruns `bun run build:release` and refuses if the // regenerated harness directories drift from what is committed. -import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'; import { execSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -88,6 +89,24 @@ function runMutating(cmd) { execSync(cmd, { cwd: repoRoot, stdio: 'inherit' }); } +function directoryHash(directory) { + if (!existsSync(directory)) return null; + const hash = createHash('sha256'); + + function addDirectory(current) { + for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const entryPath = path.join(current, entry.name); + const relative = path.relative(directory, entryPath).split(path.sep).join('/'); + hash.update(relative); + if (entry.isDirectory()) addDirectory(entryPath); + else if (entry.isFile()) hash.update(readFileSync(entryPath)); + } + } + + addDirectory(directory); + return hash.digest('hex'); +} + step(`Reading version from ${cfg.manifest}`); const manifest = JSON.parse(readFileSync(path.join(repoRoot, cfg.manifest), 'utf8')); const version = manifest.version; @@ -112,16 +131,19 @@ ok('clean'); if (cfg.buildCmd) { step(`Rebuilding outputs (${cfg.buildCmd})`); - if (dryRun) { - console.log(` [dry-run] ${cfg.buildCmd}`); - } else { - execSync(cfg.buildCmd, { cwd: repoRoot, stdio: 'inherit' }); - const postBuild = run('git status --porcelain'); - if (postBuild) { - fail(`Build produced uncommitted changes. Run \`${cfg.buildCmd}\`, commit the result, then re-run.\n${postBuild}`); - } - ok('build outputs match source'); + const pickerOutput = component === 'skill' + ? path.join(repoRoot, 'skill/scripts/picker') + : null; + const pickerHashBefore = pickerOutput ? directoryHash(pickerOutput) : null; + execSync(cfg.buildCmd, { cwd: repoRoot, stdio: 'inherit' }); + if (pickerHashBefore && directoryHash(pickerOutput) !== pickerHashBefore) { + fail(`Picker build output was stale. Run \`bun run build:picker\`, then re-run the release check.`); } + const postBuild = run('git status --porcelain'); + if (postBuild) { + fail(`Build produced uncommitted changes. Run \`${cfg.buildCmd}\`, commit the result, then re-run.\n${postBuild}`); + } + ok('build outputs match source'); } step('Checking HEAD is pushed to origin'); diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 9a241e769..03798b52d 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -25,8 +25,10 @@ export const SUITES = { description: 'Build, provider transforms, CLI helpers, context, and storage unit tests.', triggers: [ ...COMMON_INFRA_PATTERNS, + /^picker\//, /^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/, - /^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|font-match|data\/font-index|concept-seed|generate-image|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|font-fingerprint|font-index|hero-checks|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/, + /^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|font-match|data\/font-index|concept-seed|generate-image|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|font-fingerprint|font-index|hero-checks|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|picker|pin|surface-brief))/, + /^site\/(pages|content|components|layouts)\//, /^README(\.npm)?\.md$/, /^cli\/bin\//, ], @@ -70,6 +72,7 @@ export const SUITES = { 'tests/hook.test.mjs', 'tests/impeccable-paths.test.mjs', 'tests/openai-plugin.test.mjs', + 'tests/picker-server.test.mjs', 'tests/pin.test.mjs', 'tests/release.test.mjs', 'tests/doctor.test.mjs', diff --git a/skill/reference/document.md b/skill/reference/document.md index 0e9e0f96e..06f6dfa5c 100644 --- a/skill/reference/document.md +++ b/skill/reference/document.md @@ -408,7 +408,7 @@ Interview answers are words; a palette is easier picked by eye. Before writing t - **No usable native path, no key**: pause and {{ask_instruction}} whether the user wants generated visual cues to pick a palette by eye. *"I can generate a few small palette-and-mood images so you choose a direction visually instead of from descriptions. That needs an image-generation API key (FLUX and Google Nano Banana are supported out of the box; other providers work too), stored as `IMAGE_GEN_API_KEY` in `.impeccable/.env`. Add one, or skip straight to the seed?"* If a key arrives, write it to `.impeccable/.env` together with `IMAGE_GEN_PROVIDER` (`bfl` for FLUX, `gemini` for Nano Banana, the provider's own name for anything else; when the user does not say, let the wrapper infer it from the key). Confirm that file is listed in the project's `.gitignore` (add it if missing; a committed key is a leak), then load [image-api.md](image-api.md). Its shipped wrapper is the whole integration for the built-in providers; only a provider it does not know earns the project-local wrapper that file specifies. - **The user opts out, or no key arrives**: go to Step 5 and seed from the answers alone. -When generation is available, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through parallel or serial generation and `cues.json`. Do not restate its mechanics here or in chat. When its `cues.json` is written, tell the user the cues are ready and **end your turn**. The pick round is a separate later step; Steps 5-6 run after that pick, or immediately when the user opted out of generation. +When generation is available, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. Do not write DESIGN.md in that turn; Steps 5-6 run when a completed picker is handed to the later seed consumer, or immediately when the user opted out of generation. ### Step 5: Write seed DESIGN.md diff --git a/skill/reference/visual-cues.md b/skill/reference/visual-cues.md index fd30350d4..16ede437b 100644 --- a/skill/reference/visual-cues.md +++ b/skill/reference/visual-cues.md @@ -349,6 +349,14 @@ The script copies the hero untouched to `[slug].png` (removing a `[slug]-hero.pn Done when: `cues.json` lists one entry per completed palette and every listed slug has its hero PNG on disk. -## Step 6: Pause +## Step 6: Launch the picker -Tell the user in one or two lines that the visual cues are ready at `.impeccable/visual-cues/` (name the count), then end your turn. The pick round is a separate later step: do not show or describe the images, do not ask which the user prefers, and do not write DESIGN.md in this turn. +Tell the user in one line that the visual cues are ready at `.impeccable/visual-cues/` (name the count), then run `node {{scripts_path}}/picker-server.mjs` from the project root as a foreground command and parse its `PICKER_URL` line. + +- **The harness has a browser tool**: open the URL with it and let the user drive. The tool is a viewport only; never drive the questionnaire yourself, because the answers are the user's. +- **No browser tool**: tell the user *"The design picker is running at [URL]; open it in your browser and finish there."* Then wait on the foreground process. + +The server process exiting is the completion signal; never poll or watch the answers file while it runs. + +- **Exit 0**: read the `ANSWERS` path, tell the user the answers were received in one line, then stop. Do not show or describe the cues, ask for a pick in chat, or write DESIGN.md in this turn. +- **Exit 2**: tell the user the picker closed unanswered and that they can relaunch it with the same command. Never restart it unprompted. diff --git a/skill/scripts/picker-server.mjs b/skill/scripts/picker-server.mjs new file mode 100644 index 000000000..46ede6191 --- /dev/null +++ b/skill/scripts/picker-server.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +/** Browser questionnaire server (self-contained, zero dependencies). + * Serves picker files and cues, writes one JSON submission, then exits. + * Usage: node /picker-server.mjs [--port 8500] + * [--cues-dir .impeccable/visual-cues] [--timeout 60] + */ + +import http from 'node:http'; +import { readFile, mkdir, stat, writeFile } from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const pickerDir = path.join(scriptDir, 'picker'); +const answersPath = path.resolve(process.cwd(), '.impeccable/design-interview/answers.json'); +const MAX_BODY_BYTES = 1024 * 1024; +const MIME = new Map([ + ['.html', 'text/html; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.jpg', 'image/jpeg'], + ['.png', 'image/png'], + ['.svg', 'image/svg+xml'], + ['.json', 'application/json; charset=utf-8'], +]); +function printHelp() { + console.log(`Usage: node picker-server.mjs [options] + +Serve the Impeccable design picker and wait for one form submission. + +Options: + --port PORT Scan for an open port from PORT (default: 8500) + --cues-dir PATH Visual cues directory (default: .impeccable/visual-cues) + --timeout MINUTES Exit 2 if nothing submits (default: 60) + --help Show this help + +Output: + PICKER_URL URL Printed when the server is ready + ANSWERS PATH Printed after answers.json is written + +See reference/visual-cues.md for the canonical agent flow.`); +} + +function readOption(args, index) { + const arg = args[index]; + const equals = arg.indexOf('='); + if (equals !== -1) return { value: arg.slice(equals + 1), next: index }; + if (!args[index + 1] || args[index + 1].startsWith('--')) { + throw new Error(`${arg} requires a value`); + } + return { value: args[index + 1], next: index + 1 }; +} +function parseArgs(args) { + const options = { port: 8500, cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'), timeoutMinutes: 60 }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--help' || arg === '-h') return { help: true }; + if (!arg.startsWith('--port') && !arg.startsWith('--cues-dir') && !arg.startsWith('--timeout')) throw new Error(`Unknown option: ${arg}`); + + const { value, next } = readOption(args, index); + index = next; + if (arg.startsWith('--port')) options.port = Number(value); + if (arg.startsWith('--cues-dir')) options.cuesDir = path.resolve(process.cwd(), value); + if (arg.startsWith('--timeout')) options.timeoutMinutes = Number(value); + } + + if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error('--port must be an integer from 1 to 65535'); + if (!Number.isFinite(options.timeoutMinutes) || options.timeoutMinutes <= 0) throw new Error('--timeout must be a positive number of minutes'); + return options; +} +async function findOpenPort(start = 8500) { + if (start > 65535) throw new Error('No open picker port found'); + return new Promise((resolve) => { + const probe = net.createServer(); + probe.listen(start, '127.0.0.1', () => { + const port = probe.address().port; + probe.close(() => resolve(port)); + }); + probe.on('error', () => resolve(findOpenPort(start + 1))); + }); +} +function sendJson(response, statusCode, body) { + response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(body)); +} + +function httpError(statusCode, message) { + const error = new Error(message); + error.statusCode = statusCode; + return error; +} +function decodeRequestPath(rawUrl = '/') { + let decoded = rawUrl.split('?')[0]; + try { + for (let pass = 0; pass < 3; pass += 1) { + const next = decodeURIComponent(decoded); + if (next === decoded) break; + decoded = next; + } + } catch { + return null; + } + + decoded = decoded.replaceAll('\\', '/'); + if (decoded.includes('\0') || decoded.split('/').includes('..')) return null; + return decoded.startsWith('/') ? decoded : `/${decoded}`; +} + +function containedPath(baseDir, relativePath) { + const candidate = path.resolve(baseDir, relativePath); + const relative = path.relative(baseDir, candidate); + if (relative.startsWith('..') || path.isAbsolute(relative)) return null; + return candidate; +} + +async function serveFile(response, baseDir, relativePath, allowedExtensions = MIME.keys()) { + const filePath = containedPath(baseDir, relativePath); + const extension = path.extname(relativePath).toLowerCase(); + if (!filePath || ![...allowedExtensions].includes(extension) || !MIME.has(extension)) { + sendJson(response, 404, { error: 'Not found' }); + return; + } + + try { + const info = await stat(filePath); + if (!info.isFile()) throw new Error('Not a file'); + const body = await readFile(filePath); + response.writeHead(200, { + 'Content-Type': MIME.get(extension), + 'Content-Length': body.length, + }); + response.end(body); + } catch { + sendJson(response, 404, { error: 'Not found' }); + } +} + +async function readJsonBody(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB'); + chunks.push(chunk); + } + + let value; + try { + value = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw httpError(400, 'Body must be valid JSON'); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object'); + return value; +} + +let options; +try { + options = parseArgs(process.argv.slice(2)); +} catch (error) { + console.error(error.message); + process.exit(1); +} + +if (options.help) { + printHelp(); + process.exit(0); +} + +const port = await findOpenPort(options.port); +let completed = false; +let timeout; + +const server = http.createServer((request, response) => { + void handleRequest(request, response).catch((error) => { + if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message }); + else response.destroy(); + }); +}); + +async function handleRequest(request, response) { + const requestPath = decodeRequestPath(request.url); + if (!requestPath) { + sendJson(response, 400, { error: 'Invalid path' }); + return; + } + + if (request.method === 'POST' && requestPath === '/submit') { + if (completed) { + sendJson(response, 409, { error: 'Submission already received' }); + return; + } + const answers = await readJsonBody(request); + await mkdir(path.dirname(answersPath), { recursive: true }); + await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`); + completed = true; + clearTimeout(timeout); + response.once('finish', () => { + console.log(`ANSWERS ${answersPath}`); + server.close(() => process.exit(0)); + }); + sendJson(response, 200, { ok: true }); + return; + } + + if (request.method !== 'GET') { + sendJson(response, 405, { error: 'Method not allowed' }); + return; + } + if (requestPath === '/cues.json') { + await serveFile(response, options.cuesDir, 'cues.json', ['.json']); + return; + } + if (requestPath.startsWith('/cues/')) { + const cueName = requestPath.slice('/cues/'.length); + if (!cueName || cueName.includes('/')) { + sendJson(response, 404, { error: 'Not found' }); + return; + } + await serveFile(response, options.cuesDir, cueName, ['.png']); + return; + } + + const assetPath = requestPath === '/' ? 'index.html' : requestPath.slice(1); + await serveFile(response, pickerDir, assetPath); +} + +function stopWithoutSubmission(message) { + if (completed) return; + clearTimeout(timeout); + console.error(message); + server.close(() => process.exit(2)); + server.closeAllConnections?.(); +} + +server.listen(port, '127.0.0.1', () => { + console.log(`PICKER_URL http://127.0.0.1:${port}`); + timeout = setTimeout( + () => stopWithoutSubmission('Picker timed out without a submission.'), + options.timeoutMinutes * 60_000, + ); +}); + +server.on('error', (error) => { + console.error(`Picker server error: ${error.message}`); + process.exit(1); +}); +process.once('SIGINT', () => stopWithoutSubmission('Picker closed without a submission.')); +process.once('SIGTERM', () => stopWithoutSubmission('Picker closed without a submission.')); diff --git a/tests/build.test.js b/tests/build.test.js index 6f6865237..89502adff 100644 --- a/tests/build.test.js +++ b/tests/build.test.js @@ -166,6 +166,25 @@ This is a test skill body.`; expect(fs.existsSync(path.join(DIST_DIR, 'antigravity/.agent/skills/test-skill/SKILL.md'))).toBe(true); }); + test('integration: preserves binary skill assets byte-for-byte', () => { + const skillDir = path.join(TEST_DIR, 'skill'); + const assetDir = path.join(skillDir, 'scripts/picker/assets'); + const sourceBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0x00, 0x80]); + fs.mkdirSync(assetDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.src.md'), `---\nname: binary-skill\ndescription: Binary fixture\n---\n\nBody.`); + fs.writeFileSync(path.join(assetDir, 'hero.png'), sourceBytes); + + const DIST_DIR = path.join(TEST_DIR, 'dist'); + const { skills } = utils.readSourceFiles(TEST_DIR); + transformers.transformCursor(skills, DIST_DIR, utils.readPatterns(TEST_DIR)); + + const outputBytes = fs.readFileSync(path.join( + DIST_DIR, + 'cursor/.cursor/skills/binary-skill/scripts/picker/assets/hero.png', + )); + expect(outputBytes).toEqual(sourceBytes); + }); + test('integration: emits native subagent files for Codex, Claude Code, GitHub Copilot, and Cursor', () => { const skillContent = `--- name: test-skill diff --git a/tests/picker-server.test.mjs b/tests/picker-server.test.mjs new file mode 100644 index 000000000..b0affbbd8 --- /dev/null +++ b/tests/picker-server.test.mjs @@ -0,0 +1,221 @@ +/** + * Focused integration tests for the browser questionnaire server. + * Run with: node --test tests/picker-server.test.mjs + */ + +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { existsSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { before, test } from 'node:test'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const serverScript = path.join(root, 'skill/scripts/picker-server.mjs'); +const pickerIndex = path.join(root, 'skill/scripts/picker/index.html'); +const portBase = 18_500 + (process.pid % 500); +const cueManifestFixture = { + cues: ['hero-01'], + palette: { + 'hero-01': { + primary: { hex: '#1E4A42', snapped: '#1F4B42', at: [168, 252] }, + secondary: { hex: '#8C7251', snapped: '#8D7352', at: [424, 318] }, + tertiary: { hex: '#D8A82F', snapped: '#D7A930', at: [702, 190] }, + neutral: { hex: '#F2EFE8', snapped: '#F1EEE7', at: [86, 94] }, + }, + }, +}; + +before(() => { + if (existsSync(pickerIndex)) return; + execFileSync(process.execPath, [path.join(root, 'scripts/build-picker.mjs')], { + cwd: root, + stdio: 'inherit', + }); +}); + +async function createFixture() { + const cwd = await realpath(await mkdtemp(path.join(tmpdir(), 'impeccable-picker-'))); + const cuesDir = path.join(cwd, '.impeccable/visual-cues'); + await mkdir(cuesDir, { recursive: true }); + await writeFile(path.join(cuesDir, 'hero-01.png'), Buffer.from('fake-png')); + await writeFile( + path.join(cuesDir, 'cues.json'), + `${JSON.stringify(cueManifestFixture)}\n`, + ); + return { cwd, cuesDir }; +} + +async function startPicker(cwd, args = []) { + const processHandle = spawn(process.execPath, [serverScript, ...args], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + processHandle.stdout.setEncoding('utf8'); + processHandle.stderr.setEncoding('utf8'); + + let stdout = ''; + let stderr = ''; + let settled = false; + let resolveUrl; + let rejectUrl; + const urlPromise = new Promise((resolve, reject) => { + resolveUrl = resolve; + rejectUrl = reject; + }); + const timer = setTimeout(() => { + if (!settled) rejectUrl(new Error(`Server start timeout. stdout=${stdout} stderr=${stderr}`)); + }, 5000); + + processHandle.stdout.on('data', (chunk) => { + stdout += chunk; + const firstLine = stdout.split(/\r?\n/)[0]; + if (!settled && firstLine.startsWith('PICKER_URL ')) { + settled = true; + clearTimeout(timer); + resolveUrl({ firstLine, url: firstLine.slice('PICKER_URL '.length) }); + } + }); + processHandle.stderr.on('data', (chunk) => { stderr += chunk; }); + processHandle.once('error', (error) => { + if (!settled) rejectUrl(error); + }); + processHandle.once('exit', (code) => { + if (!settled) rejectUrl(new Error(`Server exited ${code}. stdout=${stdout} stderr=${stderr}`)); + }); + + const started = await urlPromise; + return { + ...started, + processHandle, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +async function waitForExit(processHandle) { + if (processHandle.exitCode !== null) return [processHandle.exitCode, processHandle.signalCode]; + return once(processHandle, 'exit'); +} + +async function cleanup(t, fixture, server) { + t.after(async () => { + if (server?.processHandle.exitCode === null) server.processHandle.kill('SIGTERM'); + await rm(fixture.cwd, { recursive: true, force: true }); + }); +} + +function rawGet(baseUrl, requestPath) { + const url = new URL(baseUrl); + return new Promise((resolve, reject) => { + const request = http.get({ + hostname: url.hostname, + port: url.port, + path: requestPath, + }, (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode)); + }); + request.once('error', reject); + }); +} + +test('serves picker and cues, writes submission, prints answers, and exits 0', async (t) => { + const fixture = await createFixture(); + const server = await startPicker(fixture.cwd, ['--port', String(portBase)]); + await cleanup(t, fixture, server); + + assert.equal(server.firstLine, `PICKER_URL ${server.url}`); + assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/); + + const pageResponse = await fetch(`${server.url}/`); + assert.equal(pageResponse.status, 200); + assert.match(pageResponse.headers.get('content-type'), /^text\/html/); + const pageHtml = await pageResponse.text(); + assert.match(pageHtml, /data-copy-url/); + assert.match(pageHtml, /Open this link in your browser at least 1200px wide/); + assert.match(pageHtml, /data-copy-url-value aria-label="Picker URL"><\/code>/); + assert.match(pageHtml, /aria-label="Copy link"/); + assert.match(pageHtml, />Start