Add screen 02 palette picker: cue deck, sampling rings, band tools

Deck of cue cards + seed palettes with scroll-snap browsing, draggable
color-sampling rings with loupe, four-band panel (copy hex+OKLCH, tint
strip, native custom color, card-local reset), /palettes.json route.

AI-assisted (agent-implemented, maintainer-directed).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-09-01 10:02:05 +05:00
co-authored by Cursor
parent ba7f3c8891
commit c19c349fc6
7 changed files with 1839 additions and 172 deletions
+232 -59
View File
@@ -1,5 +1,12 @@
---
import Picker from '../layouts/Picker.astro';
const roles = [
['primary', 'Primary'],
['secondary', 'Secondary'],
['tertiary', 'Tertiary'],
['neutral', 'Neutral'],
];
---
<Picker>
@@ -40,15 +47,179 @@ import Picker from '../layouts/Picker.astro';
</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">
<section class="picker-screen" data-screen="02" aria-hidden="true" aria-labelledby="picker-palette-title">
<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 class="picker-palette">
<h1 id="picker-palette-title" class="picker-palette-title">Choose a color palette</h1>
<div class="picker-palette-grid">
<div class="picker-deck-column" aria-label="Palette sources">
<div class="picker-deck-stage">
<div class="picker-deck-scroller" data-deck-scroll aria-label="Browse palette cards">
<div data-deck-points></div>
</div>
<div class="picker-card-layer" data-deck-cards></div>
<div class="picker-loupe" data-loupe aria-hidden="true">
<canvas width="80" height="80"></canvas>
</div>
</div>
<template data-cue-card>
<article class="picker-card">
<div class="picker-card-face picker-cue-face">
<img alt="" />
{roles.map(([role, label]) => (
<button class="picker-ring" type="button" data-role={role} role="slider" aria-label={`${label} color picker`}></button>
))}
</div>
</article>
</template>
<template data-seed-card>
<article class="picker-card">
<div class="picker-card-face picker-seed-face">
{roles.map(() => <span></span>)}
</div>
</article>
</template>
<div class="picker-deck-controls">
<button class="picker-icon-button" type="button" data-deck-prev aria-label="Previous card">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 4.5-5 5.5 5 5.5"></path></svg>
</button>
<p class="picker-deck-count" data-deck-count aria-live="polite">Loading palettes</p>
<button class="picker-icon-button" type="button" data-deck-next aria-label="Next card">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</button>
</div>
<p class="picker-ring-guide" data-ring-guide>
<span>Drag the color rings to sample from the image.</span>
<span>Use arrow keys for precise adjustments.</span>
</p>
</div>
<fieldset class="picker-palette-panel">
<legend>Chosen colors</legend>
<button class="picker-icon-button picker-tip picker-reset" type="button" data-reset data-tip="Reset to this card's colors" aria-label="Reset to this card's colors">
<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>
<div class="picker-bands">
{roles.map(([role, label]) => (
<div class="picker-band-item" data-band-item={role}>
<div class="picker-band" data-band={role} tabindex="0">
<output data-band-hex={role}>#000000</output>
<div class="picker-band-tools" role="group" aria-label={`${label} color actions`}>
<button class="picker-band-tool picker-tip" type="button" data-copy-color={role} data-tip="Copy hex + OKLCH" aria-label={`Copy ${label} hex and OKLCH`}>
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="6.5" y="6.5" width="9" height="9"></rect><path d="M4.5 13.5h-1v-10h10v1"></path></svg>
</button>
<button class="picker-band-tool picker-tip" type="button" data-edit-tints={role} data-tip="Edit tints" aria-label={`Edit ${label} tints`}>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4 14.5-.4 2 2-.4L15 6.7 13.3 5 4 14.5Z"></path><path d="m11.9 6.4 1.7 1.7"></path></svg>
</button>
<button class="picker-band-tool picker-tip" type="button" data-custom-color={role} data-tip="Custom color" aria-label={`Choose custom ${label} color`}>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4v12M4 10h12"></path></svg>
</button>
</div>
<input class="picker-native-color" type="color" value="#000000" data-color-input={role} tabindex="-1" aria-label={`Custom ${label} color`} />
</div>
<div class="picker-band-foot">
<h2>{label}</h2>
<div class="picker-tint-strip" data-tints={role} hidden>
{Array.from({ length: 7 }, () => <button class="picker-tint" type="button" data-tint></button>)}
<button class="picker-tint-close" type="button" data-close-tints aria-label="Close tints">×</button>
</div>
</div>
</div>
))}
</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>
</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>
</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>
<input type="hidden" name="palette-source" />
{roles.map(([role]) => <input type="hidden" name={`palette-${role}`} />)}
</div>
</div>
</section>
<section class="picker-screen" data-screen="03" aria-hidden="true" aria-labelledby="picker-finish-copy">
<div class="picker-container">
<div class="picker-placeholder">
<p id="picker-finish-copy">Your palette is ready.</p>
<div class="picker-actions">
<button class="ks-button ks-button-ghost" type="button" data-advance="prev">Back</button>
<button class="ks-button ks-button-primary" type="submit">Finish</button>
</div>
</div>
</div>
</section>
<div class="picker-progress" aria-hidden="true"><span></span></div>
</form>
</main>
@@ -74,24 +245,27 @@ import Picker from '../layouts/Picker.astro';
</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]');
import '../scripts/palette-picker.js';
const $ = (selector, root = document) => root.querySelector(selector);
const form = $('#picker-form');
const copyButton = $('[data-copy-url]');
const status = $('[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 url = new URL(location.href);
url.hostname = 'localhost';
const link = url.href.replace(/\/$/, '');
$('[data-copy-url-value]').textContent = link;
const activeControls = () => [...screens[current].querySelectorAll(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href]',
const controls = () => [...screens[current].querySelectorAll(
'button:not([disabled]), input:not([disabled]):not([type="hidden"]):not([tabindex="-1"]), select:not([disabled]), textarea:not([disabled]), a[href]',
)];
const goTo = (index) => {
if (!screens[index]) return;
const target = screens[index];
if (!target) return;
screens.forEach((screen, screenIndex) => {
const active = screenIndex === index;
screen.toggleAttribute('data-active', active);
@@ -99,48 +273,44 @@ import Picker from '../layouts/Picker.astro';
else screen.setAttribute('aria-hidden', 'true');
});
current = index;
activeControls()[0]?.focus();
const id = target.dataset.screen;
form.dataset.current = id;
form.style.setProperty('--progress', (index + 1) / screens.length);
document.dispatchEvent(new CustomEvent('picker:screenchange', {
detail: { screen: id },
}));
controls()[0]?.focus();
};
const next = () => goTo(current + 1);
const prev = () => goTo(current - 1);
form.onclick = (e) => {
const control = e.target.closest('[data-advance]');
if (control) goTo(current + (control.dataset.advance === 'prev' ? -1 : 1));
};
form.addEventListener('click', (event) => {
const control = event.target.closest('[data-advance]');
if (!control) return;
control.dataset.advance === 'prev' ? prev() : next();
});
document.addEventListener('keydown', (e) => {
if (e.defaultPrevented) return;
if (e.target instanceof Element && e.target.closest('input, textarea, select, [role="slider"]')) return;
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();
if (e.key === 'Enter') {
if (current === 0 && matchMedia('(max-width: 1199px)').matches) return;
const control = current === 0 ? $('[data-advance="next"]', screens[0]) : document.activeElement;
if (controls().includes(control)) {
e.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();
const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
if (!(e.key in d)) return;
e.preventDefault();
const items = controls();
const focused = items.indexOf(document.activeElement);
const index = focused < 0 ? 0 : (focused + d[e.key] + items.length) % items.length;
items[index]?.focus();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
form.onsubmit = async (e) => {
e.preventDefault();
const answers = {};
for (const [name, value] of new FormData(form)) {
if (!(name in answers)) answers[name] = value;
@@ -151,21 +321,24 @@ import Picker from '../layouts/Picker.astro';
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(answers),
});
});
};
copyUrlButton?.addEventListener('click', async () => {
copyButton.onclick = 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');
await navigator.clipboard.writeText(link);
copyButton.classList.add('is-copied');
copyButton.setAttribute('aria-label', 'Link copied');
status.textContent = 'Copied. Paste it into your browser to continue.';
setTimeout(() => {
copyButton.classList.remove('is-copied');
copyButton.setAttribute('aria-label', 'Copy link');
}, 1200);
} catch {
copyStatus.textContent = 'Copy failed. Select the link above and copy it instead.';
status.textContent = 'Copy failed. Select the link above and copy it instead.';
}
});
};
form.dataset.current = screens[current].dataset.screen;
form.style.setProperty('--progress', (current + 1) / screens.length);
</script>
</Picker>