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';
+---
+
+
+
+
+
+
+
+
+
+
+
+
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 0);
+
+ const cueResponse = await fetch(`${server.url}/cues/hero-01.png`);
+ assert.equal(cueResponse.status, 200);
+ assert.equal(await cueResponse.text(), 'fake-png');
+ const cueManifest = await fetch(`${server.url}/cues.json`);
+ assert.equal(cueManifest.status, 200);
+ assert.deepEqual(await cueManifest.json(), cueManifestFixture);
+
+ const exitPromise = waitForExit(server.processHandle);
+ const answers = { register: 'brand', direction: 'kinpaku' };
+ const submitResponse = await fetch(`${server.url}/submit`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(answers),
+ });
+ assert.equal(submitResponse.status, 200);
+ assert.deepEqual(await submitResponse.json(), { ok: true });
+ assert.equal((await exitPromise)[0], 0);
+
+ const answersPath = path.join(
+ fixture.cwd,
+ '.impeccable/design-interview/answers.json',
+ );
+ assert.deepEqual(JSON.parse(await readFile(answersPath, 'utf8')), answers);
+ assert.match(server.stdout(), new RegExp(`ANSWERS ${answersPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
+});
+
+test('rejects raw, encoded, and double-encoded path traversal', async (t) => {
+ const fixture = await createFixture();
+ const server = await startPicker(fixture.cwd, ['--port', String(portBase + 20)]);
+ await cleanup(t, fixture, server);
+
+ const attempts = [
+ '/../../etc/hosts',
+ '/%2e%2e/%2e%2e/etc/hosts',
+ '/%252e%252e/%252e%252e/etc/hosts',
+ '/assets/..%2F..%2Fetc/hosts',
+ ];
+ for (const attempt of attempts) {
+ assert.ok([400, 404].includes(await rawGet(server.url, attempt)), attempt);
+ }
+
+ const exitPromise = waitForExit(server.processHandle);
+ await fetch(`${server.url}/submit`, {
+ method: 'POST',
+ body: JSON.stringify({ done: true }),
+ });
+ assert.equal((await exitPromise)[0], 0);
+});
+
+test('timeout exits 2 with one stderr line', async (t) => {
+ const fixture = await createFixture();
+ const server = await startPicker(fixture.cwd, [
+ '--port',
+ String(portBase + 40),
+ '--timeout',
+ '0.002',
+ ]);
+ await cleanup(t, fixture, server);
+
+ assert.equal((await waitForExit(server.processHandle))[0], 2);
+ assert.equal(server.stderr().trim(), 'Picker timed out without a submission.');
+});