@designesy/score 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/engine.js +179 -13
  2. package/package.json +1 -1
package/dist/engine.js CHANGED
@@ -5051,12 +5051,176 @@ function v46FluidFloor(value) {
5051
5051
  const statics = args.filter((a) => a !== null);
5052
5052
  return statics.length ? Math.max(...statics) : null;
5053
5053
  }
5054
- function v46Selector(css, at) {
5055
- const open = css.lastIndexOf('{', at);
5056
- if (open < 0)
5057
- return '';
5058
- const start = Math.max(css.lastIndexOf('}', open), css.lastIndexOf(';', open), css.lastIndexOf('{', open - 1)) + 1; // stop at an enclosing @media brace too
5059
- return withoutCssComments(css.slice(start, open), ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
5054
+ // Engine 1.5.2: the `font` shorthand sets a size too, and until 1.5.2 v46 read
5055
+ // only `font-size`, so `font: 500 0.66rem/1 var(--mono)` sat outside the check.
5056
+ // The size is the first token that is a length, a size keyword or a size
5057
+ // function, ahead of the line-height slash and the family; a weight (500) has
5058
+ // no unit and a family name is no length, so neither is taken. A system font
5059
+ // keyword (caption, menu…) or a CSS-wide keyword sets no measurable size.
5060
+ const V46_FONT_SYSTEM = /^(?:caption|icon|menu|message-box|small-caption|status-bar|inherit|initial|unset|revert|revert-layer)$/i;
5061
+ const V46_SIZE_WORDS = new Set(['small', 'large', 'x-small', 'x-large', 'xx-small', 'xx-large', 'xxx-small', 'xxx-large', 'medium', 'smaller', 'larger']);
5062
+ /** `s` with A to Z lower-cased and every other code unit kept: the case folding of the /i flag without the u flag. */
5063
+ function foldAsciiCase(s) {
5064
+ return s.replace(/[A-Z]+/g, (run) => run.toLowerCase());
5065
+ }
5066
+ /**
5067
+ * Whether a shorthand token is a size, as
5068
+ * /^(?:\d*\.?\d+(?:[a-z]+|%)|(?:xx?-|xxx-)?(?:small|large)|medium|smaller|larger|(?:clamp|min|max|calc)\(.*\))$/i
5069
+ * answers: a number and then a unit or %, a size keyword, or a size function
5070
+ * whose parenthesis closes at the end of the token with no line break inside.
5071
+ * Read in one pass: the number in that pattern can split a run of digits
5072
+ * between \d* and \d+ in as many ways as the run is long.
5073
+ */
5074
+ function isV46SizeToken(s) {
5075
+ const n = s.length;
5076
+ let k = 0;
5077
+ let dot = false;
5078
+ for (; k < n; k++) {
5079
+ const c = s.charCodeAt(k);
5080
+ if (c === 46) {
5081
+ if (dot)
5082
+ return false; // a second dot ends every reading
5083
+ dot = true;
5084
+ }
5085
+ else if (!isDigit(c))
5086
+ break;
5087
+ }
5088
+ if (k > 0) {
5089
+ if (k === n || s.charCodeAt(k - 1) === 46)
5090
+ return false; // the number ends in a digit, and a unit follows it
5091
+ if (k === n - 1 && s.charCodeAt(k) === 37)
5092
+ return true; // %
5093
+ for (let i = k; i < n; i++) {
5094
+ const c = s.charCodeAt(i) | 32;
5095
+ if (c < 97 || c > 122)
5096
+ return false;
5097
+ }
5098
+ return true;
5099
+ }
5100
+ if (n <= 9 && V46_SIZE_WORDS.has(foldAsciiCase(s)))
5101
+ return true;
5102
+ const open = s.indexOf('(');
5103
+ if (open < 3 || open > 5 || n < open + 2 || s.charCodeAt(n - 1) !== 41)
5104
+ return false;
5105
+ const fn = foldAsciiCase(s.slice(0, open));
5106
+ if (fn !== 'clamp' && fn !== 'min' && fn !== 'max' && fn !== 'calc')
5107
+ return false;
5108
+ for (let i = open + 1; i < n - 1; i++)
5109
+ if (isLineTerminator(s.charCodeAt(i)))
5110
+ return false;
5111
+ return true;
5112
+ }
5113
+ /** The size written inside a `font` shorthand value, or null when it carries none. */
5114
+ function v46ShorthandSize(value, tokens) {
5115
+ const whole = resolveVar(value, tokens); // a whole-shorthand token alias
5116
+ if (V46_FONT_SYSTEM.test(whole))
5117
+ return null;
5118
+ for (const part of splitCssTopLevel(whole.replace(/\//g, ' / ').replace(/\s+/g, ' '), ' ')) {
5119
+ if (part === '/')
5120
+ return null; // a slash before any size: not a shorthand v46 can read
5121
+ if (isV46SizeToken(resolveVar(part, tokens)))
5122
+ return part;
5123
+ }
5124
+ return null;
5125
+ }
5126
+ /**
5127
+ * The declarations v46 reads: matches of
5128
+ * /(font-size|(?<![\w-])font)\s*:\s*([^;}]+)/gi, groups [property, value].
5129
+ * A font-size match is read past its value, as the global pattern reads it.
5130
+ * After a `font` match the scan goes on from the end of the word, so a
5131
+ * shorthand never swallows a font-size written after it. A `font` match is
5132
+ * dropped when it sits inside the value of the shorthand before it (it is
5133
+ * that value's own text), or when its value holds a `{`: an @supports
5134
+ * condition such as `(font: -apple-system-body) and …{` declares nothing.
5135
+ *
5136
+ * Engine 1.5.2. Each start is tried once, and the starts that share a value's
5137
+ * end share one search for it, so the scan reads the sheet in linear time.
5138
+ */
5139
+ function v46SizeDecls(css) {
5140
+ const start = /font/gi;
5141
+ const sizeWord = /-size/iy;
5142
+ const valueEnd = nextMatchFinder(css, /[;}]/g);
5143
+ const brace = nextMatchFinder(css, /\{/g);
5144
+ const out = [];
5145
+ let shorthandEnd = -1;
5146
+ for (let pos = 0;;) {
5147
+ start.lastIndex = pos;
5148
+ const m = start.exec(css);
5149
+ if (!m)
5150
+ break;
5151
+ const at = m.index;
5152
+ pos = at + 1;
5153
+ sizeWord.lastIndex = at + 4;
5154
+ const size = sizeWord.test(css);
5155
+ if (!size && (at < shorthandEnd || (at > 0 && isNameChar(css.charCodeAt(at - 1)))))
5156
+ continue;
5157
+ const word = size ? 9 : 4;
5158
+ const colon = wsEnd(css, at + word);
5159
+ if (css.charCodeAt(colon) !== 58)
5160
+ continue;
5161
+ const ws = wsEnd(css, colon + 1);
5162
+ let from = ws;
5163
+ let end = ws;
5164
+ if (ws < css.length && css.charCodeAt(ws) !== 59 && css.charCodeAt(ws) !== 125) {
5165
+ const r = valueEnd(ws);
5166
+ end = r === Infinity ? css.length : r;
5167
+ }
5168
+ else if (ws > colon + 1) {
5169
+ from = ws - 1; // only white space before the end: the value is its last character
5170
+ }
5171
+ else
5172
+ continue;
5173
+ if (size) {
5174
+ pos = end;
5175
+ }
5176
+ else {
5177
+ pos = at + 4;
5178
+ if (brace(from) < end)
5179
+ continue;
5180
+ shorthandEnd = end;
5181
+ }
5182
+ out.push({ index: at, text: css.slice(at, end), groups: [css.slice(at, at + word), css.slice(from, end)] });
5183
+ }
5184
+ return out;
5185
+ }
5186
+ /**
5187
+ * The selector of the rule a v46 finding sits in, by offset: the text from
5188
+ * the `{`, `}` or `;` before the last `{` at or before the offset up to that
5189
+ * brace, without comments, white space collapsed, cut to 80 characters (''
5190
+ * when no `{` comes before the offset).
5191
+ *
5192
+ * Engine 1.5.2: the sheet's braces and semicolons are listed once, each offset
5193
+ * is answered by binary search, and the findings of one rule share one
5194
+ * reading of its selector. Before, each finding ran four lastIndexOf scans,
5195
+ * and on a sheet with few braces or semicolons each one read back toward the
5196
+ * start of the sheet, so the check's cost grew with the square of its length.
5197
+ */
5198
+ function v46SelectorFinder(css) {
5199
+ const opens = [];
5200
+ const marks = [];
5201
+ for (let i = 0; i < css.length; i++) {
5202
+ const c = css.charCodeAt(i);
5203
+ if (c === 123) {
5204
+ opens.push(i);
5205
+ marks.push(i);
5206
+ }
5207
+ else if (c === 125 || c === 59)
5208
+ marks.push(i);
5209
+ }
5210
+ const read = new Map();
5211
+ return (at) => {
5212
+ const k = lowerBound(opens, at + 1) - 1; // the last `{` at or before the offset
5213
+ if (k < 0)
5214
+ return '';
5215
+ const open = opens[k];
5216
+ let selector = read.get(open);
5217
+ if (selector === undefined) {
5218
+ const j = lowerBound(marks, open) - 1; // the `{`, `}` or `;` before it
5219
+ selector = withoutCssComments(css.slice(j < 0 ? 0 : marks[j] + 1, open), ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
5220
+ read.set(open, selector);
5221
+ }
5222
+ return selector;
5223
+ };
5060
5224
  }
5061
5225
  function checkTypeScale(css, tokens) {
5062
5226
  const scale = v46TypeScale(tokens);
@@ -5078,10 +5242,12 @@ function checkTypeScale(css, tokens) {
5078
5242
  };
5079
5243
  let judged = 0;
5080
5244
  let fluid = 0;
5081
- const re = /font-size\s*:\s*([^;}]+)/gi;
5082
- let m;
5083
- while ((m = re.exec(css)) !== null) {
5084
- const written = withoutCssComments(m[1], '').replace(/!important/i, '').trim();
5245
+ const selectorAt = v46SelectorFinder(css);
5246
+ for (const m of v46SizeDecls(css)) { // engine 1.5.2: font-size and the font shorthand
5247
+ const declared = withoutCssComments(m.groups[1], '').replace(/!important/i, '').trim();
5248
+ const written = m.groups[0].length === 4 ? v46ShorthandSize(declared, tokens) : declared;
5249
+ if (written === null)
5250
+ continue;
5085
5251
  const value = resolveVar(written, tokens);
5086
5252
  if (/^var\(/i.test(value))
5087
5253
  continue; // an alias the token table cannot resolve
@@ -5089,7 +5255,7 @@ function checkTypeScale(css, tokens) {
5089
5255
  fluid++;
5090
5256
  const low = v46FluidFloor(value);
5091
5257
  if (low !== null && below(low)) {
5092
- add({ selector: v46Selector(css, m.index), value: written, px: Math.round(low * 100) / 100, issue: 'fluid minimum below the floor', nearest: floor });
5258
+ add({ selector: selectorAt(m.index), value: written, px: Math.round(low * 100) / 100, issue: 'fluid minimum below the floor', nearest: floor });
5093
5259
  }
5094
5260
  continue;
5095
5261
  }
@@ -5098,10 +5264,10 @@ function checkTypeScale(css, tokens) {
5098
5264
  continue; // em, %, keywords, calc(): relative or not static
5099
5265
  judged++;
5100
5266
  if (below(px)) {
5101
- add({ selector: v46Selector(css, m.index), value: written, px: Math.round(px * 100) / 100, issue: 'below the floor', nearest: floor });
5267
+ add({ selector: selectorAt(m.index), value: written, px: Math.round(px * 100) / 100, issue: 'below the floor', nearest: floor });
5102
5268
  }
5103
5269
  else if (!onScale(px)) {
5104
- add({ selector: v46Selector(css, m.index), value: written, px: Math.round(px * 100) / 100, issue: 'off the scale', nearest: nearest(px) });
5270
+ add({ selector: selectorAt(m.index), value: written, px: Math.round(px * 100) / 100, issue: 'off the scale', nearest: nearest(px) });
5105
5271
  }
5106
5272
  }
5107
5273
  const scaleText = `${steps.length}-step scale (ratio ${ratio.toFixed(3)}, ${scale.count} type tokens, floor ${floor}px)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designesy/score",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Design quality gate for AI-generated UI: score any URL on 45 deterministic checks, A–F grade. Runs locally; browser checks return MANUAL. No required dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Designesy <hello@designesy.org>",