mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-22 02:56:52 +03:00
Release: skill 4.0.4, CLI 3.5.0, extension 1.3.1
Version bumps for all three components plus the build:release sync of the tracked harness dirs and the plugin subtree at 4.0.4, rebased onto the composition-axes work so the release carries both threads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
bd1763764a
commit
9a949fb543
@@ -427,11 +427,17 @@ ${buildIndex} of your own grounded list; seed key ${key}.
|
||||
// Field order is the migration: `compositions` is current, `stagings` is what
|
||||
// the API emitted while these were called stagings, and `staging` is the
|
||||
// single-pick shape from before it dealt three. Older installs keep working.
|
||||
const compositions = Array.isArray(data.compositions)
|
||||
? data.compositions
|
||||
: Array.isArray(data.stagings)
|
||||
? data.stagings
|
||||
: data.staging ? [data.staging] : [];
|
||||
// Compositions are pulled from the deal until the expanded catalog is
|
||||
// ready for prime time: the current pool crowds the decision more than it
|
||||
// widens it. IMPECCABLE_COMPOSITIONS=1 re-enables rendering for catalog
|
||||
// development; the draw machinery, axes, and grain report stay intact.
|
||||
const compositionsEnabled = process.env.IMPECCABLE_COMPOSITIONS === '1';
|
||||
const compositions = !compositionsEnabled ? []
|
||||
: Array.isArray(data.compositions)
|
||||
? data.compositions
|
||||
: Array.isArray(data.stagings)
|
||||
? data.stagings
|
||||
: data.staging ? [data.staging] : [];
|
||||
// The grain report. A top-up keeps the deal at three, which is right, but it
|
||||
// must not read as three on-target inputs: a flow request answered entirely by
|
||||
// view-grain compositions means the model has to derive the flow's own
|
||||
|
||||
@@ -139,16 +139,6 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'overused fonts like Inter',
|
||||
},
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font without hierarchy',
|
||||
description:
|
||||
'Only one font family is used for the entire page. A single family can work when weight and size contrast carry the hierarchy; otherwise pair a distinctive display font with a refined body font.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'only one font family for the entire page',
|
||||
},
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
@@ -3324,7 +3314,11 @@ function isKickerCandidate(opts) {
|
||||
|| isSmallCaps;
|
||||
if (!isUppercased) return false;
|
||||
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
|
||||
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
|
||||
// Proportional only, no absolute floor: the wild's most common recipe is
|
||||
// 0.08em at a sub-13px size, which computes to under 1px and sailed past
|
||||
// the old Math.max(1, ...) floor (observed live: a page whose kickers were
|
||||
// literally class="kicker" produced zero findings).
|
||||
const minTrackedSpacing = kickerFontSize * 0.06;
|
||||
if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -4708,12 +4702,6 @@ function checkTypography() {
|
||||
if (isBrandFontOnOwnDomain(font)) continue;
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
}
|
||||
|
||||
// Single-font check: only one distinct primary font across all text
|
||||
if (fontUsage.size === 1) {
|
||||
const only = [...fontUsage.keys()][0];
|
||||
findings.push({ type: 'single-font', detail: `only font used is ${only}` });
|
||||
}
|
||||
}
|
||||
|
||||
const sizes = new Set();
|
||||
@@ -4975,14 +4963,6 @@ function checkPageTypography(doc, win) {
|
||||
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
||||
}
|
||||
|
||||
// Single font
|
||||
if (fonts.size === 1) {
|
||||
const els = doc.querySelectorAll('*');
|
||||
if (els.length >= 20) {
|
||||
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Flat type hierarchy
|
||||
const sizes = new Set();
|
||||
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
|
||||
|
||||
@@ -241,24 +241,6 @@ const REGEX_MATCHERS = [
|
||||
];
|
||||
|
||||
const REGEX_ANALYZERS = [
|
||||
// Single font
|
||||
(content, filePath) => {
|
||||
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
const fonts = new Set();
|
||||
let m;
|
||||
while ((m = fontFamilyRe.exec(content)) !== null) {
|
||||
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
|
||||
}
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
|
||||
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
|
||||
const name = [...fonts][0];
|
||||
const lines = content.split('\n');
|
||||
let line = 1;
|
||||
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
|
||||
return [finding('single-font', filePath, `only font used is ${name}`, line)];
|
||||
},
|
||||
// Flat type hierarchy
|
||||
(content, filePath) => {
|
||||
const sizes = new Set();
|
||||
@@ -626,10 +608,11 @@ const TEXT_CONTENT_ANALYZER_IDS = [
|
||||
function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
||||
// The 3 text-content analyzers are at indices 3-5 in REGEX_ANALYZERS.
|
||||
// The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS
|
||||
// (single-font's removal on 2026-07-29 shifted every index down one).
|
||||
const findings = [];
|
||||
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
||||
const analyzer = REGEX_ANALYZERS[3 + i];
|
||||
const analyzer = REGEX_ANALYZERS[2 + i];
|
||||
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
@@ -750,7 +733,6 @@ function detectText(content, filePath, options = {}) {
|
||||
// Page-level analyzers only run on full pages
|
||||
if (shouldRunPageAnalyzers(content, filePath)) {
|
||||
const analyzerIds = [
|
||||
'single-font',
|
||||
'flat-type-hierarchy',
|
||||
'monotonous-spacing',
|
||||
'em-dash-overuse',
|
||||
|
||||
@@ -952,7 +952,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
const rel = link.attribs?.rel || '';
|
||||
const href = link.attribs?.href || '';
|
||||
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
|
||||
const cssPath = path.resolve(fileDir, href);
|
||||
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
|
||||
// literal path with the query in it; a versioned link otherwise made the
|
||||
// whole stylesheet invisible to every element-level check.
|
||||
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
|
||||
try {
|
||||
const css = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
|
||||
@@ -60,9 +60,6 @@ function checkStaticPageTypography(document, window) {
|
||||
for (const font of overusedFound) {
|
||||
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
||||
}
|
||||
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
|
||||
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
|
||||
}
|
||||
const sizes = new Set();
|
||||
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
|
||||
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
|
||||
|
||||
@@ -28,16 +28,6 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'overused fonts like Inter',
|
||||
},
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font without hierarchy',
|
||||
description:
|
||||
'Only one font family is used for the entire page. A single family can work when weight and size contrast carry the hierarchy; otherwise pair a distinctive display font with a refined body font.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'only one font family for the entire page',
|
||||
},
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
|
||||
@@ -2523,7 +2523,11 @@ function isKickerCandidate(opts) {
|
||||
|| isSmallCaps;
|
||||
if (!isUppercased) return false;
|
||||
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
|
||||
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
|
||||
// Proportional only, no absolute floor: the wild's most common recipe is
|
||||
// 0.08em at a sub-13px size, which computes to under 1px and sailed past
|
||||
// the old Math.max(1, ...) floor (observed live: a page whose kickers were
|
||||
// literally class="kicker" produced zero findings).
|
||||
const minTrackedSpacing = kickerFontSize * 0.06;
|
||||
if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -3907,12 +3911,6 @@ function checkTypography() {
|
||||
if (isBrandFontOnOwnDomain(font)) continue;
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
}
|
||||
|
||||
// Single-font check: only one distinct primary font across all text
|
||||
if (fontUsage.size === 1) {
|
||||
const only = [...fontUsage.keys()][0];
|
||||
findings.push({ type: 'single-font', detail: `only font used is ${only}` });
|
||||
}
|
||||
}
|
||||
|
||||
const sizes = new Set();
|
||||
@@ -4174,14 +4172,6 @@ function checkPageTypography(doc, win) {
|
||||
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
||||
}
|
||||
|
||||
// Single font
|
||||
if (fonts.size === 1) {
|
||||
const els = doc.querySelectorAll('*');
|
||||
if (els.length >= 20) {
|
||||
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Flat type hierarchy
|
||||
const sizes = new Set();
|
||||
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
|
||||
|
||||
@@ -151,7 +151,7 @@ if (hasFlag('schema')) {
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -451,7 +451,10 @@ function page() {
|
||||
its axis with snap points and the arrows page it card by card. */
|
||||
.grid { --deck-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2)); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid::-webkit-scrollbar { display: none; }
|
||||
.grid > .card { flex: 0 0 clamp(20rem, 27vw, 27rem); scroll-snap-align: center; }
|
||||
/* Wide enough that the sketch carries the card: at 27vw the imagery read
|
||||
as a thumbnail above a column of copy, and the copy won the attention
|
||||
contest the sketch is supposed to win. */
|
||||
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
||||
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
||||
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
||||
.nav[disabled] { opacity: .25; cursor: default; }
|
||||
@@ -501,8 +504,13 @@ function page() {
|
||||
region entirely instead of reserving a blank 16:9 void. */
|
||||
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
||||
.face.text-only .body { padding-top: 12px; }
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/9; flex: none; }
|
||||
/* 16/10 matches the landscape sketch frame; portrait art overrides the
|
||||
slot with its own exact ratio at load (see the load listener), and the
|
||||
deck narrows so portrait cards line up side by side. */
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
||||
.grid.portrait-media > .card { flex-basis: clamp(14rem, 19vw, 19rem); }
|
||||
.media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
.media > img:not([hidden]) { cursor: zoom-in; }
|
||||
.face.back { background: var(--ks-lacquer-raised); }
|
||||
.back-bar { margin-top: auto; background: var(--ks-lacquer-raised); }
|
||||
.hero-blank { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
@@ -769,6 +777,30 @@ function page() {
|
||||
lightbox.hidden = false;
|
||||
requestAnimationFrame(() => lightbox.classList.add('open'));
|
||||
}));
|
||||
// Portrait art (native / mobile-first surfaces): the slot takes the
|
||||
// image's own ratio so nothing crops, and the whole deck narrows so
|
||||
// portrait cards sit side by side. Load events don't bubble; capture.
|
||||
document.addEventListener('load', (e) => {
|
||||
const img = e.target;
|
||||
if (!(img instanceof HTMLImageElement) || !img.matches('.media > img')) return;
|
||||
if (img.naturalHeight > img.naturalWidth * 1.05) {
|
||||
const m = img.closest('.media');
|
||||
m.classList.add('portrait');
|
||||
m.style.aspectRatio = img.naturalWidth + ' / ' + img.naturalHeight;
|
||||
document.querySelector('.grid')?.classList.add('portrait-media');
|
||||
}
|
||||
}, true);
|
||||
|
||||
// The whole image is the zoom target, not just the expand chip; the chip
|
||||
// stays as the visible affordance. Chip and PIP handlers stop propagation,
|
||||
// so this fires only for clicks on the art itself.
|
||||
document.querySelectorAll('.media').forEach(m => m.addEventListener('click', () => {
|
||||
const img = m.querySelector(':scope > img:not([hidden])');
|
||||
if (!img || !img.getAttribute('src')) return;
|
||||
lightboxImg.src = img.getAttribute('src');
|
||||
lightbox.hidden = false;
|
||||
requestAnimationFrame(() => lightbox.classList.add('open'));
|
||||
}));
|
||||
const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
|
||||
Reference in New Issue
Block a user