mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 15:46:30 +03:00
Add browser questionnaire foundation
Prepared with AI assistance.
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}/`);
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.'));
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
Reference in New Issue
Block a user