Improve palette preview: dual artboard, drop register wiring.

Show landing and app wireframes side by side with palette-driven accents instead of PRODUCT.md register and /context.json, matching v4's per-surface modes. Assisted-by: Cursor AI agent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-09-01 10:02:39 +05:00
co-authored by Cursor
parent d64909decf
commit f7751fad0c
6 changed files with 197 additions and 317 deletions
+8
View File
@@ -15,6 +15,14 @@
],
"createdAt": "2026-07-17T00:00:00.000Z",
"reason": "Live overlay chrome is injected over arbitrary host pages and builds a self-contained UI with its own small type scale; DESIGN.md's ramp describes the impeccable website, not this widget"
},
{
"rule": "broken-image",
"value": "*",
"files": [
"picker/pages/index.astro"
],
"createdAt": "2026-07-23T14:51:18.241Z"
}
]
},
+35 -57
View File
@@ -104,6 +104,17 @@ const roles = [
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4.6 7.2A6.2 6.2 0 1 1 4 11.4M4.6 7.2V3.8m0 3.4H8"></path></svg>
</button>
<p
class="picker-palette-hint"
data-palette-hint
data-idle="Drag the rings on the image to pull colors from it, or edit any swatch directly."
data-primary="Your main brand color: buttons, links, the color people remember. Pick something with presence."
data-secondary="Supports the primary: section accents, hovers, secondary buttons. Adjacent, not identical."
data-tertiary="The rare accent: badges, highlights, one detail per screen. The most saturated of the four."
data-neutral="Backgrounds and large surfaces: most of every page. Quiet, near-white or near-black."
aria-live="polite"
>Drag the rings on the image to pull colors from it, or edit any swatch directly.</p>
<div class="picker-bands">
{roles.map(([role, label]) => (
<div class="picker-band-item" data-band-item={role}>
@@ -133,70 +144,37 @@ const roles = [
))}
</div>
<div class="picker-preview" data-register="brand" aria-hidden="true">
<div class="pv-brand">
<div class="pv-chrome"></div>
<div class="pv-brand-page">
<div class="pv-brand-nav">
<div class="pv-logo"></div>
<div class="pv-nav-bars"><i></i><i></i><i></i><i></i></div>
<div class="pv-primary-pill"></div>
</div>
<div class="pv-brand-hero">
<div class="pv-hero-copy">
<div class="pv-eyebrow"></div>
<div class="pv-headline"></div>
<div class="pv-copy-bars"></div>
<div class="pv-actions"><i></i><i></i></div>
</div>
<div class="pv-image"></div>
</div>
<div class="pv-features">
<div><i></i><span></span></div>
<div><i></i><span></span></div>
<div><i></i><span></span></div>
<div class="picker-preview" aria-hidden="true">
<div class="pv-desktop">
<div class="pv-nav">
<div class="pv-logo"></div>
<div class="pv-nav-bars"><i></i><i></i><i></i></div>
<div class="pv-pill"></div>
</div>
<div class="pv-hero">
<div class="pv-hero-copy">
<div class="pv-eyebrow"></div>
<div class="pv-headline"><i></i><i></i></div>
<div class="pv-copy-bar"></div>
<div class="pv-actions"><i></i><i></i></div>
</div>
<div class="pv-image"></div>
</div>
</div>
<div class="pv-product">
<div class="pv-chrome"></div>
<div class="pv-app">
<div class="pv-sidebar">
<div class="pv-logo"></div>
<div class="pv-active-nav"></div>
<div class="pv-side-bars"><i></i><i></i><i></i><i></i></div>
</div>
<div class="pv-app-main">
<div class="pv-topbar">
<div class="pv-search"></div>
<div class="pv-alert"></div>
<div class="pv-avatar"></div>
</div>
<div class="pv-stats">
<div><i></i><span></span></div>
<div><i></i><span></span></div>
<div><i></i><span></span></div>
</div>
<div class="pv-app-lower">
<div class="pv-chart"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="pv-app-aside"><span></span><i></i></div>
</div>
</div>
<div class="pv-phone">
<div class="pv-phone-top">
<div class="pv-logo"></div>
<div class="pv-avatar"></div>
</div>
<div class="pv-phone-body">
<div class="pv-mini-card"><i></i><span></span></div>
<div class="pv-mini-card"><i></i><span></span></div>
<div class="pv-mini-chart"><i></i><i></i><i></i><i></i><i></i></div>
</div>
<div class="pv-tabbar"><i></i><i></i><i></i><i></i></div>
</div>
</div>
<p
class="picker-palette-hint"
data-palette-hint
data-idle="Drag the rings on the image to pull colors from it, or edit any swatch directly."
data-primary="Your main brand color: buttons, links, the color people remember. Pick something with presence."
data-secondary="Supports the primary: section accents, hovers, secondary buttons. Adjacent, not identical."
data-tertiary="The rare accent: badges, highlights, one detail per screen. The most saturated of the four."
data-neutral="Backgrounds and large surfaces: most of every page. Quiet, near-white or near-black."
aria-live="polite"
>Drag the rings on the image to pull colors from it, or edit any swatch directly.</p>
<button class="ks-button ks-button-primary picker-select" type="button" data-select-palette data-advance="next" disabled>Select this palette</button>
</fieldset>
</div>
+1 -3
View File
@@ -324,9 +324,7 @@ document.addEventListener('picker:screenchange', (event) => activate(event.detai
try {
const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject());
const context = get('/context.json').catch(() => ({ register: 'brand' }));
const [cueData, seedData, contextData] = await Promise.all([get('/cues.json'), get('/palettes.json'), context]);
preview.dataset.register = contextData.register === 'product' ? 'product' : 'brand';
const [cueData, seedData] = await Promise.all([get('/cues.json'), get('/palettes.json')]);
cards = [
...cueData.cues.map((id) => ({ id, type: 'cue', palette: cueData.palette[id] })),
...seedData.seeds.map((seed) => ({ ...seed, type: 'seed' })),
+151 -217
View File
@@ -403,6 +403,32 @@ html.light .ks-button.ks-button-primary:active {
inset 0 0 0 1px color-mix(in srgb, var(--ks-champagne) 40%, transparent);
}
.picker-ring::after {
content: "";
position: absolute;
inset: 1px;
border: 2px solid white;
border-radius: 50%;
opacity: 0;
pointer-events: none;
transform: scale(0.78);
transition:
opacity 180ms var(--ks-ease),
transform 180ms var(--ks-ease);
}
.picker-palette:has(.picker-band[data-band="primary"]:is(:hover, :focus-within))
.picker-card[data-pos="0"] .picker-ring[data-role="primary"]::after,
.picker-palette:has(.picker-band[data-band="secondary"]:is(:hover, :focus-within))
.picker-card[data-pos="0"] .picker-ring[data-role="secondary"]::after,
.picker-palette:has(.picker-band[data-band="tertiary"]:is(:hover, :focus-within))
.picker-card[data-pos="0"] .picker-ring[data-role="tertiary"]::after,
.picker-palette:has(.picker-band[data-band="neutral"]:is(:hover, :focus-within))
.picker-card[data-pos="0"] .picker-ring[data-role="neutral"]::after {
opacity: 1;
transform: scale(1);
}
.picker-card[data-pos="0"] .picker-ring {
pointer-events: auto;
}
@@ -756,60 +782,49 @@ html.light .ks-button.ks-button-primary:active {
--pv-surface-2: color-mix(in oklab, var(--pv-neutral) 92%, var(--pv-n-ink));
--pv-bone: color-mix(in oklab, var(--pv-neutral) 62%, var(--pv-n-ink));
--pv-strong: color-mix(in oklab, var(--pv-neutral) 22%, var(--pv-n-ink));
--pv-chrome: color-mix(in oklab, var(--pv-neutral) 35%, var(--pv-n-ink));
--pv-secondary-wash: color-mix(in oklab, var(--pv-secondary) 28%, var(--pv-neutral));
--pv-tertiary-wash: color-mix(in oklab, var(--pv-tertiary) 22%, var(--pv-neutral));
width: 100%;
aspect-ratio: 16 / 7;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
}
/* Two artboards floating on the page itself: a landing page on the desktop
card (the palette persuading) and an app screen on the phone card (the
palette operating). Every palette is judged doing both jobs, so no
per-project switch is needed. The phone's fixed portrait ratio keeps the
wide frame from stretching a single page mock. */
.pv-desktop,
.pv-phone {
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--pv-neutral);
border: 1px solid var(--ks-rule);
border-radius: 2px;
}
.picker-preview[data-register="brand"] .pv-product,
.picker-preview[data-register="product"] .pv-brand {
display: none;
}
.pv-brand,
.pv-product {
width: 100%;
height: 100%;
background: var(--pv-neutral);
}
.pv-chrome {
position: relative;
height: 13%;
background: var(--pv-chrome);
}
.pv-chrome::before {
content: "";
position: absolute;
top: 50%;
left: 3.2%;
width: 5px;
aspect-ratio: 1;
background: var(--pv-bone);
border-radius: 50%;
box-shadow: 10px 0 var(--pv-bone), 20px 0 var(--pv-bone);
transform: translateY(-50%);
}
.pv-brand-page {
height: 87%;
.pv-desktop {
display: grid;
grid-template-rows: 20% 56% 24%;
grid-template-rows: auto 1fr;
border-radius: 4px;
}
.pv-brand-nav {
.pv-phone {
height: 100%;
aspect-ratio: 9 / 19;
display: grid;
grid-template-rows: auto 1fr auto;
border-radius: 10px;
}
.pv-nav {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 5%;
gap: 10px;
align-items: center;
padding-inline: 5%;
padding: 10px 12px;
}
.pv-logo {
@@ -822,74 +837,76 @@ html.light .ks-button.ks-button-primary:active {
.pv-nav-bars {
display: flex;
justify-content: flex-end;
gap: 5%;
gap: 8px;
}
.pv-nav-bars i,
.pv-side-bars i {
.pv-nav-bars i {
display: block;
width: 18px;
height: 3px;
background: var(--pv-bone);
border-radius: 2px;
}
.pv-nav-bars i {
width: 9%;
}
.pv-primary-pill {
.pv-pill {
width: 32px;
height: 12px;
background: var(--pv-primary);
border-radius: 2px;
}
.pv-brand-hero {
.pv-hero {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 8%;
align-items: center;
padding: 3% 9%;
padding: 4px 8% 14px;
}
.pv-hero-copy {
display: grid;
gap: 7px;
gap: 8px;
}
.pv-eyebrow {
width: 24%;
height: 7px;
background: var(--pv-tertiary-wash);
border-radius: 2px;
}
.pv-headline,
.pv-copy-bars {
position: relative;
width: 22%;
height: 4px;
background: var(--pv-strong);
background: var(--pv-tertiary);
border-radius: 2px;
}
/* The headline is display type set in the brand color, not permanent black
ink: it must visibly react when the primary changes. */
.pv-headline {
width: 86%;
height: 7px;
margin-block: 2px 7px;
box-shadow: 0 11px var(--pv-strong);
display: grid;
gap: 7px;
margin-block: 2px 3px;
}
.pv-copy-bars {
width: 66%;
margin-top: 3px;
.pv-headline i {
display: block;
width: 88%;
height: 8px;
background: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
border-radius: 2px;
}
.pv-headline i:last-child {
width: 58%;
}
.pv-copy-bar {
width: 64%;
height: 5px;
background: var(--pv-bone);
box-shadow: 0 8px var(--pv-bone);
border-radius: 2px;
}
.pv-actions {
display: flex;
gap: 7px;
margin-top: 9px;
margin-top: 10px;
}
.pv-actions i {
@@ -905,104 +922,20 @@ html.light .ks-button.ks-button-primary:active {
}
.pv-image {
height: 82%;
background: var(--pv-secondary-wash);
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
background: linear-gradient(135deg, var(--pv-secondary-wash), color-mix(in oklab, var(--pv-secondary) 52%, var(--pv-neutral)));
border: 1px solid var(--pv-secondary);
border-radius: 2px;
}
.pv-features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10%;
padding: 4% 11%;
border-top: 1px solid var(--pv-bone);
}
.pv-features > div {
display: grid;
gap: 5px;
}
.pv-features i {
width: 12px;
aspect-ratio: 1;
background: var(--pv-secondary-wash);
border-radius: 50%;
}
.pv-features > div:last-child i {
background: var(--pv-tertiary-wash);
}
.pv-features span,
.pv-app-aside span {
width: 70%;
height: 3px;
background: var(--pv-bone);
border-radius: 2px;
box-shadow: 0 6px var(--pv-bone);
}
.pv-app {
height: 87%;
display: grid;
grid-template-columns: 18% 82%;
}
.pv-sidebar {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 10%;
padding: 16% 14%;
background: var(--pv-surface-2);
border-right: 1px solid var(--pv-bone);
}
.pv-active-nav {
width: 100%;
height: 10px;
background: var(--pv-primary);
border-radius: 2px;
}
.pv-side-bars {
display: grid;
align-content: start;
gap: 9px;
}
.pv-side-bars i:nth-child(even) {
width: 72%;
}
.pv-app-main {
min-width: 0;
display: grid;
grid-template-rows: 20% 35% 45%;
background: var(--pv-neutral);
}
.pv-topbar {
display: flex;
align-items: center;
gap: 4%;
padding-inline: 5%;
background: var(--pv-surface-2);
border-bottom: 1px solid var(--pv-bone);
}
.pv-search {
width: 42%;
height: 9px;
margin-right: auto;
background: var(--pv-neutral);
border: 1px solid var(--pv-bone);
border-radius: 2px;
}
.pv-alert {
width: 6px;
.pv-image::after {
content: "";
position: absolute;
top: 9px;
right: 9px;
width: 7px;
aspect-ratio: 1;
background: var(--pv-tertiary);
border-radius: 50%;
@@ -1015,99 +948,99 @@ html.light .ks-button.ks-button-primary:active {
border-radius: 50%;
}
.pv-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3%;
padding: 4%;
/* Phone artboard: an app screen. Each mini card highlights a different
palette role so the secondary and tertiary are judged as UI accents,
not just as washes. */
.pv-phone-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 10px;
background: var(--pv-surface-2);
border-bottom: 1px solid var(--ks-rule);
}
.pv-stats > div {
position: relative;
.pv-phone-body {
min-height: 0;
display: grid;
align-content: center;
grid-template-rows: auto auto 1fr;
align-content: start;
gap: 7px;
padding: 9%;
padding: 9px 10px 10px;
}
.pv-mini-card {
display: grid;
gap: 5px;
padding: 8px 9px;
background: var(--pv-surface-2);
border: 1px solid var(--pv-bone);
border: 1px solid var(--ks-rule);
border-radius: 2px;
}
.pv-stats i,
.pv-stats span {
.pv-mini-card i,
.pv-mini-card span {
display: block;
height: 3px;
background: var(--pv-bone);
border-radius: 2px;
}
.pv-stats span {
width: 68%;
.pv-mini-card span {
width: 62%;
height: 6px;
background: var(--pv-strong);
background: var(--pv-secondary);
}
.pv-stats > div:last-child::after {
content: "";
position: absolute;
top: 15%;
right: 10%;
width: 5px;
aspect-ratio: 1;
.pv-mini-card:nth-child(2) span {
background: var(--pv-tertiary);
border-radius: 50%;
}
.pv-app-lower {
.pv-mini-chart {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr);
gap: 3%;
padding: 0 4% 4%;
}
.pv-chart,
.pv-app-aside {
min-width: 0;
display: flex;
align-items: flex-end;
gap: 4%;
padding: 8%;
gap: 4px;
padding: 8px 9px;
background: var(--pv-surface-2);
border: 1px solid var(--pv-bone);
border: 1px solid var(--ks-rule);
border-radius: 2px;
}
.pv-chart i {
.pv-mini-chart i {
flex: 1;
height: 28%;
height: 34%;
background: var(--pv-secondary);
border-radius: 2px 2px 0 0;
}
.pv-chart i:nth-child(2) { height: 50%; }
.pv-chart i:nth-child(3) { height: 72%; }
.pv-chart i:nth-child(4) { height: 42%; }
.pv-chart i:nth-child(5) { height: 86%; background: var(--pv-primary); }
.pv-chart i:nth-child(6) { height: 62%; }
.pv-chart i:nth-child(7) { height: 38%; }
.pv-mini-chart i:nth-child(2) { height: 58%; }
.pv-mini-chart i:nth-child(3) { height: 44%; }
.pv-mini-chart i:nth-child(4) { height: 82%; background: var(--pv-primary); }
.pv-mini-chart i:nth-child(5) { height: 66%; }
.pv-app-aside {
align-items: flex-start;
flex-direction: column;
justify-content: center;
.pv-tabbar {
display: flex;
justify-content: space-evenly;
align-items: center;
padding: 8px 0;
border-top: 1px solid var(--ks-rule);
}
.pv-app-aside i {
width: 32px;
height: 11px;
margin-top: 9px;
.pv-tabbar i {
width: 5px;
aspect-ratio: 1;
background: var(--pv-bone);
border-radius: 50%;
}
.pv-tabbar i:first-child {
background: var(--pv-primary);
border-radius: 2px;
}
.picker-palette-hint {
min-height: 3.2em;
padding-inline-end: 48px;
color: var(--ks-text-muted);
font-size: 0.84rem;
line-height: 1.6;
@@ -1136,6 +1069,7 @@ html.light .ks-button.ks-button-primary:active {
.picker-progress span,
.picker-card,
.picker-ring,
.picker-ring::after,
.picker-ring-guide,
.picker-icon-button,
.picker-band-tools,
-14
View File
@@ -10,7 +10,6 @@ import { readFile, mkdir, stat, writeFile } from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { extractSectionValue } from './context.mjs';
import { SEEDS } from './palette.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const pickerDir = path.join(scriptDir, 'picker');
@@ -170,15 +169,6 @@ if (options.help) {
process.exit(0);
}
let register = 'brand';
try {
const product = await readFile(path.resolve(process.cwd(), 'PRODUCT.md'), 'utf8');
const value = (extractSectionValue(product, 'Register') || '').toLowerCase();
if (value === 'brand' || value === 'product') register = value;
} catch {
// A missing or unreadable PRODUCT.md keeps the brand preview default.
}
const port = await findOpenPort(options.port);
let completed = false;
let timeout;
@@ -229,10 +219,6 @@ async function handleRequest(request, response) {
});
return;
}
if (requestPath === '/context.json') {
sendJson(response, 200, { register });
return;
}
if (requestPath.startsWith('/cues/')) {
const cueName = requestPath.slice('/cues/'.length);
if (!cueName || cueName.includes('/')) {
+2 -26
View File
@@ -40,7 +40,7 @@ before(() => {
});
});
async function createFixture(register = null) {
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 });
@@ -49,9 +49,6 @@ async function createFixture(register = null) {
path.join(cuesDir, 'cues.json'),
`${JSON.stringify(cueManifestFixture)}\n`,
);
if (register) {
await writeFile(path.join(cwd, 'PRODUCT.md'), `# Product\n\n## Register\n\n${register}\n`);
}
return { cwd, cuesDir };
}
@@ -182,7 +179,7 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
}
const exitPromise = waitForExit(server.processHandle);
const answers = { register: 'brand', direction: 'kinpaku' };
const answers = { cue: 'hero-01', direction: 'kinpaku' };
const submitResponse = await fetch(`${server.url}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -209,27 +206,6 @@ test('palette CLI still prints a seed', () => {
assert.match(output, /Seed color \(anchor for your primary brand color\):/);
});
test('serves the product register from PRODUCT.md', async (t) => {
const fixture = await createFixture('product');
const server = await startPicker(fixture.cwd, ['--port', String(portBase + 10)]);
await cleanup(t, fixture, server);
const response = await fetch(`${server.url}/context.json`);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /^application\/json/);
assert.deepEqual(await response.json(), { register: 'product' });
});
test('defaults picker context to brand without PRODUCT.md', async (t) => {
const fixture = await createFixture();
const server = await startPicker(fixture.cwd, ['--port', String(portBase + 11)]);
await cleanup(t, fixture, server);
const response = await fetch(`${server.url}/context.json`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { register: 'brand' });
});
test('picker color math round-trips sRGB and clips out-of-gamut OKLCH', async () => {
const {
contrastInk,