@askewly/design 0.4.4 → 0.4.5
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/data/knowledge.json +1 -1
- package/data/recipes.json +1 -1
- package/data/terms.json +1 -1
- package/dist/index.js +19 -5
- package/dist/inject.js +8 -3
- package/dist/kickstart.js +13 -0
- package/dist/slop.js +400 -0
- package/dist/verify.js +35 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -198,16 +198,30 @@ program
|
|
|
198
198
|
write(` ${path.relative(target, skip.file)}:${skip.line} — ${skip.reason}`);
|
|
199
199
|
}
|
|
200
200
|
};
|
|
201
|
-
|
|
201
|
+
// M53 split the report by severity. Warnings are layer-1 slop detections:
|
|
202
|
+
// they name a spot a human (or layer 2) should look at, and a detection is
|
|
203
|
+
// not yet a verdict, so they must not fail the run.
|
|
204
|
+
const errors = violations.filter((v) => v.severity === "error");
|
|
205
|
+
const warnings = violations.filter((v) => v.severity === "warn");
|
|
206
|
+
const format = (violation) => ` ${path.relative(target, violation.file)}:${violation.line} [${violation.rule}] ${violation.excerpt}`;
|
|
207
|
+
const printWarnings = (write) => {
|
|
208
|
+
if (warnings.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
write(`\n${warnings.length} slop signal(s) — detected, not judged (methodology/slop-verification-boundary.md):`);
|
|
211
|
+
for (const warning of warnings)
|
|
212
|
+
write(format(warning));
|
|
213
|
+
};
|
|
214
|
+
if (errors.length === 0) {
|
|
202
215
|
console.log(`verify PASS — ${files} file(s) scanned, no color literals and no file over ${typographyThreshold} type sizes`);
|
|
203
216
|
printSkips((line) => console.log(line));
|
|
217
|
+
printWarnings((line) => console.log(line));
|
|
204
218
|
return;
|
|
205
219
|
}
|
|
206
|
-
console.error(`verify FAIL — ${
|
|
207
|
-
for (const violation of
|
|
208
|
-
console.error(
|
|
209
|
-
}
|
|
220
|
+
console.error(`verify FAIL — ${errors.length} violation(s) in ${files} file(s):`);
|
|
221
|
+
for (const violation of errors)
|
|
222
|
+
console.error(format(violation));
|
|
210
223
|
printSkips((line) => console.error(line));
|
|
224
|
+
printWarnings((line) => console.error(line));
|
|
211
225
|
console.error("\nFix: replace color literals with semantic token utilities (bg-primary, text-foreground, ...);");
|
|
212
226
|
console.error(" collapse extra type sizes onto the token scale, or raise --typography-threshold.");
|
|
213
227
|
process.exit(1);
|
package/dist/inject.js
CHANGED
|
@@ -37,10 +37,15 @@ export function extractCodeSnippet(recipe) {
|
|
|
37
37
|
throw new InjectError(`recipe "${recipe.id}" has no ## Code section`);
|
|
38
38
|
}
|
|
39
39
|
const fence = codeSection.match(/```[a-z]*\n([\s\S]*?)```/);
|
|
40
|
-
if (
|
|
41
|
-
|
|
40
|
+
if (fence) {
|
|
41
|
+
return fence[1].trimEnd() + "\n";
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
// 최근 회차 recipe 는 발췌 대신 code_asset 파일을 가리킨다 — build-data 가 그 원문을
|
|
44
|
+
// code_source 로 실어 주므로 오프라인 add 가 여기서도 성립한다 (2026-08-20).
|
|
45
|
+
if (recipe.code_source) {
|
|
46
|
+
return recipe.code_source.trimEnd() + "\n";
|
|
47
|
+
}
|
|
48
|
+
throw new InjectError(`recipe "${recipe.id}" has no fenced code block under ## Code`);
|
|
44
49
|
}
|
|
45
50
|
/** Extracts list items of a `## <title>` section (used for Checks / Anti-patterns notes). */
|
|
46
51
|
export function extractSectionNotes(recipe, title) {
|
package/dist/kickstart.js
CHANGED
|
@@ -261,6 +261,19 @@ ${vars(c.light, a.light, a.onLight, a.ring, "light")}
|
|
|
261
261
|
${vars(c.dark, a.dark, a.onDark, a.darkRing, "dark")}
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/* Reserve the vertical scrollbar's track at all times. Without this the page
|
|
265
|
+
* jumps sideways by the scrollbar width whenever content height crosses the
|
|
266
|
+
* viewport — a row expands, a filter narrows a list, a modal locks scroll — and
|
|
267
|
+
* it reads as the whole layout twitching. \`scrollbar-gutter: stable\` alone does
|
|
268
|
+
* NOT do it: that property has no effect while overflow is \`visible\`, which is
|
|
269
|
+
* the root element's default. Both declarations, or neither works. Measured
|
|
270
|
+
* 2026-08-12 on ui.askewly.com: clientWidth flipped 2545 <-> 2560 between a tall
|
|
271
|
+
* and a short page, and three separate design verdicts blamed the assets for it. */
|
|
272
|
+
html {
|
|
273
|
+
overflow-y: scroll;
|
|
274
|
+
scrollbar-gutter: stable;
|
|
275
|
+
}
|
|
276
|
+
|
|
264
277
|
@theme inline {
|
|
265
278
|
--color-background: var(--background);
|
|
266
279
|
--color-foreground: var(--foreground);
|
package/dist/slop.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer-1 slop rules (M53).
|
|
3
|
+
*
|
|
4
|
+
* These implement the thresholds fixed in
|
|
5
|
+
* `methodology/slop-verification-boundary.md` §정량 임계치. Every rule here is
|
|
6
|
+
* a pair: `extract` pulls the signal out of the source, `score` compares it to
|
|
7
|
+
* a threshold T. The split is not decoration — T moves when new evidence
|
|
8
|
+
* arrives, and it must move without anyone reopening the extraction.
|
|
9
|
+
*
|
|
10
|
+
* Two boundaries hold this file down:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Detection only.** Layer 1 answers "is it there", never "is it right".
|
|
13
|
+
* Glassmorphism *exists* is a fact; whether it communicates depth or just
|
|
14
|
+
* decorates is layer 2. A rule that reaches for the second question stops
|
|
15
|
+
* being deterministic.
|
|
16
|
+
* 2. **Severity is not cosmetic.** Only a rule the catalog already bans *and*
|
|
17
|
+
* that the external survey classified as deterministic-with-no-caveat gets
|
|
18
|
+
* to fail a build. Everything else warns. Failing all of them would rebuild
|
|
19
|
+
* the ban list that D3 threw out: the problem was never the value, it was
|
|
20
|
+
* that nobody chose it.
|
|
21
|
+
*/
|
|
22
|
+
export function toLines(masked) {
|
|
23
|
+
return masked.split("\n").map((text, index) => ({ line: index + 1, text }));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Strips Tailwind variant prefixes: `md:dark:backdrop-blur-sm` → `backdrop-blur-sm`.
|
|
27
|
+
* Variants change *when* a utility applies, never *what* it is, so a rule that
|
|
28
|
+
* reads the prefix as part of the name sees a different class on every screen.
|
|
29
|
+
*/
|
|
30
|
+
export function stripVariants(utility) {
|
|
31
|
+
const parts = utility.split(":");
|
|
32
|
+
return parts[parts.length - 1];
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// 후보 1 — backdrop-filter (glassmorphism). T = 1 (존재하면 검출). 경고.
|
|
36
|
+
//
|
|
37
|
+
// Threshold source: 슬롭 판정 경계 §정량 임계치 #1 — external survey §3.4 P2,
|
|
38
|
+
// five independent sources (Hallmark·Krebs·Impeccable·DeleteSlop·Calvo,
|
|
39
|
+
// 접근일 2026-08-13), plus the local catalog's A section.
|
|
40
|
+
//
|
|
41
|
+
// Warning, not failure: the survey files P2 as "존재는 A, 정당성은 B" and
|
|
42
|
+
// Hallmark conditions it on purpose ("works only when communicating depth").
|
|
43
|
+
// So this rule reports the site and hands the judgment to layer 2.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
const GLASS_CSS = /backdrop-filter\s*:/;
|
|
46
|
+
/**
|
|
47
|
+
* The utility form. The trailing lookahead is what separates a class from a
|
|
48
|
+
* mention: a utility in a class list is always followed by whitespace, a
|
|
49
|
+
* quote, or a closing bracket. M53 step-3 measured the alternative — a plain
|
|
50
|
+
* `\b` also matched the prose "backdrop-blur가 전경 가독성을 보존합니다" in a
|
|
51
|
+
* paragraph *about* glassmorphism, because a Korean particle ends an ASCII
|
|
52
|
+
* word too.
|
|
53
|
+
*/
|
|
54
|
+
const GLASS_UTILITY = /(?:^|[\s"'`{(])(?:[a-z0-9[\]-]+:)*backdrop-blur(?:-[a-z0-9[\].\/-]+)?(?=["'`\s})\],;]|$)/;
|
|
55
|
+
/**
|
|
56
|
+
* A quoted literal that holds nothing but the utility name, and that no class
|
|
57
|
+
* attribute introduces, is an identifier rather than a class list — a term id
|
|
58
|
+
* in an array, or the right-hand side of `kind === "backdrop-blur"`. Both were
|
|
59
|
+
* measured in step-3.
|
|
60
|
+
*
|
|
61
|
+
* The lookbehind names the class attribute rather than a bare `=`: an equals
|
|
62
|
+
* sign also precedes `kind === "backdrop-blur"`, so the looser form let that
|
|
63
|
+
* comparison through on the first pass.
|
|
64
|
+
*/
|
|
65
|
+
const GLASS_IDENTIFIER = /(?<!class(?:Name)?\s{0,3}=\s{0,3})(["'`])backdrop-blur(?:-[a-z0-9-]+)?\1/g;
|
|
66
|
+
/** Lines that declare a backdrop blur, in CSS or as a Tailwind utility. */
|
|
67
|
+
export function extractGlass(lines) {
|
|
68
|
+
return lines.filter(({ text }) => {
|
|
69
|
+
if (GLASS_CSS.test(text))
|
|
70
|
+
return true;
|
|
71
|
+
return GLASS_UTILITY.test(text.replace(GLASS_IDENTIFIER, ""));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export function scoreGlass(signal, threshold = GLASS_LIMIT) {
|
|
75
|
+
if (signal.length < threshold)
|
|
76
|
+
return [];
|
|
77
|
+
return signal.map(({ line, text }) => ({
|
|
78
|
+
line,
|
|
79
|
+
rule: "slop-glass",
|
|
80
|
+
excerpt: `backdrop blur: ${text.trim().slice(0, 100)}`,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
/** One occurrence is the signal. There is no "a little glassmorphism". */
|
|
84
|
+
export const GLASS_LIMIT = 1;
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// 후보 2 — gradient text. T = 1 (조합이 한 번이라도 성립하면 검출). 하드 실패.
|
|
87
|
+
//
|
|
88
|
+
// Threshold source: 슬롭 판정 경계 §정량 임계치 #2 — external survey §3.1 C3
|
|
89
|
+
// and its A table ("헤드라인은 background-clip: text 존재로 확정"), three
|
|
90
|
+
// sources (Hallmark G2·Krebs·Impeccable, 접근일 2026-08-13). The local
|
|
91
|
+
// catalog already bans it outright, which is the second half of the only
|
|
92
|
+
// condition that earns a hard failure.
|
|
93
|
+
//
|
|
94
|
+
// The clip and the gradient are read as a pair on purpose. `bg-clip-text`
|
|
95
|
+
// alone is a legitimate way to paint text with an image, and a gradient alone
|
|
96
|
+
// is a background. It is the combination that the survey named.
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
const CLIP_TO_TEXT = /(?:-webkit-)?background-clip\s*:\s*text|(?:^|[\s"'`{(])(?:[a-z0-9[\]-]+:)*bg-clip-text\b/;
|
|
99
|
+
const GRADIENT = /(?:linear|radial|conic)-gradient\(|(?:^|[\s"'`{(])(?:[a-z0-9[\]-]+:)*bg-(?:gradient-to-[trbl]+|linear-to-[trbl]+|linear-\d|radial|conic)\b/;
|
|
100
|
+
/**
|
|
101
|
+
* The two halves, each with the line it was found on. Whole-file scope: the
|
|
102
|
+
* clip lives on the element and the gradient often on the line above or in a
|
|
103
|
+
* sibling class string, so a per-line rule would see neither.
|
|
104
|
+
*/
|
|
105
|
+
export function extractGradientText(lines) {
|
|
106
|
+
return {
|
|
107
|
+
clip: lines.find(({ text }) => CLIP_TO_TEXT.test(text)) ?? null,
|
|
108
|
+
gradient: lines.find(({ text }) => GRADIENT.test(text)) ?? null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function scoreGradientText(signal, threshold = GRADIENT_TEXT_LIMIT) {
|
|
112
|
+
const pairs = signal.clip && signal.gradient ? 1 : 0;
|
|
113
|
+
if (pairs < threshold)
|
|
114
|
+
return [];
|
|
115
|
+
const clip = signal.clip;
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
line: clip.line,
|
|
119
|
+
rule: "slop-gradient-text",
|
|
120
|
+
// Reported at the clip, pointing at the gradient: the fix is to drop one
|
|
121
|
+
// of the two, and the reader needs both addresses to choose which.
|
|
122
|
+
excerpt: `gradient text: background-clip to text here, gradient at line ${signal.gradient.line}`,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
/** One pairing is the signal. */
|
|
127
|
+
export const GRADIENT_TEXT_LIMIT = 1;
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// 후보 5 — 폰트가 디폴트뿐. T = 부분집합 성립 (집합 크기와 무관). 경고.
|
|
130
|
+
//
|
|
131
|
+
// Threshold source: 슬롭 판정 경계 §정량 임계치 #5 — external survey §3.2
|
|
132
|
+
// T1·T2 (접근일 2026-08-13). Rank-2 signal, seven sources.
|
|
133
|
+
//
|
|
134
|
+
// Warning by D3: the same name is a choice in one project and a leftover in
|
|
135
|
+
// another, and a text scanner cannot tell them apart. What it can do is say
|
|
136
|
+
// "every face on this page is one the survey keeps naming" and let a person
|
|
137
|
+
// answer whether that was on purpose.
|
|
138
|
+
//
|
|
139
|
+
// ⚠ Membership here does not mean "framework default". The survey could not
|
|
140
|
+
// confirm the common claim that Geist is Next.js's default font
|
|
141
|
+
// (https://vercel.com/font carries no such wording, 접근일 2026-08-13). These
|
|
142
|
+
// names are on the list because the survey's sources keep naming them, which
|
|
143
|
+
// is a weaker and more honest reason.
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
const DEFAULT_FACES = new Set([
|
|
146
|
+
// Hallmark G1 — display set by a system or Google-default face.
|
|
147
|
+
"inter",
|
|
148
|
+
"roboto",
|
|
149
|
+
"open sans",
|
|
150
|
+
"poppins",
|
|
151
|
+
"lato",
|
|
152
|
+
"system-ui",
|
|
153
|
+
"-apple-system",
|
|
154
|
+
"blinkmacsystemfont",
|
|
155
|
+
"segoe ui",
|
|
156
|
+
"helvetica",
|
|
157
|
+
"helvetica neue",
|
|
158
|
+
"arial",
|
|
159
|
+
"sans-serif",
|
|
160
|
+
"serif",
|
|
161
|
+
"monospace",
|
|
162
|
+
// Krebs / Design Slop Cop / Impeccable — the 2026 "tasteful default" set.
|
|
163
|
+
"space grotesk",
|
|
164
|
+
"instrument serif",
|
|
165
|
+
"geist",
|
|
166
|
+
"geist mono",
|
|
167
|
+
"syne",
|
|
168
|
+
"fraunces",
|
|
169
|
+
]);
|
|
170
|
+
// `font-family: "Söhne", Inter, sans-serif` and the JS object form.
|
|
171
|
+
const FONT_FAMILY_CSS = /font-family\s*:\s*([^;}\n]+)/g;
|
|
172
|
+
const FONT_FAMILY_JS = /fontFamily\s*:\s*["'`]([^"'`]+)["'`]/g;
|
|
173
|
+
/**
|
|
174
|
+
* The first name of each font stack, lowercased.
|
|
175
|
+
*
|
|
176
|
+
* Only the first position counts, matching how the survey states the check
|
|
177
|
+
* ("computed `font-family` 첫 항목이 금지 목록에 있는지"). Everything after it
|
|
178
|
+
* is a fallback the user is unlikely to ever see, and counting fallbacks would
|
|
179
|
+
* flag every stack ever written, since they all end in a generic name.
|
|
180
|
+
*/
|
|
181
|
+
export function extractFontFaces(lines) {
|
|
182
|
+
const faces = new Set();
|
|
183
|
+
let first = null;
|
|
184
|
+
for (const entry of lines) {
|
|
185
|
+
for (const rule of [FONT_FAMILY_CSS, FONT_FAMILY_JS]) {
|
|
186
|
+
rule.lastIndex = 0;
|
|
187
|
+
for (const match of entry.text.matchAll(rule)) {
|
|
188
|
+
const head = match[1].split(",")[0].trim().replace(/^["']|["']$/g, "").toLowerCase();
|
|
189
|
+
if (head.length === 0 || head.startsWith("var("))
|
|
190
|
+
continue;
|
|
191
|
+
faces.add(head);
|
|
192
|
+
first ??= entry;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { faces, first };
|
|
197
|
+
}
|
|
198
|
+
export function scoreDefaultFontOnly(signal, defaults = DEFAULT_FACES) {
|
|
199
|
+
if (signal.faces.size === 0 || signal.first === null)
|
|
200
|
+
return [];
|
|
201
|
+
const outliers = [...signal.faces].filter((face) => !defaults.has(face));
|
|
202
|
+
if (outliers.length > 0)
|
|
203
|
+
return [];
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
line: signal.first.line,
|
|
207
|
+
rule: "slop-default-font-only",
|
|
208
|
+
excerpt: `every declared face is one the survey names: ${[...signal.faces].join(", ")}`,
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// 후보 9 — 분리 수단 3개 이상 중첩. T = 3. 경고.
|
|
214
|
+
//
|
|
215
|
+
// Threshold source: 슬롭 판정 경계 §정량 임계치 #9 — KG
|
|
216
|
+
// `separation-means-overuse-is-ai-slop` ("한 블록에 분리 수단이 3개 이상
|
|
217
|
+
// 겹치면 슬롭 신호다", 2026-08-06), resting on knowledge/dashboard-density.md
|
|
218
|
+
// §2 and Apple HIG Layout (접근일 2026-08-12).
|
|
219
|
+
//
|
|
220
|
+
// This is the one rule here whose source is ours rather than the survey's. The
|
|
221
|
+
// survey lists the symptoms separately (nested cards, side stripes, hairline
|
|
222
|
+
// plus shadow) but never names the count as the cause. That also means the
|
|
223
|
+
// evidence is thinner: the KG node is `status: draft` off a single
|
|
224
|
+
// measurement, which is exactly why this warns instead of failing — and why
|
|
225
|
+
// running it across the live assets doubles as the reproduction check that
|
|
226
|
+
// node has been waiting for.
|
|
227
|
+
//
|
|
228
|
+
// Padding is deliberately not counted. It is level 1 of the hierarchy and the
|
|
229
|
+
// prescribed default; counting it would make every padded surface a finding
|
|
230
|
+
// and say nothing. The measured case counted tint + left bar + border + badge.
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
/** `className="…"`, `class='…'`, and the quoted parts of `className={…}`. */
|
|
233
|
+
const CLASS_ATTRIBUTE = /class(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([\s\S]*?)\})/g;
|
|
234
|
+
const QUOTED_INSIDE_BRACES = /["'`]([^"'`]*)["'`]/g;
|
|
235
|
+
/**
|
|
236
|
+
* The separation means present in one class string, by name.
|
|
237
|
+
*
|
|
238
|
+
* Three are counted: background difference, elevation, and the left accent
|
|
239
|
+
* bar. The bar stays apart from the border because the measured case had both.
|
|
240
|
+
*
|
|
241
|
+
* **Rounding and border are one mean, not two** (M53 step-3 correction).
|
|
242
|
+
* Counting them separately scored every ordinary card at 3 and made the rule
|
|
243
|
+
* fire 763 times across 158 files, 752 of them the identical
|
|
244
|
+
* `rounding + border + background` triple. Our own published knowledge says
|
|
245
|
+
* why that count is wrong: knowledge/dashboard-density.md §2-나 (M48,
|
|
246
|
+
* 2026-08-12) has border, radius and shadow chosen together as **one elevation
|
|
247
|
+
* step**, following Geist. One decision must not score as two.
|
|
248
|
+
*
|
|
249
|
+
* T did not move. The threshold is still 3, per the KG node; what was wrong
|
|
250
|
+
* was the signal definition, and the measured case it came from still trips
|
|
251
|
+
* the rule after the fold (tint + left bar + elevation = 3).
|
|
252
|
+
*/
|
|
253
|
+
export function separationMeans(classes) {
|
|
254
|
+
const utilities = classes.split(/\s+/).filter(Boolean).map(stripVariants);
|
|
255
|
+
const means = new Set();
|
|
256
|
+
for (const utility of utilities) {
|
|
257
|
+
// `bg-clip-text`, `bg-gradient-to-r`, `bg-cover` and friends set no surface.
|
|
258
|
+
if (/^bg-(?!clip-|gradient-|linear-|radial|conic|cover|contain|center|top|bottom|left|right|repeat|no-repeat|fixed|local|scroll|origin-|blend-|none$|transparent$|inherit$|current$)[a-z0-9]/.test(utility)) {
|
|
259
|
+
means.add("background");
|
|
260
|
+
}
|
|
261
|
+
if (/^rounded(?:-(?!none$)[a-z0-9[\]/.-]+)?$/.test(utility))
|
|
262
|
+
means.add("elevation");
|
|
263
|
+
if (/^border-l(?:-|$)/.test(utility))
|
|
264
|
+
means.add("left-bar");
|
|
265
|
+
else if (/^border(?:-(?!l$|l-)[a-z0-9[\]/.-]+)?$/.test(utility) && !/^border-(?:none|0)$/.test(utility)) {
|
|
266
|
+
means.add("elevation");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return [...means];
|
|
270
|
+
}
|
|
271
|
+
export function extractSeparation(lines) {
|
|
272
|
+
const found = [];
|
|
273
|
+
for (const { line, text } of lines) {
|
|
274
|
+
CLASS_ATTRIBUTE.lastIndex = 0;
|
|
275
|
+
for (const match of text.matchAll(CLASS_ATTRIBUTE)) {
|
|
276
|
+
const braces = match[3];
|
|
277
|
+
const classes = braces === undefined ? (match[1] ?? match[2] ?? "") : [...braces.matchAll(QUOTED_INSIDE_BRACES)].map((m) => m[1]).join(" ");
|
|
278
|
+
found.push({ line, means: separationMeans(classes) });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return found;
|
|
282
|
+
}
|
|
283
|
+
export function scoreSeparation(signal, threshold = SEPARATION_LIMIT) {
|
|
284
|
+
return signal
|
|
285
|
+
.filter((block) => block.means.length >= threshold)
|
|
286
|
+
.map(({ line, means }) => ({
|
|
287
|
+
line,
|
|
288
|
+
rule: "slop-separation-overuse",
|
|
289
|
+
excerpt: `${means.length} separation means on one block (limit ${threshold - 1}): ${means.join(" + ")}`,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
/** Three overlapping means is the signal, per the KG node. */
|
|
293
|
+
export const SEPARATION_LIMIT = 3;
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// 후보 10 — 배경 레벨 3단 이상. T = 2 상한. 경고.
|
|
296
|
+
//
|
|
297
|
+
// Threshold source: 슬롭 판정 경계 §정량 임계치 #10 —
|
|
298
|
+
// knowledge/dashboard-density.md §2-3 "페이지 배경은 2단 상한 (Geist
|
|
299
|
+
// Background 1/2)", 근거 장부 research/2026-08-01-m8-dashboard-density-capture.md
|
|
300
|
+
// (Geist Colors 실브라우저 캡처, 접근일 2026-08-01).
|
|
301
|
+
//
|
|
302
|
+
// Same shape as `typography-scale-exceeded`: count distinct steps per file and
|
|
303
|
+
// compare. Only the neutral surfaces count. Accent, status and brand surfaces
|
|
304
|
+
// are a different channel — they carry meaning rather than depth, and folding
|
|
305
|
+
// them in would make every screen with a danger banner a finding.
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
/** Neutral surface roles from the token SSOT (`color.semantic.surface`). */
|
|
308
|
+
const NEUTRAL_SURFACES = new Set(["background", "card", "popover", "muted", "secondary"]);
|
|
309
|
+
/** Off-token grey families. A hand-picked grey is a level too, and the third
|
|
310
|
+
* one is precisely what this rule exists to notice. */
|
|
311
|
+
const GREY_FAMILIES = /^(?:gray|grey|slate|zinc|neutral|stone)-\d{2,3}$/;
|
|
312
|
+
function levelName(utility, prefix, roles) {
|
|
313
|
+
if (!utility.startsWith(`${prefix}-`))
|
|
314
|
+
return null;
|
|
315
|
+
// `bg-muted/60` and `text-foreground/80` are the same level at another alpha.
|
|
316
|
+
const name = utility.slice(prefix.length + 1).split("/")[0];
|
|
317
|
+
if (roles.has(name))
|
|
318
|
+
return name;
|
|
319
|
+
if (GREY_FAMILIES.test(name))
|
|
320
|
+
return name;
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
function extractLevels(lines, prefix, roles) {
|
|
324
|
+
const levels = new Map();
|
|
325
|
+
for (const { line, text } of lines) {
|
|
326
|
+
CLASS_ATTRIBUTE.lastIndex = 0;
|
|
327
|
+
for (const match of text.matchAll(CLASS_ATTRIBUTE)) {
|
|
328
|
+
const braces = match[3];
|
|
329
|
+
const classes = braces === undefined
|
|
330
|
+
? (match[1] ?? match[2] ?? "")
|
|
331
|
+
: [...braces.matchAll(QUOTED_INSIDE_BRACES)].map((m) => m[1]).join(" ");
|
|
332
|
+
for (const utility of classes.split(/\s+/).filter(Boolean).map(stripVariants)) {
|
|
333
|
+
const name = levelName(utility, prefix, roles);
|
|
334
|
+
if (name !== null && !levels.has(name))
|
|
335
|
+
levels.set(name, line);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { levels };
|
|
340
|
+
}
|
|
341
|
+
export function extractSurfaceLevels(lines) {
|
|
342
|
+
return extractLevels(lines, "bg", NEUTRAL_SURFACES);
|
|
343
|
+
}
|
|
344
|
+
function scoreLevels(signal, rule, limit, label) {
|
|
345
|
+
const names = [...signal.levels.keys()];
|
|
346
|
+
if (names.length <= limit)
|
|
347
|
+
return [];
|
|
348
|
+
// Reported where the step that broke the limit first appears, matching how
|
|
349
|
+
// the typography rule points at the breaking line rather than line 1.
|
|
350
|
+
const breaking = [...signal.levels.values()][limit];
|
|
351
|
+
return [
|
|
352
|
+
{
|
|
353
|
+
line: breaking,
|
|
354
|
+
rule,
|
|
355
|
+
excerpt: `${label}: ${names.join(", ")} (${names.length} > limit ${limit})`,
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
}
|
|
359
|
+
export function scoreSurfaceLevels(signal, limit = SURFACE_LEVEL_LIMIT) {
|
|
360
|
+
return scoreLevels(signal, "slop-surface-levels", limit, "neutral surface levels");
|
|
361
|
+
}
|
|
362
|
+
/** Two, per the density doc. The third one is where layers start piling up. */
|
|
363
|
+
export const SURFACE_LEVEL_LIMIT = 2;
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// 후보 11 — 텍스트 색 3단 이상. T = 2 상한. 경고.
|
|
366
|
+
//
|
|
367
|
+
// Threshold source: 슬롭 판정 경계 §정량 임계치 #11 —
|
|
368
|
+
// knowledge/dashboard-density.md §3-2 "텍스트 위계는 2단 — primary/secondary.
|
|
369
|
+
// 세 번째 회색이 필요해 보이면 위계 설계 실패 신호".
|
|
370
|
+
//
|
|
371
|
+
// `card-foreground` and `popover-foreground` fold into `foreground`: in the
|
|
372
|
+
// token SSOT all three resolve to the same value, so counting them apart would
|
|
373
|
+
// report a hierarchy that nobody can see on screen.
|
|
374
|
+
//
|
|
375
|
+
// On-colour roles (`primary-foreground`, `accent-foreground`, and the status
|
|
376
|
+
// foregrounds) are excluded for the same reason surfaces exclude status: they
|
|
377
|
+
// are the readable pair of a coloured surface, not a step in the neutral
|
|
378
|
+
// hierarchy.
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
const NEUTRAL_TEXT = new Set(["foreground", "muted-foreground", "secondary-foreground"]);
|
|
381
|
+
/** Aliases that resolve to the same token value in the SSOT. */
|
|
382
|
+
const TEXT_ALIASES = {
|
|
383
|
+
"card-foreground": "foreground",
|
|
384
|
+
"popover-foreground": "foreground",
|
|
385
|
+
};
|
|
386
|
+
export function extractTextLevels(lines) {
|
|
387
|
+
const raw = extractLevels(lines, "text", new Set([...NEUTRAL_TEXT, ...Object.keys(TEXT_ALIASES)]));
|
|
388
|
+
const levels = new Map();
|
|
389
|
+
for (const [name, line] of raw.levels) {
|
|
390
|
+
const canonical = TEXT_ALIASES[name] ?? name;
|
|
391
|
+
if (!levels.has(canonical))
|
|
392
|
+
levels.set(canonical, line);
|
|
393
|
+
}
|
|
394
|
+
return { levels };
|
|
395
|
+
}
|
|
396
|
+
export function scoreTextLevels(signal, limit = TEXT_LEVEL_LIMIT) {
|
|
397
|
+
return scoreLevels(signal, "slop-text-levels", limit, "neutral text levels");
|
|
398
|
+
}
|
|
399
|
+
/** Two, per the density doc: primary and secondary. */
|
|
400
|
+
export const TEXT_LEVEL_LIMIT = 2;
|
package/dist/verify.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { DEFAULT_TYPOGRAPHY_THRESHOLD, typographyViolation } from "./typography.js";
|
|
4
|
+
import { extractFontFaces, extractGlass, extractGradientText, extractSeparation, extractSurfaceLevels, extractTextLevels, scoreDefaultFontOnly, scoreSeparation, scoreSurfaceLevels, scoreTextLevels, scoreGlass, scoreGradientText, toLines, } from "./slop.js";
|
|
5
|
+
/** Severity per slop rule. Thresholds and the reason for each grade live in
|
|
6
|
+
* `methodology/slop-verification-boundary.md` §정량 임계치. */
|
|
7
|
+
const SLOP_SEVERITY = {
|
|
8
|
+
"slop-glass": "warn",
|
|
9
|
+
"slop-gradient-text": "error",
|
|
10
|
+
"slop-default-font-only": "warn",
|
|
11
|
+
"slop-separation-overuse": "warn",
|
|
12
|
+
"slop-surface-levels": "warn",
|
|
13
|
+
"slop-text-levels": "warn",
|
|
14
|
+
};
|
|
4
15
|
/**
|
|
5
16
|
* File-level opt-out for the typography rule. Some files are not one screen —
|
|
6
17
|
* a gallery holding a dozen demos, a miniature mockup drawn at fractional
|
|
@@ -168,12 +179,33 @@ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS, options = {}) {
|
|
|
168
179
|
continue;
|
|
169
180
|
// Report the untouched source line — the mask is a matching device,
|
|
170
181
|
// not something the reader should have to decode.
|
|
171
|
-
violations.push({ file, line: index + 1, rule: name, excerpt: original[index].trim().slice(0, 120) });
|
|
182
|
+
violations.push({ file, line: index + 1, rule: name, excerpt: original[index].trim().slice(0, 120), severity: "error" });
|
|
172
183
|
}
|
|
173
184
|
});
|
|
174
185
|
// The marker is read from the original source: it lives in a comment, and
|
|
175
186
|
// the masked copy has blanked those out.
|
|
176
187
|
const markerLine = original.findIndex((text) => TYPOGRAPHY_MARKER.test(text));
|
|
188
|
+
const notOneScreen = markerLine !== -1 && (TYPOGRAPHY_MARKER.exec(original[markerLine])?.[1] ?? "").trim().length > 0;
|
|
189
|
+
// Layer-1 slop rules (M53). They read the same masked copy: a blur named in
|
|
190
|
+
// a comment is not a blur that renders.
|
|
191
|
+
//
|
|
192
|
+
// The two counting rules honour the same file-level marker the typography
|
|
193
|
+
// rule uses, because they ask the same question of the same unit: how many
|
|
194
|
+
// steps does *one screen* use. M53 step-3 measured what happens otherwise —
|
|
195
|
+
// six of ten level findings came from gallery files holding a dozen demos
|
|
196
|
+
// each, where the count is arithmetically right and means nothing.
|
|
197
|
+
const lines = toLines(masked);
|
|
198
|
+
const slop = [
|
|
199
|
+
...scoreGlass(extractGlass(lines)),
|
|
200
|
+
...scoreGradientText(extractGradientText(lines)),
|
|
201
|
+
...scoreDefaultFontOnly(extractFontFaces(lines)),
|
|
202
|
+
...scoreSeparation(extractSeparation(lines)),
|
|
203
|
+
...(notOneScreen ? [] : scoreSurfaceLevels(extractSurfaceLevels(lines))),
|
|
204
|
+
...(notOneScreen ? [] : scoreTextLevels(extractTextLevels(lines))),
|
|
205
|
+
];
|
|
206
|
+
for (const finding of slop) {
|
|
207
|
+
violations.push({ file, ...finding, severity: SLOP_SEVERITY[finding.rule] });
|
|
208
|
+
}
|
|
177
209
|
if (markerLine !== -1) {
|
|
178
210
|
const reason = (TYPOGRAPHY_MARKER.exec(original[markerLine])?.[1] ?? "").trim();
|
|
179
211
|
if (reason.length === 0) {
|
|
@@ -182,6 +214,7 @@ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS, options = {}) {
|
|
|
182
214
|
line: markerLine + 1,
|
|
183
215
|
rule: "typography-marker-no-reason",
|
|
184
216
|
excerpt: "askewly-typography-ok needs a reason: askewly-typography-ok: <why this file is not one screen>",
|
|
217
|
+
severity: "error",
|
|
185
218
|
});
|
|
186
219
|
}
|
|
187
220
|
else {
|
|
@@ -200,6 +233,7 @@ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS, options = {}) {
|
|
|
200
233
|
line: typography.line,
|
|
201
234
|
rule: "typography-scale-exceeded",
|
|
202
235
|
excerpt: `font-size steps${where}: ${typography.steps.join(", ")} (${typography.steps.length} > limit ${typography.threshold})`,
|
|
236
|
+
severity: "error",
|
|
203
237
|
});
|
|
204
238
|
}
|
|
205
239
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askewly/design",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Query, inject, and verify the Askewly Design system (tokens, UI vocabulary, recipes) from your terminal or coding agent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://ui.askewly.com",
|