mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 18:47:02 +03:00
update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user