@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/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
- if (violations.length === 0) {
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 — ${violations.length} violation(s) in ${files} file(s):`);
207
- for (const violation of violations) {
208
- console.error(` ${path.relative(target, violation.file)}:${violation.line} [${violation.rule}] ${violation.excerpt}`);
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 (!fence) {
41
- throw new InjectError(`recipe "${recipe.id}" has no fenced code block under ## Code`);
40
+ if (fence) {
41
+ return fence[1].trimEnd() + "\n";
42
42
  }
43
- return fence[1].trimEnd() + "\n";
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.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",