mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +03:00
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>
345 lines
16 KiB
Plaintext
345 lines
16 KiB
Plaintext
---
|
||
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>
|