mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 10:36:27 +03:00
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:
+232
-59
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user