Files
pbakaus_impeccable/picker/pages/index.astro
T
Abdul WahabandCursor c19c349fc6 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>
2026-09-01 10:02:05 +05:00

345 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import Picker from '../layouts/Picker.astro';
const roles = [
['primary', 'Primary'],
['secondary', 'Secondary'],
['tertiary', 'Tertiary'],
['neutral', 'Neutral'],
];
---
<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>
<section class="picker-screen" data-screen="02" aria-hidden="true" aria-labelledby="picker-palette-title">
<div class="picker-container">
<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>
<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>
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 url = new URL(location.href);
url.hostname = 'localhost';
const link = url.href.replace(/\/$/, '');
$('[data-copy-url-value]').textContent = link;
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) => {
const target = screens[index];
if (!target) 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;
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();
};
form.onclick = (e) => {
const control = e.target.closest('[data-advance]');
if (control) goTo(current + (control.dataset.advance === 'prev' ? -1 : 1));
};
document.addEventListener('keydown', (e) => {
if (e.defaultPrevented) return;
if (e.target instanceof Element && e.target.closest('input, textarea, select, [role="slider"]')) return;
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;
}
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.onsubmit = async (e) => {
e.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),
});
};
copyButton.onclick = async () => {
try {
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 {
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>