@designesy/score 0.8.0 → 0.8.1
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.
- package/README.md +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +151 -20
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @designesy/score
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Design quality gate for AI-generated UI: score any URL on 44 deterministic checks, A–F grade. This is the standalone engine: it fetches a URL, extracts CSS + `:root` tokens, and runs every check that needs no browser locally, with **no Designesy server required**; the browser checks return MANUAL. No required dependencies.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
package/dist/engine.d.ts
CHANGED
|
@@ -159,7 +159,7 @@ export declare function scoreFromParts(input: ScorePartsInput): Promise<ScoreRes
|
|
|
159
159
|
* the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
|
|
160
160
|
* contract source and fails when the two differ.
|
|
161
161
|
*/
|
|
162
|
-
export declare const CONTRACT_VERSION = "v0.4.
|
|
162
|
+
export declare const CONTRACT_VERSION = "v0.4.5";
|
|
163
163
|
/**
|
|
164
164
|
* The one verdict rule, shared by every emission format.
|
|
165
165
|
*
|
package/dist/engine.js
CHANGED
|
@@ -209,7 +209,21 @@ const BROWSER_HEADERS = {
|
|
|
209
209
|
'Sec-Fetch-User': '?1',
|
|
210
210
|
'Upgrade-Insecure-Requests': '1',
|
|
211
211
|
};
|
|
212
|
-
|
|
212
|
+
/** Redirects httpsFetch follows before it gives up (engine 1.3.1). */
|
|
213
|
+
const MAX_REDIRECT_HOPS = 3;
|
|
214
|
+
/**
|
|
215
|
+
* GET a URL, following each 3xx with a Location by hand. Every redirect target
|
|
216
|
+
* is re-validated with isValidUrl first (the SSRF guard), and at most
|
|
217
|
+
* MAX_REDIRECT_HOPS redirects are followed. Until engine 1.3.1 the comment said
|
|
218
|
+
* "Follow up to 3 redirects" but nothing counted them, so a redirect loop
|
|
219
|
+
* recursed until the process gave out. A redirect past the limit fails closed,
|
|
220
|
+
* the same way a refused target does. A response reached through at least one
|
|
221
|
+
* redirect reports redirected: true and the final hop's URL as finalUrl; before
|
|
222
|
+
* 1.3.1 redirected was true only when the LAST response was itself a 3xx, so a
|
|
223
|
+
* followed redirect read redirected: false. `hop` counts the redirects already
|
|
224
|
+
* followed; callers leave it at 0.
|
|
225
|
+
*/
|
|
226
|
+
function httpsFetch(url, headers, timeoutMs = 8000, hop = 0) {
|
|
213
227
|
return new Promise((resolve) => {
|
|
214
228
|
let resolved = false;
|
|
215
229
|
const done = (r) => {
|
|
@@ -220,18 +234,19 @@ function httpsFetch(url, headers, timeoutMs = 8000) {
|
|
|
220
234
|
};
|
|
221
235
|
try {
|
|
222
236
|
const req = httpsGet(url, { headers: { ...BROWSER_HEADERS, ...(headers || {}) }, timeout: timeoutMs, lookup: safeLookup }, (res) => {
|
|
223
|
-
// Follow
|
|
237
|
+
// Follow a redirect manually (3xx → Location)
|
|
224
238
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
225
239
|
try {
|
|
226
240
|
const next = new URL(res.headers.location, url).href;
|
|
227
241
|
res.resume();
|
|
228
|
-
// SSRF guard: re-validate every redirect target before following
|
|
229
|
-
|
|
242
|
+
// SSRF guard: re-validate every redirect target before following
|
|
243
|
+
// it. Hop limit: a chain longer than MAX_REDIRECT_HOPS (a loop
|
|
244
|
+
// among them) fails closed the same way.
|
|
245
|
+
if (!isValidUrl(next) || hop >= MAX_REDIRECT_HOPS) {
|
|
230
246
|
done({ ok: false, status: 0, text: '', redirected: false, finalUrl: url });
|
|
231
247
|
return;
|
|
232
248
|
}
|
|
233
|
-
|
|
234
|
-
httpsFetch(next, headers, timeoutMs).then(done);
|
|
249
|
+
httpsFetch(next, headers, timeoutMs, hop + 1).then(done);
|
|
235
250
|
return;
|
|
236
251
|
}
|
|
237
252
|
catch { /* fall through */ }
|
|
@@ -240,7 +255,7 @@ function httpsFetch(url, headers, timeoutMs = 8000) {
|
|
|
240
255
|
res.setEncoding('utf-8');
|
|
241
256
|
res.on('data', (chunk) => { body += chunk; });
|
|
242
257
|
res.on('end', () => {
|
|
243
|
-
done({ ok: (res.statusCode || 0) < 400, status: res.statusCode || 0, text: body, redirected:
|
|
258
|
+
done({ ok: (res.statusCode || 0) < 400, status: res.statusCode || 0, text: body, redirected: hop > 0, finalUrl: url });
|
|
244
259
|
});
|
|
245
260
|
res.on('error', () => done({ ok: false, status: 0, text: '', redirected: false, finalUrl: url }));
|
|
246
261
|
});
|
|
@@ -1086,7 +1101,13 @@ function checkFontFamilyCount(css, tokens) {
|
|
|
1086
1101
|
// the adjusted @font-face. Those are not additional typeface choices — they
|
|
1087
1102
|
// are the same family's fallback metrics — so they must not count as
|
|
1088
1103
|
// palette drift.
|
|
1089
|
-
|
|
1104
|
+
//
|
|
1105
|
+
// Engine 1.3.1: the WORD fallback anywhere in the name, not only as its
|
|
1106
|
+
// last word. A hand-tuned face can carry a suffix after it: designesy.org's
|
|
1107
|
+
// "Fraunces Fallback Tuned" is Times New Roman under size-adjust, drawn only
|
|
1108
|
+
// before Fraunces loads, and `\sfallback$` counted it as a fourth family
|
|
1109
|
+
// (v26 WARN, "4 families: fraunces fallback tuned, fraunces, ...").
|
|
1110
|
+
if (/\bfallback\b/.test(stack))
|
|
1090
1111
|
continue;
|
|
1091
1112
|
families.add(stack);
|
|
1092
1113
|
}
|
|
@@ -1098,6 +1119,57 @@ function checkFontFamilyCount(css, tokens) {
|
|
|
1098
1119
|
return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'WARN', detail: `${count} families (recommended ≤3): ${list}` };
|
|
1099
1120
|
return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'FAIL', detail: `${count} families, palette drift (recommended ≤3): ${list}` };
|
|
1100
1121
|
}
|
|
1122
|
+
/**
|
|
1123
|
+
* v27 helper (engine 1.3.1): does this rule's selector style a form field
|
|
1124
|
+
* itself? True when the subject (the last compound) of any selector in the
|
|
1125
|
+
* list is an input, textarea or select element, or carries the exact class
|
|
1126
|
+
* .input or .field, and is not a pseudo-element (::placeholder does not set
|
|
1127
|
+
* the size iOS Safari reads). ".field-card-meta", ".input-hint", "::selection",
|
|
1128
|
+
* ".field label" and "user-select" are not fields.
|
|
1129
|
+
*/
|
|
1130
|
+
function isFieldSelector(selector) {
|
|
1131
|
+
const clean = selector.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
1132
|
+
return splitCssTopLevel(clean, ',').some((part) => {
|
|
1133
|
+
const compounds = part.trim().split(/\s*[>+~]\s*|\s+/);
|
|
1134
|
+
const subject = compounds[compounds.length - 1] || '';
|
|
1135
|
+
if (subject.indexOf('::') >= 0)
|
|
1136
|
+
return false;
|
|
1137
|
+
return /(?:^|[^\w.#:-])(?:input|textarea|select)(?![\w-])/i.test(subject)
|
|
1138
|
+
|| /\.(?:input|field)(?![\w-])/i.test(subject);
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* v27 helper (engine 1.3.1): in each rule whose selector styles a field
|
|
1143
|
+
* (isFieldSelector), every `font-size: var(...)` declaration is rewritten to
|
|
1144
|
+
* its value from the :root token table, through substituteCssVars (var()
|
|
1145
|
+
* chains, and a var()'s own fallback where no token declares the name). A
|
|
1146
|
+
* value that does not resolve is left exactly as written, and so is every rule
|
|
1147
|
+
* that does not style a field: v27's literal scan matches its keywords
|
|
1148
|
+
* anywhere before a brace, so resolving var() in every rule would have read
|
|
1149
|
+
* designesy.org's `.field-card-meta { font-size: var(--text-label) }` (12.8px,
|
|
1150
|
+
* a card caption) as an input below 16px. Only values that start with var(
|
|
1151
|
+
* are touched; calc(), clamp() and min()/max() stay as written, as before.
|
|
1152
|
+
*
|
|
1153
|
+
* The scan is anchored on each `font-size: var(` and reads its rule's selector
|
|
1154
|
+
* backwards from the opening brace, so its cost is linear in the sheet. A
|
|
1155
|
+
* whole-sheet rule regex (`([^{}]*)\{([^{}]*)\}`) is quadratic on a long
|
|
1156
|
+
* @media prelude: measured at +4.5 s on five 43 KB preludes.
|
|
1157
|
+
*/
|
|
1158
|
+
function resolveFontSizeVars(css, tokens) {
|
|
1159
|
+
if (css.indexOf('var(') < 0)
|
|
1160
|
+
return css;
|
|
1161
|
+
return css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, (decl, lead, value, offset) => {
|
|
1162
|
+
const open = css.lastIndexOf('{', offset);
|
|
1163
|
+
// Not inside a rule body (a stray declaration, or a brace closed between).
|
|
1164
|
+
if (open < 0 || css.lastIndexOf('}', offset) > open)
|
|
1165
|
+
return decl;
|
|
1166
|
+
const start = Math.max(css.lastIndexOf('}', open - 1), css.lastIndexOf('{', open - 1), css.lastIndexOf(';', open - 1)) + 1;
|
|
1167
|
+
if (!isFieldSelector(css.slice(start, open)))
|
|
1168
|
+
return decl;
|
|
1169
|
+
const sub = substituteCssVars(value, [tokens]);
|
|
1170
|
+
return sub === null ? decl : lead + sub.trim();
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1101
1173
|
/**
|
|
1102
1174
|
* v27 helper: does the delivered markup carry a field iOS Safari zooms into on
|
|
1103
1175
|
* focus? That is a <textarea>, a <select>, or an <input> of any type except the
|
|
@@ -1124,12 +1196,20 @@ function hasZoomableField(html) {
|
|
|
1124
1196
|
// unchanged: a sub-16px input rule still FAILs and a declared floor still PASSes,
|
|
1125
1197
|
// because both describe the stylesheet that fields rendered later by script
|
|
1126
1198
|
// would also inherit.
|
|
1127
|
-
|
|
1199
|
+
//
|
|
1200
|
+
// Since engine 1.3.1 a font-size written as var() is read through the :root
|
|
1201
|
+
// token table first (resolveFontSizeVars), so `input { font-size:
|
|
1202
|
+
// var(--text-body) }` with `--text-body: 1rem` is the 16px floor it renders as.
|
|
1203
|
+
// Both regexes below read literals only; before 1.3.1 that rule read WARN ("no
|
|
1204
|
+
// explicit input font-size ≥16px detected") on designesy.org. A var() the table
|
|
1205
|
+
// cannot resolve is left as written, so it reads as it did before.
|
|
1206
|
+
function checkInputFontFloor(css, html, tokens = {}) {
|
|
1207
|
+
const sheet = resolveFontSizeVars(css, tokens);
|
|
1128
1208
|
// Find input/textarea/select rules with font-size < 16px or < 1rem.
|
|
1129
1209
|
const inputRe = /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi;
|
|
1130
1210
|
const below = [];
|
|
1131
1211
|
let m;
|
|
1132
|
-
while ((m = inputRe.exec(
|
|
1212
|
+
while ((m = inputRe.exec(sheet)) !== null) {
|
|
1133
1213
|
const val = parseFloat(m[1]);
|
|
1134
1214
|
const unit = m[2].toLowerCase();
|
|
1135
1215
|
const px = unit === 'rem' ? val * 16 : val;
|
|
@@ -1140,8 +1220,8 @@ function checkInputFontFloor(css, html) {
|
|
|
1140
1220
|
// Also check: is there a global input font-size ≥16px? (e.g. `input { font-size: 1rem }`)
|
|
1141
1221
|
// The strict form `input\s*\{` matches standalone rules; the grouped form
|
|
1142
1222
|
// `input,...,{` matches selectors merged by CSS minifiers (e.g. `input,textarea,select{font-size:1rem}`).
|
|
1143
|
-
const hasGlobalFloor = /input\s*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(
|
|
1144
|
-
|| /input\s*[,][^{]*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(
|
|
1223
|
+
const hasGlobalFloor = /input\s*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(sheet)
|
|
1224
|
+
|| /input\s*[,][^{]*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(sheet);
|
|
1145
1225
|
if (hasGlobalFloor)
|
|
1146
1226
|
return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
|
|
1147
1227
|
if (!hasZoomableField(html))
|
|
@@ -1391,7 +1471,8 @@ function checkSemanticStatusRoles(tokens) {
|
|
|
1391
1471
|
families.ok = true;
|
|
1392
1472
|
if (segments.some((s) => ['warn', 'warning', 'caution'].includes(s)))
|
|
1393
1473
|
families.warn = true;
|
|
1394
|
-
|
|
1474
|
+
// 'destructive' is shadcn/ui's error role (--destructive); engine 1.3.1.
|
|
1475
|
+
if (segments.some((s) => ['error', 'danger', 'fail', 'critical', 'negative', 'destructive'].includes(s)))
|
|
1395
1476
|
families.error = true;
|
|
1396
1477
|
if (segments.some((s) => ['info', 'notice', 'informational'].includes(s)))
|
|
1397
1478
|
families.info = true;
|
|
@@ -3343,6 +3424,25 @@ const RECOGNIZED_BUTTON_COMMANDS = new Set([
|
|
|
3343
3424
|
'find', 'replace', 'navigate', 'run', 'score', 'review', 'verify',
|
|
3344
3425
|
'detect', 'assess', 'enforce', 'dismiss', 'analyze', 'inspect', 'check',
|
|
3345
3426
|
'evaluate', 'validate', 'test', 'monitor', 'track', 'measure', 'scan',
|
|
3427
|
+
// Engine 1.3.1. get, set and try: the comment in checkButtonTextVerb said
|
|
3428
|
+
// "Get X", "Set X" and "Try X" were allowed, but none of the three was in
|
|
3429
|
+
// this set, so "Get started" and "Try it free" read WARN on any site.
|
|
3430
|
+
'get', 'set', 'try',
|
|
3431
|
+
// Imperative CTA verbs that the suffix heuristic in isVerbLike misses (it
|
|
3432
|
+
// already accepts explore, collapse, toggle, choose and compare by their -e
|
|
3433
|
+
// ending). Each is read as a verb at the start of a button label, and none
|
|
3434
|
+
// names a thing a button would be labelled with on its own:
|
|
3435
|
+
// flip "Flip card", "Flip to the flame": a command, no noun label use
|
|
3436
|
+
// learn "Learn more": verb only
|
|
3437
|
+
// sign "Sign in", "Sign up", "Sign out": the noun reading does not
|
|
3438
|
+
// lead a button label
|
|
3439
|
+
// join "Join the waitlist": verb only
|
|
3440
|
+
// expand "Expand all", "Expand section": verb only
|
|
3441
|
+
// select "Select all", "Select plan": an imperative in button use
|
|
3442
|
+
// read "Read more", "Read the guide": an imperative in button use
|
|
3443
|
+
// Left out on purpose: switch and watch, which are common nouns ("Switch",
|
|
3444
|
+
// a toggle's name; "Watch", a product) as well as verbs.
|
|
3445
|
+
'flip', 'learn', 'sign', 'join', 'expand', 'select', 'read',
|
|
3346
3446
|
]);
|
|
3347
3447
|
function isVerbLike(word) {
|
|
3348
3448
|
const w = word.toLowerCase().trim();
|
|
@@ -3440,12 +3540,41 @@ function checkButtonTextVerb(html) {
|
|
|
3440
3540
|
// with no space between). A space separates a real word: "Alt text" is a
|
|
3441
3541
|
// label, not a shortcut, and must not be eaten.
|
|
3442
3542
|
const TEXT_SHORTCUT_RE = /[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/;
|
|
3543
|
+
// Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
|
|
3544
|
+
// "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
|
|
3545
|
+
// must stand as its own word (the start of the label or after a space), so
|
|
3546
|
+
// "Halt+1" is untouched, and only the plus form is read: a modifier followed
|
|
3547
|
+
// by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
|
|
3548
|
+
const PLUS_SHORTCUT_RE = /(?:^|\s)(?:ctrl|cmd|shift|alt|option|command)(?:\s*\+\s*[a-z0-9]+)+$/i;
|
|
3549
|
+
const stripShortcut = (s) => s.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').replace(PLUS_SHORTCUT_RE, '').trim();
|
|
3550
|
+
// Engine 1.3.1: keycaps are read from the markup, not guessed from text.
|
|
3551
|
+
// A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
|
|
3552
|
+
// a hint beside the label rather than the label, so its text is dropped
|
|
3553
|
+
// before the verb check, innermost first so nested keycaps
|
|
3554
|
+
// (<kbd><kbd>Ctrl</kbd>+<kbd>K</kbd></kbd>) go too. designesy.org's flip
|
|
3555
|
+
// button, <kbd>ctrl</kbd> <span aria-hidden="true">+</span> <kbd>i</kbd>
|
|
3556
|
+
// after a hidden lead, read as "Flip to the flame: ctrl i" in 1.3.0. A "+"
|
|
3557
|
+
// left between dropped keycaps is dropped with them. A button whose only
|
|
3558
|
+
// text is keycaps is then empty and is judged as an icon-only button: by its
|
|
3559
|
+
// aria-label when it has one, and not at all when it does not.
|
|
3560
|
+
const labelText = (inner) => {
|
|
3561
|
+
const visible = stripAriaHidden(inner);
|
|
3562
|
+
let out = visible;
|
|
3563
|
+
for (let i = 0; i < 4; i++) {
|
|
3564
|
+
const next = out.replace(/<kbd\b[^>]*>(?:(?!<kbd\b)[\s\S])*?<\/kbd>/gi, ' ');
|
|
3565
|
+
if (next === out)
|
|
3566
|
+
break;
|
|
3567
|
+
out = next;
|
|
3568
|
+
}
|
|
3569
|
+
const text = out.replace(/<[^>]*>/g, '').trim();
|
|
3570
|
+
return out === visible ? text : text.replace(/(?:\s*\+)+$/, '').trim();
|
|
3571
|
+
};
|
|
3443
3572
|
while ((m = buttonRe.exec(html)) !== null) {
|
|
3444
|
-
let text =
|
|
3573
|
+
let text = labelText(m[1]);
|
|
3445
3574
|
// Strip leading icon characters so "✕Close" → "Close"
|
|
3446
3575
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3447
3576
|
// Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
|
|
3448
|
-
text = text
|
|
3577
|
+
text = stripShortcut(text);
|
|
3449
3578
|
// Icon-only button with an accessible name: use the aria-label verb.
|
|
3450
3579
|
if (!text) {
|
|
3451
3580
|
const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
|
|
@@ -3466,9 +3595,9 @@ function checkButtonTextVerb(html) {
|
|
|
3466
3595
|
buttonTexts.push({ text, tag: m[0] });
|
|
3467
3596
|
}
|
|
3468
3597
|
while ((m = roleButtonRe.exec(html)) !== null) {
|
|
3469
|
-
let text =
|
|
3598
|
+
let text = labelText(m[1]);
|
|
3470
3599
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3471
|
-
text = text
|
|
3600
|
+
text = stripShortcut(text);
|
|
3472
3601
|
if (!text) {
|
|
3473
3602
|
const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
|
|
3474
3603
|
if (aria)
|
|
@@ -3530,7 +3659,9 @@ function checkButtonTextVerb(html) {
|
|
|
3530
3659
|
// Allow if first word is verb-like
|
|
3531
3660
|
if (isVerbLike(firstWord))
|
|
3532
3661
|
continue;
|
|
3533
|
-
//
|
|
3662
|
+
// "Get X", "Set X", "Try X" are allowed above: get, set and try are in
|
|
3663
|
+
// RECOGNIZED_BUTTON_COMMANDS (since engine 1.3.1; this comment claimed it
|
|
3664
|
+
// before they were).
|
|
3534
3665
|
// If we get here, the first word is likely a noun → potential violation
|
|
3535
3666
|
violations.push(`"${text}"`);
|
|
3536
3667
|
}
|
|
@@ -4195,7 +4326,7 @@ export async function scoreFromParts(input) {
|
|
|
4195
4326
|
checkTouchTargets(css),
|
|
4196
4327
|
checkHeadingHierarchy(html),
|
|
4197
4328
|
checkFontFamilyCount(css, tokens),
|
|
4198
|
-
checkInputFontFloor(css, html),
|
|
4329
|
+
checkInputFontFloor(css, html, rawTokens),
|
|
4199
4330
|
checkReadingWidth(css),
|
|
4200
4331
|
checkTokenLayerDepth(tokens),
|
|
4201
4332
|
checkSemanticColorVocabulary(tokens),
|
|
@@ -4704,7 +4835,7 @@ export async function scoreFromParts(input) {
|
|
|
4704
4835
|
* the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
|
|
4705
4836
|
* contract source and fails when the two differ.
|
|
4706
4837
|
*/
|
|
4707
|
-
export const CONTRACT_VERSION = 'v0.4.
|
|
4838
|
+
export const CONTRACT_VERSION = 'v0.4.5';
|
|
4708
4839
|
/**
|
|
4709
4840
|
* The one verdict rule, shared by every emission format.
|
|
4710
4841
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@designesy/score",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "Design quality gate for AI-generated UI: score any URL on 44 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>",
|
|
7
7
|
"homepage": "https://www.designesy.org/score",
|