diff --git a/src/extractors/colors.js b/src/extractors/colors.js index c2163e5..70a0522 100644 --- a/src/extractors/colors.js +++ b/src/extractors/colors.js @@ -99,12 +99,18 @@ export function extractColors(computedStyles) { // interactive backgrounds: a strong signal, with diminishing returns // chroma, not HSL saturation: near-black #002533 has s=100 but reads dark // usage and painted area: a brand colour is repeated across the page + // Evidence of use counts only in proportion to how colourful the cluster is: + // near-black #101214 on atlassian.com (chroma 1.5) got in through two dark + // buttons and then won on 702 uses. Area has diminishing returns, since one + // full-page background (84% of discord.com) is a surface, not repetition. function brandScore(c) { const chroma = ((100 - Math.abs(2 * c.lightness - 100)) * c.saturation) / 100; - return 40 * Math.log2(1 + c.interactiveBg) - + chroma + const colourful = Math.min(1, chroma / 30); + return chroma + colourful * ( + 40 * Math.log2(1 + c.interactiveBg) + 25 * Math.log10(Math.max(1, c.count)) - + 300 * (c.areaShare || 0); + + 60 * Math.sqrt(c.areaShare || 0) + ); } // The browser's default link colours say nothing about the brand. const UA_LINK_COLORS = new Set(['#0000ee', '#551a8b']); diff --git a/src/extractors/typography.js b/src/extractors/typography.js index f4c7ab3..2fd5d0a 100644 --- a/src/extractors/typography.js +++ b/src/extractors/typography.js @@ -21,6 +21,11 @@ function rendersText(el) { return el.hasText !== false && !NON_RENDERING_TAGS.has(el.tag); } +// Code faces. Syntax highlighting draws every token as its own text element, so +// on tailwindcss.com plexMono out-counted Inter 450 to 131. A code face is never +// the body font and sorts after the text faces. +const MONO_FAMILY_RE = /mono|code|consol|courier|menlo|monaco/i; + function normaliseFamily(raw) { if (!raw) return null; // Strip quotes + take the first stack member (sites declare e.g. @@ -349,8 +354,9 @@ export function extractTypography(computedStyles, options = {}) { // Unique font families sorted by usage const families = [...familyCount.entries()] - .sort((a, b) => b[1] - a[1]) + .sort((a, b) => MONO_FAMILY_RE.test(a[0]) - MONO_FAMILY_RE.test(b[0]) || b[1] - a[1]) .map(([name, count]) => { + if (MONO_FAMILY_RE.test(name)) return { name, count, usage: 'mono' }; const usedOn = computedStyles .filter(el => el.fontFamily?.includes(name) && rendersText(el)) .map(el => el.tag); diff --git a/tests/color-ranking.test.js b/tests/color-ranking.test.js index 216bf1b..0f81087 100644 --- a/tests/color-ranking.test.js +++ b/tests/color-ranking.test.js @@ -61,6 +61,27 @@ describe('brand colour ranking', () => { assert.equal(extractColors(styles).primary.hex, '#ff4800'); }); + it('a near-black used everywhere does not outrank the brand blue (atlassian: #101214 x702, 21% area)', () => { + const styles = [ + ...page(), + ...times(700, () => el({ tag: 'span', color: '#101214' })), + ...times(2, () => el({ tag: 'button', bg: '#101214', color: '#ffffff', area: 180_000 })), + ...times(200, () => el({ tag: 'a', color: '#1868db' })), + el({ tag: 'button', bg: '#1868db', color: '#ffffff', area: 2500 }), + ]; + assert.equal(extractColors(styles).primary.hex, '#1868db'); + }); + + it('one full-page background does not outrank the brand colour on buttons and text (discord: 84% area)', () => { + const styles = [ + ...page(), + el({ tag: 'main', bg: '#1a2081', area: 6_000_000 }), + ...times(2, () => el({ tag: 'a', bg: '#5865f2', color: '#ffffff', area: 3000 })), + ...times(33, () => el({ tag: 'span', color: '#5865f2' })), + ]; + assert.equal(extractColors(styles).primary.hex, '#5865f2'); + }); + it('a CTA colour used on many buttons still wins over a dark accent surface (hubspot)', () => { const styles = [ ...page(), diff --git a/tests/typography-usage.test.js b/tests/typography-usage.test.js index 246c243..086a339 100644 --- a/tests/typography-usage.test.js +++ b/tests/typography-usage.test.js @@ -56,6 +56,21 @@ describe('typography family usage', () => { assert.deepEqual(names, ['GDS Transport']); }); + it('a code face is not the body font, however many highlighted tokens it draws (tailwindcss, resend)', () => { + const styles = [ + // syntax highlighting: every token is its own text element + ...Array.from({ length: 450 }, () => el('span', 'plexMono, ui-monospace, monospace', { hasText: true })), + ...Array.from({ length: 90 }, () => el('p', 'inter, system-ui, sans-serif', { hasText: true })), + ...Array.from({ length: 41 }, () => el('h2', 'inter, system-ui, sans-serif', { hasText: true })), + ...Array.from({ length: 144 }, () => el('span', '"Source Code Pro", monospace', { hasText: true })), + ]; + const typo = extractTypography(styles); + assert.equal(typo.families[0].name, 'inter', 'the text face leads the family list'); + assert.equal(typo.families.find((f) => f.name === 'plexMono').usage, 'mono'); + assert.equal(typo.families.find((f) => f.name === 'Source Code Pro').usage, 'mono'); + assert.equal(typo.families.find((f) => f.name === 'inter').usage, 'all'); + }); + it('still counts records captured before hasText existed', () => { const typo = extractTypography([el('p', '"Inter", sans-serif'), el('h2', '"Inter", sans-serif')]); assert.equal(typo.families[0].name, 'Inter');