Add browser questionnaire foundation

Prepared with AI assistance.
This commit is contained in:
Abdul Wahab
2026-09-01 10:02:05 +05:00
parent 1f20997a6d
commit ba7f3c8891
15 changed files with 1140 additions and 18 deletions
+9
View File
@@ -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/
+4 -2
View File
@@ -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",
+20
View File
@@ -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,
},
},
});
+30
View File
@@ -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;
---
<!doctype html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@300;400;500;600;700&family=Alumni+Sans:wght@100;300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body class="picker-page">
<slot />
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
---
import Picker from '../layouts/Picker.astro';
---
<Picker>
<main class="picker-shell">
<div class="picker-hero-art" style="background-image: url('assets/hero-light.jpg')" aria-hidden="true"></div>
<form id="picker-form">
<section class="picker-screen" data-screen="01" data-active aria-labelledby="picker-start-title">
<div class="picker-container">
<div class="picker-start">
<h1 id="picker-start-title" class="picker-title">Let's build your<br />design system.</h1>
<div class="picker-actions">
<button class="ks-button ks-button-primary" type="button" data-advance="next">
Start
<span class="ks-button-arrow" aria-hidden="true">
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square">
<path d="M0 4h14M10 0l4 4-4 4"></path>
</svg>
</span>
</button>
</div>
<div class="picker-key-hint">
<div class="picker-key-cluster" aria-hidden="true">
<div class="picker-key-row picker-key-row-top">
<kbd class="picker-keycap">↑</kbd>
</div>
<div class="picker-key-row">
<kbd class="picker-keycap">←</kbd>
<kbd class="picker-keycap">↓</kbd>
<kbd class="picker-keycap">→</kbd>
</div>
</div>
<p class="picker-key-caption">Arrow keys to move</p>
</div>
</div>
</div>
</section>
<!-- Phase 6 replaces this placeholder with the first question screen. -->
<section class="picker-screen" data-screen="02" aria-hidden="true" aria-labelledby="picker-placeholder-copy">
<div class="picker-container">
<div class="picker-placeholder">
<p id="picker-placeholder-copy">Next screen arrives with its spec</p>
<button class="ks-button ks-button-primary" type="submit">Finish</button>
</div>
</div>
</section>
</form>
</main>
<aside class="picker-width-gate" aria-labelledby="picker-width-title">
<div>
<h2 id="picker-width-title">This window is too narrow.</h2>
<p>Open this link in your browser at least 1200px wide.</p>
<div class="picker-copy-link">
<code data-copy-url-value aria-label="Picker URL"></code>
<button class="picker-copy-link-button" type="button" aria-label="Copy link" data-copy-url>
<svg class="picker-copy-link-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<svg class="picker-copy-link-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
<span>Copy link</span>
</button>
</div>
<p class="picker-copy-status" data-copy-status aria-live="polite">Paste it into your browser to continue.</p>
</div>
</aside>
<script>
const form = document.querySelector('#picker-form');
const copyUrlButton = document.querySelector('[data-copy-url]');
const copyUrlValue = document.querySelector('[data-copy-url-value]');
const copyStatus = document.querySelector('[data-copy-status]');
const screens = [...form.querySelectorAll('.picker-screen')];
let current = screens.findIndex((screen) => screen.hasAttribute('data-active'));
const browserUrl = new URL(window.location.href);
browserUrl.hostname = 'localhost';
const shareUrl = browserUrl.href.replace(/\/$/, '');
if (copyUrlValue) copyUrlValue.textContent = shareUrl;
const activeControls = () => [...screens[current].querySelectorAll(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href]',
)];
const goTo = (index) => {
if (!screens[index]) return;
screens.forEach((screen, screenIndex) => {
const active = screenIndex === index;
screen.toggleAttribute('data-active', active);
if (active) screen.removeAttribute('aria-hidden');
else screen.setAttribute('aria-hidden', 'true');
});
current = index;
activeControls()[0]?.focus();
};
const next = () => goTo(current + 1);
const prev = () => goTo(current - 1);
form.addEventListener('click', (event) => {
const control = event.target.closest('[data-advance]');
if (!control) return;
control.dataset.advance === 'prev' ? prev() : next();
});
document.addEventListener('keydown', (event) => {
if (event.defaultPrevented) return;
if (event.target instanceof Element && event.target.closest('input, textarea, select, [role="slider"]')) return;
const directions = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
if (current === 0 && event.key === 'Enter') {
if (window.matchMedia('(max-width: 1199px)').matches) return;
event.preventDefault();
screens[0].querySelector('[data-advance="next"]')?.click();
return;
}
if (event.key === 'Enter') {
const control = document.activeElement;
if (activeControls().includes(control)) {
event.preventDefault();
control.click();
}
return;
}
if (!(event.key in directions)) return;
event.preventDefault();
const controls = activeControls();
const focused = controls.indexOf(document.activeElement);
const index = focused < 0 ? 0 : (focused + directions[event.key] + controls.length) % controls.length;
controls[index]?.focus();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const answers = {};
for (const [name, value] of new FormData(form)) {
if (!(name in answers)) answers[name] = value;
else answers[name] = Array.isArray(answers[name]) ? [...answers[name], value] : [answers[name], value];
}
await fetch('/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(answers),
});
});
copyUrlButton?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(shareUrl);
copyUrlButton.classList.add('is-copied');
copyUrlButton.setAttribute('aria-label', 'Link copied');
copyStatus.textContent = 'Copied. Paste it into your browser to continue.';
window.setTimeout(() => {
copyUrlButton.classList.remove('is-copied');
copyUrlButton.setAttribute('aria-label', 'Copy link');
}, 1200);
} catch {
copyStatus.textContent = 'Copy failed. Select the link above and copy it instead.';
}
});
</script>
</Picker>
+327
View File
@@ -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;
}
}
}
+37
View File
@@ -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)}/`);
+5 -2
View File
@@ -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";
+32 -10
View File
@@ -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');
+4 -1
View File
@@ -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',
+1 -1
View File
@@ -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
+10 -2
View File
@@ -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.
+250
View File
@@ -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 <scripts_path>/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.'));
+19
View File
@@ -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
+221
View File
@@ -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<span class="ks-button-arrow"/);
assert.match(pageHtml, /rel="icon" type="image\/svg\+xml" href="\.\/favicon\.svg"/);
assert.match(pageHtml, /assets\/hero-light\.jpg/);
const stylesheet = pageHtml.match(/href="(\.\/assets\/[^"]+\.css)"/)?.[1];
assert.ok(stylesheet);
assert.equal((await fetch(new URL(stylesheet, `${server.url}/`))).status, 200);
const faviconResponse = await fetch(`${server.url}/favicon.svg`);
assert.equal(faviconResponse.status, 200);
assert.match(faviconResponse.headers.get('content-type'), /^image\/svg\+xml/);
assert.match(await faviconResponse.text(), /<svg/);
const heroResponse = await fetch(`${server.url}/assets/hero-light.jpg`);
assert.equal(heroResponse.status, 200);
assert.equal(heroResponse.headers.get('content-type'), 'image/jpeg');
assert.ok((await heroResponse.arrayBuffer()).byteLength > 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.');
});