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:
Paul Bakaus
2026-07-29 17:50:40 -07:00
co-authored by Claude Fable 5
parent bd1763764a
commit 9a949fb543
270 changed files with 2037 additions and 1919 deletions
@@ -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(); });