-
Notifications
You must be signed in to change notification settings - Fork 9.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
new_audit(font-size-audit): legible font sizes audit #3533
Changes from 19 commits
4cc44c1
ad797fc
b2aa5c8
64360dc
3f2effc
fdf6473
35599d3
483a5c2
9a10a10
4b0863c
005d308
a4ec14e
7298ae6
acb9aa8
a61106e
eaf454b
6d1b518
e07a3f8
242024d
3c66033
99d89ba
018e2bf
4e1f171
dc13c14
66d4b73
507c78d
045fc6a
696f926
1da6cfe
9051b92
8596665
64374a0
312d030
1f7ddbb
1698dd7
97e1096
12fecc7
0929685
a0a08f7
b3a99b9
d45a313
e810e67
1f4c6d2
682b2c7
e3007b2
26ea8c3
a8b14f6
a7fa017
f93ad70
9e2346e
83f49c7
94efb06
9e912de
cf25be4
ece62d2
c017451
93efc77
ae2c823
a6c6ecd
68f896a
f2af51f
007a458
0d5da12
3b91408
ede0864
a588863
06443e5
88091d7
5a71a58
3223cb5
0b0cb9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
/** | ||
* @license Copyright 2017 Google Inc. All Rights Reserved. | ||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. | ||
*/ | ||
'use strict'; | ||
|
||
const parseURL = require('url').parse; | ||
const Audit = require('../audit'); | ||
const ViewportAudit = require('../viewport'); | ||
const CSSStyleDeclaration = require('../../lib/web-inspector').CSSStyleDeclaration; | ||
const MINIMAL_LEGIBLE_FONT_SIZE_PX = 16; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we have any supporting links to reference for these constants? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is "a good default", but since it depends on the font (and language?) it's a hard value to pinpoint. @rviscomi plans to adjust it once we have more data from running this audit on httparchive and making it available to the test users. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. perfect let's just add a link there for now then 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Link is in the meta section of this audit, but it makes sense to put it also here 👍 |
||
const MINIMAL_PERCENTAGE_OF_LEGIBLE_TEXT = 75; | ||
|
||
/** | ||
* @param {!Array<{textLength:number}>} rules | ||
* @returns number | ||
*/ | ||
function getTotalTextLength(rules) { | ||
return rules.reduce((sum, item) => sum + item.textLength, 0); | ||
} | ||
|
||
/** | ||
* @param {Array<{cssRule: WebInspector.CSSStyleDeclaration, fontSize: number, textLength: number, node: Node}>} fontSizeArtifact | ||
* @returns {Array<{cssRule: WebInspector.CSSStyleDeclaration, fontSize: number, textLength: number, node: Node}>} | ||
*/ | ||
function getFailingRules(fontSizeArtifact) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: maybe rename to reflect that this is coalescing/de-duping rules too? I expected to find a simple filter There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point - I renamed it to |
||
const failingRules = new Map(); | ||
|
||
fontSizeArtifact.forEach(({cssRule, fontSize, textLength, node}) => { | ||
if (fontSize >= MINIMAL_LEGIBLE_FONT_SIZE_PX) { | ||
return; | ||
} | ||
|
||
const artifactId = getFontArtifactId(cssRule, node); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Turned out not to be a regression but rather bug that was there from the beginning. Note that font-size is defined twice in this rule and I've been only examining first occurrence in This was causing effective rule not to be found and every node listed as a separate entry in the details table. Fixed 🔧 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah ok, great catch! |
||
const failingRule = failingRules.get(artifactId); | ||
|
||
if (!failingRule) { | ||
failingRules.set(artifactId, { | ||
node, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. will all duplicate rules correspond to the same node? or is this the optional node that's only set for inline style? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's always set, but it only matters for inline ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah ok and in the inline and attribute cases it's obviously the same node since that's the source, gotcha |
||
cssRule, | ||
fontSize, | ||
textLength, | ||
}); | ||
} else { | ||
failingRule.textLength += textLength; | ||
} | ||
}); | ||
|
||
return failingRules.valuesArray(); | ||
} | ||
|
||
/** | ||
* @param {Node} node | ||
* @return {{type:string, snippet:string}} | ||
*/ | ||
function nodeToTableNode(node) { | ||
const attributesString = node.attributes.map((value, idx) => | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how does this look in practice, are the attributes way too long? is there a subset we might be interested in? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good point, list of attributes may be long in some cases (e.g. schema.org metadata). However, limiting number of attributes shown to e.g. class and id, may be IMO confusing to the user as our representation of the node won't match the original one. This is especially problematic as ATM users have to find these nodes manually in their code/DOM (we don't link to the Element's panel from the report). If we want to limit number of attributes shown, we should at least expose node's xpath (just like a11y tests do) to make sure it is findable: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hm yeah I agree, I'm on board with the path to element on hover either way |
||
(idx % 2 === 0) ? ` ${value}` : `="${value}"` | ||
).join(''); | ||
|
||
return { | ||
type: 'node', | ||
snippet: `<${node.localName}${attributesString}>`, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Getting a nice looking unique selector is quite complicated and since we don't have real DOM nodes here, but rather devtools protocol nodes, it'd be tricky to reuse Axe's getSelector. I've put together a simpler alternative that would work here, but it doesn't guarantee uniques. WDYT? BTW I realized that two of my audits that already landed: hreflang and is-crawlable, both suffer from the same issue (selector is not provided). For both of those however I don't have access to the parent nodes making it impossible to produce an useful selector. Should I just pass an empty string there? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, yeah maybe not worry about it just yet then. Just use the ID/class of the immediate node if we have it, add a TODO, and we'll file an issue linking off to that gist to make better selectors for these SEO audits then There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done, PTAL: 88091d7 . Should I file the issue for that TODO? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looks good! and yeah that'd be great :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #4015 👍 |
||
}; | ||
} | ||
|
||
/** | ||
* @param {Array<{header:{styleSheetId:string, sourceURL:string}}>} stylesheets | ||
* @param {string} baseURL | ||
* @param {WebInspector.CSSStyleDeclaration} styleDeclaration | ||
* @param {Node} node | ||
* @returns {{source:!string, selector:string}} | ||
*/ | ||
function getOrigin(stylesheets, baseURL, styleDeclaration, node) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. origin is a pretty overloaded term on the web, how about "findStyleRuleSource" or something? |
||
if ( | ||
!styleDeclaration || | ||
styleDeclaration.type === CSSStyleDeclaration.Type.Attributes || | ||
styleDeclaration.type === CSSStyleDeclaration.Type.Inline | ||
) { | ||
return { | ||
source: baseURL, | ||
selector: nodeToTableNode(node), | ||
}; | ||
} | ||
|
||
if (styleDeclaration.parentRule && | ||
styleDeclaration.parentRule.origin === global.CSSAgent.StyleSheetOrigin.USER_AGENT) { | ||
return { | ||
selector: styleDeclaration.parentRule.selectors.map(item => item.text).join(', '), | ||
source: 'User Agent Stylesheet', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this seems like an unfortunate situation to show to a user, is the default on mobile illegible text? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Default UA font size value is legible ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. got it |
||
}; | ||
} | ||
|
||
if (styleDeclaration.type === CSSStyleDeclaration.Type.Regular && styleDeclaration.parentRule) { | ||
const rule = styleDeclaration.parentRule; | ||
const stylesheetMeta = stylesheets.find(ss => ss.header.styleSheetId === rule.styleSheetId); | ||
|
||
if (stylesheetMeta) { | ||
const url = parseURL(stylesheetMeta.header.sourceURL, baseURL); | ||
const range = styleDeclaration.range; | ||
const selector = rule.selectors.map(item => item.text).join(', '); | ||
let source = `${url.href}`; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🤔 Normally, source path is shown before line and column number, but when CSS is dynamically injected its source URL is not set: We could default to the main resource path, but line and column numbers are relative to the injected stylesheet, not the document so it might be confusing. IMO it would make most sense to just say 'dynamically injected' in this case (and drop line and column numbers). WDYT? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm yeah rather tricky "dynamically injected" or even just "dynamic" sounds good to me There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
if (range) { | ||
const absoluteStartLine = range.startLine + stylesheetMeta.header.startLine + 1; | ||
const absoluteStartColumn = range.startColumn + stylesheetMeta.header.startColumn + 1; | ||
|
||
source += `:${absoluteStartLine}:${absoluteStartColumn}`; | ||
} | ||
|
||
return { | ||
selector, | ||
source, | ||
}; | ||
} | ||
} | ||
|
||
return { | ||
source: 'Unknown', | ||
}; | ||
} | ||
|
||
/** | ||
* @param {WebInspector.CSSStyleDeclaration} styleDeclaration | ||
* @param {Node} node | ||
* @return string | ||
*/ | ||
function getFontArtifactId(styleDeclaration, node) { | ||
if (styleDeclaration && styleDeclaration.type === CSSStyleDeclaration.Type.Regular) { | ||
const startLine = styleDeclaration.range ? styleDeclaration.range.startLine : 0; | ||
const startColumn = styleDeclaration.range ? styleDeclaration.range.startColumn : 0; | ||
return `${styleDeclaration.styleSheetId}@${startLine}:${startColumn}`; | ||
} else { | ||
return `node_${node.nodeId}`; | ||
} | ||
} | ||
|
||
class FontSize extends Audit { | ||
/** | ||
* @return {!AuditMeta} | ||
*/ | ||
static get meta() { | ||
return { | ||
category: 'Mobile friendly', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we nixed all the categories, so no need anymore :) |
||
name: 'font-size', | ||
description: 'Document uses legible font sizes.', | ||
failureDescription: 'Document doesn\'t use legible font sizes.', | ||
helpText: 'Font sizes less than 16px are too small to be legible and require mobile ' + | ||
'visitors to “pinch to zoom” in order to read. ' + | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it's a little funny to see this audit end up in passing even when there are failures (example). I know this is the same as other audits, but i'm thinking we could start explaining our passing cutoff in the helpText here.
@vinamratasingal @kaycebasques hows that sound? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
'[Learn more](https://developers.google.com/speed/docs/insights/UseLegibleFontSizes).', | ||
requiredArtifacts: ['FontSize', 'Styles', 'URL', 'Viewport'], | ||
}; | ||
} | ||
|
||
/** | ||
* @param {!Artifacts} artifacts | ||
* @return {!AuditResult} | ||
*/ | ||
static audit(artifacts) { | ||
const hasViewportSet = ViewportAudit.audit(artifacts).rawValue; | ||
if (!hasViewportSet) { | ||
return { | ||
rawValue: false, | ||
debugString: 'Text is illegible because of a missing viewport config', | ||
}; | ||
} | ||
|
||
const totalTextLenght = getTotalTextLength(artifacts.FontSize); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. s/Lenght/Length |
||
|
||
if (totalTextLenght === 0) { | ||
return { | ||
rawValue: true, | ||
}; | ||
} | ||
|
||
const failingRules = getFailingRules(artifacts.FontSize); | ||
const failingTextLength = getTotalTextLength(failingRules); | ||
const percentageOfPassingText = (totalTextLenght - failingTextLength) / totalTextLenght * 100; | ||
const pageUrl = artifacts.URL.finalUrl; | ||
|
||
const headings = [ | ||
{key: 'source', itemType: 'url', text: 'Source'}, | ||
{key: 'selector', itemType: 'code', text: 'Selector'}, | ||
{key: 'coverage', itemType: 'text', text: 'Coverage'}, | ||
{key: 'fontSize', itemType: 'text', text: 'Font Size'}, | ||
]; | ||
|
||
const tableData = failingRules.sort((a, b) => b.textLength - a.textLength) | ||
.map(({cssRule, textLength, fontSize, node}) => { | ||
const percentageOfAffectedText = textLength / totalTextLenght * 100; | ||
const origin = getOrigin(artifacts.Styles, pageUrl, cssRule, node); | ||
|
||
return { | ||
source: origin.source, | ||
selector: origin.selector, | ||
coverage: `${percentageOfAffectedText.toFixed(2)}%`, | ||
fontSize: `${fontSize}px`, | ||
}; | ||
}); | ||
const details = Audit.makeTableDetails(headings, tableData); | ||
const passed = percentageOfPassingText >= MINIMAL_PERCENTAGE_OF_LEGIBLE_TEXT; | ||
const debugString = passed ? | ||
null : `${parseFloat((100 - percentageOfPassingText).toFixed(2))}% of text is too small.`; | ||
|
||
return { | ||
rawValue: passed, | ||
details, | ||
debugString, | ||
}; | ||
} | ||
} | ||
|
||
module.exports = FontSize; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,10 +12,17 @@ module.exports = { | |
gatherers: [ | ||
'seo/meta-description', | ||
], | ||
}, { | ||
passName: 'extraPass', | ||
gatherers: [ | ||
'seo/font-size', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. are we also adding to the default config now just without the report reference? @brendankenny There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point, added it to the default config. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. It would probably make sense to dedup gatherers or show some warning when duplicates are found. In the meantime I removed all gatherers from seo config because all of them are already in default config. |
||
'styles', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. unfortunate that this relies on the styles gatherer :/ do you foresee a way the audit could optionally rely on the styles gatherer output? i.e. still report the violations but maybe with less helpful identifiers? I'm not sure we're going to get to a place in DevTools where we want to add the extra pass by default and it'd be a shame to miss out on the whole audit There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only thing I need the styles gatherer for is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. styles gatherer parses all the stylesheets on the page with gonzales which can be extremely slow in some cases (old runs of theverge spent ~20s in styles gatherer) this seems like a useful split though for cases where you don't need the actual parsed content of stylesheets 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've tried creating a Why this happens? No idea, possibly a Chrome bug. Why it worked when I used I'm not a fan of keeping |
||
], | ||
}], | ||
audits: [ | ||
'seo/meta-description', | ||
'seo/http-status-code', | ||
'seo/font-size', | ||
], | ||
groups: { | ||
'seo-mobile': { | ||
|
@@ -41,6 +48,7 @@ module.exports = { | |
{id: 'document-title', weight: 1, group: 'seo-content'}, | ||
{id: 'meta-description', weight: 1, group: 'seo-content'}, | ||
{id: 'http-status-code', weight: 1, group: 'seo-crawl'}, | ||
{id: 'font-size', weight: 1, group: 'seo-mobile'}, | ||
], | ||
}, | ||
}, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps we can assert how many elements fail too?
details: { items: { length: x }}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we fail because viewport is not configured (see debugString below), so we don't even go into evaluating text size and
details
will be empty. Viewport is empty on this page because of theviewport
audit that we also want to fail here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah I figured that out last time, but who knows what I was thinking 18 days ago :)