This commit is contained in:
Abdul Wahab
2026-09-01 10:02:39 +05:00
parent 4d919ec519
commit b4110667c9
8 changed files with 1647 additions and 288 deletions
+2
View File
@@ -64,6 +64,8 @@ TDD order is non-negotiable:
5. Add two adapters that wrap the pure check: `checkElementXxxDOM(el)` for the browser (`getComputedStyle` + `getBoundingClientRect`) and `checkElementXxx(el, tag, window)` for jsdom (`parseFloat(style.width)` instead of layout). Wire **both** adapters into **both** element loops in `cli/engine/detect-antipatterns.mjs` (browser loop ~line 1837, jsdom loop in `detectHtml` ~line 2058). Forgetting one is the most common mistake.
6. Verify on a live page at `http://localhost:4321/fixtures/antipatterns/{rule-id}.html` and on the homepage. The two adapter paths can disagree.
To scan the browser questionnaire, use `node cli/bin/cli.js detect skill/scripts/picker/` after `bun run build:picker`. Name the directory, never `index.html` on its own: the page links its stylesheet, so a page-only scan reads none of the picker's CSS. The baseline and the reason each accepted finding stands are in CLAUDE.md's **Picker anti-pattern gate**.
Conventions: wrap the identifying heading text in straight double quotes inside snippets so the fixture test can extract it. jsdom-specific helpers `resolveBackground()`, `resolveGradientStops()`, and `parseGradientColors()` exist because `background:` shorthand isn't decomposed and computed colors aren't normalized in jsdom — use them. Reference rules to copy from: `side-tab` (border), `low-contrast` (color+gradient), `icon-tile-stack` (sibling relationship), `flat-type-hierarchy` (page-level).
## Commit & Pull Request Guidelines
+21
View File
@@ -230,6 +230,27 @@ bun run build:browser
**IMPORTANT**: Always use `node` (not `bun`) to run the detect CLI. Bun's jsdom implementation is extremely slow and will cause scans with HTML files to hang for minutes.
### Picker anti-pattern gate
After `bun run build:picker`, scan the **built directory**:
```bash
node cli/bin/cli.js detect skill/scripts/picker/
```
**Name the directory, not `index.html`.** The built page only links its stylesheet, and the rules that read CSS text run per file, so scanning the page alone never reads a line of the picker's CSS. That is how three `design-system-color` findings vanished with nothing fixed: the mode-tile demo palette moved out of an inline `style` attribute and into `picker.css`, and the gate went quiet while the values stayed put. Naming the bundle by path is not a fix either, because Vite content-hashes the filename and the stale path silently narrows the scan back to the page.
Baseline: **39 findings**, 27 on `index.html` and 12 on the bundled stylesheet. The groups below are accepted with a reason on record, so hold 39 rather than treating it as a backlog:
- 18 `cramped-padding` on the palette strips, nine for each of the two: the editable one on screen 02 and the reading copy under screen 03's choices. Each strip reports its group, its four items, and its four swatches. A swatch is a color that has to reach its own edges, so `.picker-band` carries no padding and places its hex readout and drag grip by inset instead, which the rule has no way to see. Adding padding here would put a frame around every color the visitor picked.
- 3 `design-system-color` for the mode-tile demo palette (`#195856`, `#5f7c7a`, `#3a958b`). They are examples standing in for the palette the visitor is about to pick, which is the carve-out DESIGN.md's OKLCH-Only Rule already names. The argument is at the declaration in `picker/styles/picker.css`; read it before touching them.
- 7 `design-system-font-size` already waived for `picker/styles/picker.css` in `.impeccable/config.json`. They reappear under the built path only because that waiver is scoped to the source path.
- 2 `border-accent-on-rounded` on `.ks-bento` in the vendored `kinpaku-kit.css`, which carries no `border-radius` at all. The rule wants a radius on the same line, and minification puts the whole stylesheet on one line, so every line-scoped regex rule reads this bundle with no line context. Check a snippet against the source before acting on it.
The last 9 are the page findings the questionnaire has carried since those screens were built: `cramped-padding` on the option panel and the icon sheet, both edge-to-edge grids whose rows hold their own padding, and the mono readouts and micro-labels under the text-size floors. They are the group to revisit first if the number is ever worth lowering.
For line numbers during triage, `node cli/bin/cli.js detect picker/styles/` scans the unminified sources. It is not the gate: the source scan's style-context heuristic skips custom-property declarations, so it misses the palette entirely. A served URL is worse than either, because the browser pass only measures the screen in front of it and the picker hides all but the active one.
## Versioning
**Feature PRs do not bump versions and do not add changelog entries.** Bumping is a release step, not part of the change that earns the release: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened. Land the code first; the maintainer bumps and writes the changelog when cutting the release. This holds even though the "Bump when: ..." notes below name the source dirs — those say *which* component a change belongs to, not *when* to edit the manifest. The only PR that touches a manifest version is one whose purpose is the release itself.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+232 -51
View File
@@ -294,10 +294,7 @@ const questions = [
<label class="picker-mode-tile">
<input type="checkbox" name="surface-modes" value="persuade" aria-label="Landing pages, marketing, campaigns, and pricing" checked />
<span class="picker-mode-preview picker-mode-preview--exact" aria-hidden="true">
<span
class="picker-preview"
style="--pv-primary: #195856; --pv-secondary: #5F7C7A; --pv-tertiary: #3A958B; --pv-neutral: #EDEFEF; --pv-n-ink: var(--pk-ink-dark);"
>
<span class="picker-preview">
<span class="pv-desktop">
<span class="pv-nav">
<span class="pv-logo"></span>
@@ -336,21 +333,85 @@ const questions = [
<label class="picker-mode-tile">
<input type="checkbox" name="surface-modes" value="operate" aria-label="App UI, dashboards, admin, and settings" />
<span class="picker-mode-preview" aria-hidden="true">
<span class="pm-desktop pm-split">
<span class="pm-side"><i></i><i></i><i></i><i></i></span>
<span class="pm-main">
<span class="pm-cards"><i></i><i></i><i></i></span>
<span class="pm-rows"><i></i><i></i><i></i><i></i></span>
<span class="picker-mode-preview picker-mode-preview--exact" aria-hidden="true">
<span class="picker-preview picker-preview--ops">
<span class="pv-desktop">
<span class="pv-nav">
<span class="pv-logo"></span>
<span class="pv-nav-bars"><i></i><i></i><i></i></span>
<span class="pv-pill"></span>
</span>
<span class="po-body">
<span class="po-side">
<span class="po-item"><i class="po-dot"></i><i class="po-line"></i></span>
<span class="po-item po-item--on"><i class="po-dot"></i><i class="po-line"></i></span>
<span class="po-item"><i class="po-dot"></i><i class="po-line"></i></span>
</span>
<span class="po-main">
<span class="po-chart">
<i style="--h: 19.23cqh"></i>
<i style="--h: 26.17cqh"></i>
<i style="--h: 16cqh"></i>
<i class="po-peak" style="--h: 30.7cqh"></i>
<i style="--h: 22.3cqh"></i>
</span>
<span class="po-divider"></span>
<span class="po-rows">
<span class="po-row">
<i class="po-dot"></i>
<i class="po-cell" style="--w: 15.24cqh"></i>
<i class="po-cell" style="--w: 12.76cqh"></i>
<i class="po-cell" style="--w: 12.76cqh"></i>
</span>
<span class="po-row po-row--on">
<i class="po-dot"></i>
<i class="po-cell" style="--w: 15.24cqh"></i>
<i class="po-cell" style="--w: 12.76cqh"></i>
<i class="po-cell" style="--w: 12.61cqh"></i>
</span>
<span class="po-row">
<i class="po-dot"></i>
<i class="po-cell" style="--w: 15.24cqh"></i>
<i class="po-cell" style="--w: 12.76cqh"></i>
<i class="po-cell" style="--w: 12.61cqh"></i>
</span>
</span>
</span>
<span class="po-panel">
<span class="po-heading"></span>
<span class="po-field"><i class="po-chev"></i></span>
<span class="po-switch"><i class="po-label"></i><i class="po-toggle po-toggle--on"></i></span>
<span class="po-switch"><i class="po-label"></i><i class="po-toggle"></i></span>
</span>
</span>
</span>
</span>
<span class="pm-phone">
<span class="pm-phone-top"><i class="pm-dot"></i><i class="pm-bar pm-faint" style="--w:30%"></i></span>
<span class="pm-phone-body">
<span class="pm-cards pm-cards-2"><i></i><i></i></span>
<span class="pm-rows"><i></i><i></i><i></i></span>
<span class="pv-phone">
<span class="pv-phone-top">
<span class="pv-logo"></span>
<span class="pv-avatar"></span>
</span>
<span class="po-phone-body">
<span class="po-chart">
<i style="--h: 17.34cqh"></i>
<i style="--h: 25.42cqh"></i>
<i style="--h: 15.33cqh"></i>
<i class="po-peak" style="--h: 33.57cqh"></i>
<i style="--h: 23.93cqh"></i>
</span>
<span class="po-field"><i class="po-chev"></i></span>
<span class="po-prow">
<i class="po-dot"></i>
<i class="po-label"></i>
<i class="po-toggle po-toggle--on"></i>
</span>
<span class="po-prow">
<i class="po-dot"></i>
<i class="po-label"></i>
<i class="po-chev po-chev--next"></i>
</span>
</span>
<span class="pv-tabbar"><i></i><i></i><i></i><i></i></span>
</span>
<span class="pm-tabbar"><i></i><i></i><i></i><i></i></span>
</span>
</span>
<span class="picker-mode-copy">
@@ -361,28 +422,50 @@ const questions = [
<label class="picker-mode-tile">
<input type="checkbox" name="surface-modes" value="read" aria-label="Docs, articles, guides, and changelogs" />
<span class="picker-mode-preview" aria-hidden="true">
<span class="pm-desktop pm-landing">
<span class="pm-topbar"><i class="pm-dot"></i><span class="pm-links"><i></i><i></i></span></span>
<span class="pm-article">
<i class="pm-bar pm-strong" style="--w:62%"></i>
<i class="pm-bar pm-faint" style="--w:34%"></i>
<i class="pm-bar" style="--w:100%"></i>
<i class="pm-bar" style="--w:100%"></i>
<i class="pm-bar" style="--w:90%"></i>
<i class="pm-bar" style="--w:97%"></i>
<i class="pm-bar" style="--w:58%"></i>
<span class="picker-mode-preview picker-mode-preview--exact" aria-hidden="true">
<span class="picker-preview picker-preview--docs">
<span class="pv-desktop">
<span class="pv-nav">
<span class="pv-logo"></span>
<span class="pv-nav-bars"><i></i><i></i><i></i></span>
<span class="pv-pill"></span>
</span>
<span class="pd-body">
<span class="pd-side">
<span class="pd-item"><i></i><span></span></span>
<span class="pd-item pd-item--on"><i></i><span></span></span>
<span class="pd-item"><i></i><span></span></span>
<span class="pd-item"><i></i><span></span></span>
</span>
<span class="pd-main">
<span class="pd-heading" style="--w:95%"></span>
<span class="pd-para pd-para--lede"><i style="--w:71%"></i><i style="--w:41%"></i></span>
<span class="pd-para"><i></i><i></i><i style="--w:72%"></i></span>
<span class="pd-para"><i></i><i></i><i style="--w:43%"></i></span>
<span class="pd-note">
<i class="pd-note-dot"></i>
<span class="pd-note-lines"><i style="--w:85%"></i><i style="--w:62%"></i></span>
</span>
</span>
</span>
</span>
</span>
<span class="pm-phone">
<span class="pm-phone-body">
<i class="pm-bar pm-strong" style="--w:74%"></i>
<i class="pm-bar pm-faint" style="--w:40%"></i>
<i class="pm-bar" style="--w:100%"></i>
<i class="pm-bar" style="--w:100%"></i>
<i class="pm-bar" style="--w:88%"></i>
<i class="pm-bar" style="--w:96%"></i>
<i class="pm-bar" style="--w:64%"></i>
<span class="pv-phone">
<span class="pv-phone-top">
<span class="pv-logo"></span>
<span class="pv-avatar"></span>
</span>
<span class="pd-phone-body">
<span class="pd-crumb"></span>
<span class="pd-heading" style="--w:98.5%"></span>
<span class="pd-para pd-para--lede"><i style="--w:71%"></i><i style="--w:55%"></i></span>
<span class="pd-para"><i></i><i></i><i></i><i style="--w:69%"></i></span>
<span class="pd-para"><i></i><i></i><i style="--w:57%"></i></span>
<span class="pd-note">
<i class="pd-note-dot"></i>
<span class="pd-note-lines"><i style="--w:92%"></i><i style="--w:63%"></i></span>
</span>
</span>
<span class="pv-tabbar"><i></i><i></i><i></i><i></i></span>
</span>
</span>
</span>
@@ -394,19 +477,63 @@ const questions = [
<label class="picker-mode-tile">
<input type="checkbox" name="surface-modes" value="experience" aria-label="Portfolios, galleries, and showcases" />
<span class="picker-mode-preview" aria-hidden="true">
<span class="pm-desktop">
<span class="pm-mosaic">
<span class="pm-media"></span>
<span class="pm-media"></span>
<span class="pm-media"></span>
<span class="picker-mode-preview picker-mode-preview--exact" aria-hidden="true">
<span class="picker-preview picker-preview--gallery">
<span class="pv-desktop">
<span class="pv-nav">
<span class="pv-logo"></span>
<span class="pv-nav-bars"><i></i><i></i><i></i></span>
<span class="pv-pill"></span>
</span>
<span class="pg-body">
<span class="pg-row">
<span class="pv-image"></span>
<span class="pg-cap">
<i class="pg-cap-title" style="--w:33.61%"></i>
<i></i>
<i style="--w:65.3%"></i>
</span>
</span>
<span class="pg-row pg-row--flip">
<span class="pg-cap">
<i class="pg-cap-title" style="--w:36.64%"></i>
<i></i>
<i style="--w:66.07%"></i>
</span>
<span class="pv-image"></span>
</span>
<span class="pg-rail">
<i class="pg-arrow"></i>
<span class="pg-track">
<i class="pg-track-on" style="--w:20.31cqh"></i>
<i style="--w:9.14cqh"></i>
<i style="--w:8.98cqh"></i>
<i style="--w:12.44cqh"></i>
</span>
<i class="pg-arrow pg-arrow--next"></i>
</span>
</span>
</span>
</span>
<span class="pm-phone">
<span class="pm-phone-body pm-fill">
<span class="pm-media"></span>
<span class="pm-media"></span>
<i class="pm-bar pm-faint" style="--w:46%"></i>
<span class="pv-phone">
<span class="pv-phone-top">
<span class="pv-logo"></span>
<span class="pv-avatar"></span>
</span>
<span class="pg-phone-body">
<span class="pv-image"></span>
<span class="pg-cap">
<i class="pg-cap-title" style="--w:30.18%"></i>
<i style="--w:79.68%"></i>
<i style="--w:55.6%"></i>
</span>
<span class="pv-image"></span>
<span class="pg-cap">
<i class="pg-cap-title" style="--w:30.18%"></i>
<i style="--w:79.68%"></i>
<i style="--w:55.6%"></i>
</span>
</span>
<span class="pv-tabbar"><i></i><i></i><i></i></span>
</span>
</span>
</span>
@@ -508,12 +635,19 @@ const questions = [
{roles.map(([role, label]) => (
<div class="picker-band-item" data-band-item={role}>
<div class="picker-band" data-band={role} tabindex="0">
<button class="picker-band-grip picker-tip" type="button" data-grip={role} data-tip="Drag to reorder" aria-label={`Reorder the ${label} color`}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="7" cy="5" r="1.5"></circle><circle cx="13" cy="5" r="1.5"></circle>
<circle cx="7" cy="10" r="1.5"></circle><circle cx="13" cy="10" r="1.5"></circle>
<circle cx="7" cy="15" r="1.5"></circle><circle cx="13" cy="15" r="1.5"></circle>
</svg>
</button>
<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`}>
<button class="picker-band-tool picker-tip" type="button" data-edit-tints={role} data-tip="Edit tints" aria-expanded="false" 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`}>
@@ -533,6 +667,8 @@ const questions = [
))}
</div>
<p class="picker-band-status" data-reorder-status role="status" aria-live="polite"></p>
<div class="picker-preview" aria-hidden="true">
<div class="pv-desktop">
<div class="pv-nav">
@@ -627,6 +763,28 @@ const questions = [
</span>
</label>
</fieldset>
<div class="picker-bands picker-bands--compact" data-band-scope="strategy">
{roles.map(([role, label]) => (
<div class="picker-band-item" data-band-item={role}>
<div class="picker-band" data-band={role}>
<button class="picker-band-grip picker-tip" type="button" data-grip={role} data-tip="Drag to reorder" aria-label={`Reorder the ${label} color`}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="7" cy="5" r="1.5"></circle><circle cx="13" cy="5" r="1.5"></circle>
<circle cx="7" cy="10" r="1.5"></circle><circle cx="13" cy="10" r="1.5"></circle>
<circle cx="7" cy="15" r="1.5"></circle><circle cx="13" cy="15" r="1.5"></circle>
</svg>
</button>
<output data-band-hex={role}>#000000</output>
</div>
<div class="picker-band-foot">
<h2>{label}</h2>
</div>
</div>
))}
</div>
<p class="picker-band-status" data-reorder-status role="status" aria-live="polite"></p>
</div>
<Artboard class="picker-strategy-preview" strategyHook />
@@ -1010,9 +1168,32 @@ const questions = [
$('[data-progress-name]').textContent = name || '';
};
// The palette screen and the strategy screen are the one pair in the run
// that shows the same objects twice, so they are the pair whose change is
// worth animating: the four colors and the test page travel between the two
// positions instead of being redrawn. Everywhere else there is nothing to
// carry, and capturing a screen that is mid-animation reads worse than a
// plain swap does.
const MORPH = new Set(['02', '03']);
const morphs = (from, to) => (
typeof document.startViewTransition === 'function'
&& !matchMedia('(prefers-reduced-motion: reduce)').matches
&& MORPH.has(from?.dataset.screen)
&& MORPH.has(to?.dataset.screen)
);
const goTo = (index) => {
const target = screens[index];
if (!target) return;
if (morphs(screens[current], target)) {
document.startViewTransition(() => swap(index));
return;
}
swap(index);
};
const swap = (index) => {
const target = screens[index];
screens.forEach((screen, screenIndex) => {
const active = screenIndex === index;
screen.toggleAttribute('data-active', active);
+387 -6
View File
@@ -12,7 +12,7 @@ const panel = $('.picker-palette-panel');
const hint = $('[data-palette-hint]');
const ringGuide = $('[data-ring-guide]');
const loupe = $('[data-loupe]');
const preview = $('.picker-preview');
let preview = $('.picker-preview');
const typePreview = document.querySelector('[data-type-preview]');
const fontOptions = document.querySelector('[data-font-options]');
const pairTemplate = document.querySelector('[data-pair-card]');
@@ -178,6 +178,9 @@ function renderBand(role) {
}
function renderPreview() {
// The preview is swapped for the chosen mode's variant before the deck is
// dealt, so this runs at least once with no card to read a color off.
if (!cards.length) return;
for (const role of ROLES) preview.style.setProperty(`--pv-${role}`, state().colors[role]);
preview.style.setProperty('--pv-n-ink', contrastInk(state().colors.neutral));
}
@@ -1097,10 +1100,19 @@ function buildCard(item) {
function closeTints() {
if (!openTint) return;
const item = $(`[data-band-item="${openTint}"]`, panel);
const role = openTint;
const item = $(`[data-band-item="${role}"]`, panel);
const strip = $('[data-tints]', item);
// Focus cannot stay on a button that is about to be hidden, and the control
// that opened the strip is the one the user is back to deciding about.
const held = strip.contains(document.activeElement);
delete item.dataset.tintOpen;
$('[data-tints]', item).hidden = true;
strip.hidden = true;
openTint = null;
const toggle = $(`[data-edit-tints="${role}"]`, panel);
toggle.setAttribute('aria-expanded', 'false');
toggle.dataset.tip = 'Edit tints';
if (held) toggle.focus();
}
function render() {
@@ -1138,6 +1150,8 @@ function deckKeys(e) {
const delta = { ArrowLeft: -1, ArrowRight: 1 }[e.key];
if (!delta) return;
if (e.target instanceof Element && e.target.closest('[role="slider"], input')) return;
// A band held by the keyboard owns the arrows until it is dropped.
if (drag?.keyboard) return;
e.preventDefault();
browse(current + delta);
}
@@ -1171,9 +1185,319 @@ function openTints(role) {
});
strip.hidden = false;
item.dataset.tintOpen = '';
const toggle = $(`[data-edit-tints="${role}"]`, panel);
toggle.setAttribute('aria-expanded', 'true');
toggle.dataset.tip = 'Close tints';
$('button', strip)?.focus();
}
/* Reordering the palette.
The four slots keep their roles. Their labels sit in the feet and hold still;
what a drag carries is the color, so dropping the neutral band in second
place is what makes that color the secondary.
The bands travel and the feet do not, which is also what makes the swap
invisible. When a band lands, every slot on screen is already showing the
color it is about to be given, so the colors can be rearranged and the
transforms dropped in the same frame with nothing to see.
Pointer events rather than HTML5 drag and drop: the native API cannot be
animated and behaves badly by touch. */
const REORDER_MS = 220;
const LIFT_SCALE = 1.02;
const EASE = getComputedStyle(document.documentElement).getPropertyValue('--ks-ease').trim() || 'ease';
const REORDER_KEYS = new Set([' ', 'Enter', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Escape']);
const moves = new WeakMap();
// The editable strip on the palette screen, and its reading copy under the
// strategy screen's choices. Which one a drag started in decides how far the
// result has to be carried.
const paletteBands = $('.picker-bands', panel);
const strategyBands = document.querySelector('[data-band-scope="strategy"]');
const reduceMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
const bandNodes = (scope) => ROLES.map((role) => $(`[data-band="${role}"]`, scope));
const gripNodes = (scope) => ROLES.map((role) => $(`[data-grip="${role}"]`, scope));
const roleLabel = (index, scope) => $(`[data-band-item="${ROLES[index]}"] .picker-band-foot h2`, scope).textContent;
const announceReorder = (scope, message) => {
const status = $('[data-reorder-status]', scope.closest('.picker-screen') ?? scope);
if (status) status.textContent = message;
};
let drag = null;
let landing = null;
/* Which band stands in which slot while the lifted one is headed for `to`:
the entry at slot n is the index of the band that belongs there. */
function slotOrder(from, to) {
const order = ROLES.map((_, index) => index);
order.splice(to, 0, ...order.splice(from, 1));
return order;
}
/* FLIP: the node is put where it belongs first and then played back from where
it was, so nothing downstream ever measures a half-finished position. */
function travel(node, previous, offset, animate) {
node.style.transform = offset ? `translateX(${offset}px)` : '';
moves.get(node)?.cancel();
if (!animate) return;
moves.set(node, node.animate(
[{ transform: `translateX(${previous}px)` }, { transform: `translateX(${offset}px)` }],
{ duration: REORDER_MS, easing: EASE },
));
}
function lift(offset, animate) {
const node = drag.nodes[drag.from];
const previous = drag.offsets[drag.from];
const at = (value) => `translateX(${value}px) scale(${LIFT_SCALE})`;
drag.offsets[drag.from] = offset;
node.style.transform = at(offset);
moves.get(node)?.cancel();
if (!animate) return;
moves.set(node, node.animate(
[{ transform: at(previous) }, { transform: at(offset) }],
{ duration: REORDER_MS, easing: EASE },
));
}
function openGap(to, animate) {
slotOrder(drag.from, to).forEach((index, slot) => {
if (index === drag.from) return;
const offset = drag.homes[slot].left - drag.homes[index].left;
if (offset === drag.offsets[index]) return;
travel(drag.nodes[index], drag.offsets[index], offset, animate);
drag.offsets[index] = offset;
});
drag.to = to;
}
function nearestSlot(shift) {
const { homes, from } = drag;
const center = homes[from].left + homes[from].width / 2 + shift;
const away = (index) => Math.abs(homes[index].left + homes[index].width / 2 - center);
return homes.reduce((best, home, index) => (away(index) < away(best) ? index : best), 0);
}
/* The rearranged palette goes back through the same path a reset takes: the
card's saved colors are rewritten and the screen is rendered from them, so
the readouts, the rings, the preview, and the values the select button copies
into the form all still come off one source. */
function commitOrder(from, to, scope) {
const saved = state();
const colors = {};
const detached = {};
const rings = {};
slotOrder(from, to).forEach((index, slot) => {
const role = ROLES[slot];
const source = ROLES[index];
colors[role] = saved.colors[source];
detached[role] = saved.detached[source];
// The ring that sampled the color travels with it, so no marker is left
// sitting on a pixel it no longer matches.
rings[role] = saved.rings[source];
});
Object.assign(saved, { colors, detached, rings });
render();
// `render` reaches the palette screen, which draws itself from the deck. The
// rest of the run reads the palette out of the committed fields instead, so a
// reorder made on the strategy screen has to rewrite those too or it would
// last exactly as long as the screen it happened on.
if (scope !== paletteBands) recommitPalette();
}
/* The fields the select button writes, rewritten from the state the bands were
just rearranged into, and everything that reads them repainted. Silent before
a palette has been chosen: there is nothing to keep in step yet. */
function recommitPalette() {
if (!$('[name="palette-source"]').value) return;
for (const role of ROLES) $(`[name="palette-${role}"]`).value = state().colors[role];
paintStrategyBands();
for (const artboard of document.querySelectorAll('.picker-screen[data-active] [data-artboard]')) {
syncCommittedPalette(artboard);
}
}
/* The strategy screen's band is a reading of the committed palette rather than
of the deck, which is also what makes it correct after a reorder on either
screen: both end in the fields this paints from. */
function paintStrategyBands() {
const committed = roleMap((role) => $(`[name="palette-${role}"]`).value);
if (Object.values(committed).some((hex) => !hex)) return;
for (const role of ROLES) {
const band = $(`[data-band="${role}"]`, strategyBands);
band.style.setProperty('--band-color', committed[role]);
band.style.setProperty('--band-ink', contrastInk(committed[role]));
$('output', band).textContent = committed[role];
}
}
function reorderedSummary(from, to) {
const { colors } = state();
return slotOrder(from, to)
.map((index, slot) => `${ROLES[slot]} ${colors[ROLES[index]]}`)
.join(', ');
}
function beginDrag(index, keyboard, scope) {
// A second grab with one still in the air puts the first back first, so no
// measurement is taken off a row that is standing somewhere temporary.
if (drag) endDrag(true);
landing?.();
closeTints();
const nodes = bandNodes(scope);
for (const node of nodes) {
moves.get(node)?.cancel();
node.style.transform = '';
}
drag = {
from: index,
to: index,
keyboard,
scope,
nodes,
grips: gripNodes(scope),
homes: nodes.map((node) => node.getBoundingClientRect()),
offsets: ROLES.map(() => 0),
id: card().id,
pointerId: null,
startX: 0,
};
nodes[index].dataset.dragging = '';
drag.grips[index].dataset.dragging = '';
lift(0, false);
}
function endDrag(cancel, after) {
const { nodes, grips, from, to, homes, id, scope } = drag;
const target = cancel ? 0 : homes[to].left - homes[from].left;
if (cancel) openGap(from, !reduceMotion());
drag = null;
delete nodes[from].dataset.dragging;
delete grips[from].dataset.dragging;
landing = () => {
landing = null;
// The deck can be browsed under a drag, and another card's colors are not
// the ones that were picked up.
if (!cancel && to !== from && card().id === id) commitOrder(from, to, scope);
for (const node of nodes) {
moves.get(node)?.cancel();
node.style.transform = '';
}
after?.();
};
if (reduceMotion()) {
landing();
return;
}
const node = nodes[from];
const start = node.style.transform;
const end = `translateX(${target}px) scale(1)`;
node.style.transform = end;
const animation = node.animate(
[{ transform: start }, { transform: end }],
{ duration: REORDER_MS, easing: EASE },
);
moves.set(node, animation);
animation.finished.then(() => landing?.(), () => {});
}
function finishDrag(keyboard) {
const { from, to, scope } = drag;
if (to === from) {
endDrag(true);
return;
}
const summary = `Palette reordered. ${reorderedSummary(from, to)}`;
// Focus follows the color rather than the slot it left, and only once the
// band has landed: a focus ring drawn on a band in flight points at nothing.
endDrag(false, keyboard ? () => gripNodes(scope)[to].focus() : null);
announceReorder(scope, summary);
}
const gripOf = (target) => (target instanceof Element ? target.closest('[data-grip]') : null);
const scopeOf = (grip) => grip.closest('.picker-bands');
/* Both strips reorder, and they reorder the same palette. The handlers are
written once against whichever strip the grip that was grabbed belongs to. */
function wireReorder(root) {
root.addEventListener('pointerdown', (event) => {
const grip = gripOf(event.target);
if (!grip || event.button !== 0 || !cards.length) return;
// Keeps the press off the focus ring and out of a text selection. The grip is
// reached by keyboard through Tab, and the pointer drag needs neither.
event.preventDefault();
beginDrag(ROLES.indexOf(grip.dataset.grip), false, scopeOf(grip));
drag.pointerId = event.pointerId;
drag.startX = event.clientX;
grip.setPointerCapture(event.pointerId);
});
root.addEventListener('pointermove', (event) => {
if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return;
const { homes, from } = drag;
// Held inside the strip: a band that can be flown across the screen covers
// the image the colors were pulled from and says nothing more than this does.
const shift = Math.min(
homes.at(-1).left - homes[from].left,
Math.max(homes[0].left - homes[from].left, event.clientX - drag.startX),
);
lift(shift, false);
const to = nearestSlot(shift);
if (to !== drag.to) openGap(to, !reduceMotion());
});
root.addEventListener('pointerup', (event) => {
if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return;
finishDrag(false);
});
root.addEventListener('pointercancel', (event) => {
if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return;
endDrag(true);
});
root.addEventListener('keydown', (event) => {
const grip = gripOf(event.target);
if (!grip || !REORDER_KEYS.has(event.key) || !cards.length) return;
const scope = scopeOf(grip);
const held = Boolean(drag?.keyboard);
const grab = event.key === ' ' || event.key === 'Enter';
if (!held && (drag || !grab)) return;
event.preventDefault();
if (!held) {
beginDrag(ROLES.indexOf(grip.dataset.grip), true, scope);
announceReorder(scope, `${roleLabel(drag.from, scope)} color lifted, position ${drag.from + 1} of ${ROLES.length}. Arrow keys move it, space drops it, escape puts it back.`);
return;
}
if (grab) {
finishDrag(true);
return;
}
if (event.key === 'Escape') {
endDrag(true);
announceReorder(scope, 'Reorder cancelled.');
return;
}
const step = { ArrowLeft: -1, ArrowRight: 1, Home: -ROLES.length, End: ROLES.length }[event.key];
const to = Math.min(ROLES.length - 1, Math.max(0, drag.to + step));
if (to === drag.to) return;
openGap(to, !reduceMotion());
lift(drag.homes[to].left - drag.homes[drag.from].left, !reduceMotion());
announceReorder(scope, `Position ${to + 1} of ${ROLES.length}.`);
});
// A lifted band with nobody holding it would keep answering arrow keys aimed
// at whatever took the focus.
root.addEventListener('focusout', (event) => {
if (!drag?.keyboard || event.target !== drag.grips[drag.from]) return;
const scope = drag.scope;
endDrag(true);
announceReorder(scope, 'Reorder cancelled.');
});
}
wireReorder(panel);
wireReorder(strategyBands);
panel.onpointerover = panel.onfocusin = ({ target }) => {
const band = target.closest('[data-band]');
if (band) setActiveRole(band.dataset.band);
@@ -1190,8 +1514,12 @@ panel.onclick = async (e) => {
const tip = data.tip;
data.tip = 'Copied';
setTimeout(() => data.tip = tip, 1200);
} else if (data.editTints) openTints(data.editTints);
else if (data.customColor) $(`[data-color-input="${data.customColor}"]`, panel).click();
} else if (data.editTints) {
// The button that opened the strip closes it, so the × is a second way out
// rather than the only one.
if (openTint === data.editTints) closeTints();
else openTints(data.editTints);
} else if (data.customColor) $(`[data-color-input="${data.customColor}"]`, panel).click();
else if (data.tint) setColor(openTint, data.tint);
else if ('closeTints' in data) closeTints();
else if ('reset' in data) {
@@ -1204,9 +1532,19 @@ panel.onclick = async (e) => {
const item = card();
$('[name="palette-source"]').value = item.id;
for (const role of ROLES) $(`[name="palette-${role}"]`).value = state().colors[role];
// Painted before the screen changes rather than on arrival: the next screen
// is captured for the transition as it is handed over, and a strip still
// holding last run's colors is what would be captured.
paintStrategyBands();
}
};
/* The select button lives with the screen's actions rather than inside the
fieldset, so the delegated handler above never reached it and the palette
left the run as four empty fields. It commits the same state the bands are
rendered from, reordered or not, so it is that handler and not a copy. */
$('[data-select-palette]').addEventListener('click', panel.onclick);
$('[data-deck-prev]').onclick = () => browse(current - 1);
$('[data-deck-next]').onclick = () => browse(current + 1);
scroller.addEventListener('scroll', () => {
@@ -1230,6 +1568,9 @@ document.addEventListener('picker:screenchange', (event) => {
for (const artboard of screenNode?.querySelectorAll('[data-artboard]') ?? []) {
syncCommittedPalette(artboard);
}
// Coming back to the strategy screen from further along, where the palette may
// have been reordered on the screen it was left on.
if (event.detail.screen === '03') paintStrategyBands();
// Arriving is the quietest moment there is, so the rail settles here even
// if it is already in order: the chosen pair is the row you land on.
if (event.detail.screen === '04') {
@@ -1272,8 +1613,47 @@ const modesNext = document.querySelector('[data-modes-next]');
const syncModesNext = () => {
if (modesNext) modesNext.disabled = !modeInputs.some((input) => input.checked);
};
for (const input of modeInputs) input.addEventListener('change', syncModesNext);
/* The palette is judged on a page, and which page that should be is answered
here: the palette screen shows the preview of the surface being designed.
Each tile already holds a finished drawing of its mode, so the chosen one is
lifted out of the tile rather than restated on screen 02, which is the only
way a new variant lands on both screens at once.
First in tile order, not first clicked. A multi-select answer has no other
stable primary, and click order would move the palette's test page around
for reasons the visitor cannot see. */
const modePreviews = modeInputs.map((input) => (
input.closest('.picker-mode-tile')?.querySelector('.picker-preview')
));
const landingPreview = preview.cloneNode(true);
let previewSource;
function syncModePreview() {
const chosen = modeInputs.findIndex((input) => input.checked);
// A tile drawn in something other than this component keeps the landing page,
// which is also the floor for the empty answer the continue button blocks.
const source = (chosen === -1 ? null : modePreviews[chosen]) ?? landingPreview;
if (source === previewSource) return;
previewSource = source;
const clone = source.cloneNode(true);
// Decorative on both screens, but the marker sits on the tile's wrapper
// rather than on the component, so it does not survive the lift by itself.
clone.setAttribute('aria-hidden', 'true');
for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id');
preview.replaceWith(clone);
preview = clone;
renderPreview();
}
for (const input of modeInputs) {
input.addEventListener('change', () => {
syncModesNext();
syncModePreview();
});
}
syncModesNext();
syncModePreview();
try {
const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject());
@@ -1286,6 +1666,7 @@ try {
if (modeInputs.some((input) => wanted.has(input.value))) {
for (const input of modeInputs) input.checked = wanted.has(input.value);
syncModesNext();
syncModePreview();
}
}
cards = [
+1005 -231
View File
File diff suppressed because it is too large Load Diff