@3xhaust/oh-my-design 0.16.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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,427 @@
1
+ import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { extname, relative, resolve, sep } from 'node:path';
3
+
4
+ export const SLOP_SOURCE_SCHEMA_VERSION = '1.0';
5
+ export const SLOP_SOURCE_MAX_BYTES = 512 * 1024;
6
+
7
+ export type SlopSourceCandidateId =
8
+ | 'mid-sentence-break'
9
+ | 'all-property-transition'
10
+ | 'repeated-kicker-treatment'
11
+ | 'animated-status-glow'
12
+ | 'rounded-accent-callout'
13
+ | 'decorative-ordinal-run'
14
+ | 'default-font-pair'
15
+ | 'global-terminal-styling';
16
+
17
+ export interface SlopSourceCandidate {
18
+ candidateId: SlopSourceCandidateId;
19
+ path: string;
20
+ line: number;
21
+ signals: string[];
22
+ reason: string;
23
+ reviewQuestion: string;
24
+ owner: 'hand';
25
+ }
26
+
27
+ export interface SlopSourceScan {
28
+ schemaVersion: typeof SLOP_SOURCE_SCHEMA_VERSION;
29
+ root: string;
30
+ filesScanned: number;
31
+ candidates: SlopSourceCandidate[];
32
+ }
33
+
34
+ const SOURCE_EXTENSIONS = new Set(['.html', '.css', '.scss', '.js', '.jsx', '.ts', '.tsx']);
35
+ const SKIP_DIRECTORIES = new Set([
36
+ 'node_modules', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt', 'vendor', '.git',
37
+ '.omd', 'test', 'tests', '__tests__', 'fixtures', 'snapshots',
38
+ ]);
39
+ const ROOT_GENERATED_DIRECTORIES = new Set(['agents', 'skills']);
40
+ const LOCKFILES = new Set([
41
+ 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock',
42
+ 'bun.lockb', 'deno.lock',
43
+ ]);
44
+
45
+ const REASONS: Record<SlopSourceCandidateId, Pick<SlopSourceCandidate, 'reason' | 'reviewQuestion'>> = {
46
+ 'mid-sentence-break': {
47
+ reason: 'A heading or paragraph fixes a line break before the sentence has ended.',
48
+ reviewQuestion: 'Does this break express meaning, or should width and wrapping decide the line?',
49
+ },
50
+ 'all-property-transition': {
51
+ reason: 'A transition targets every changing property instead of naming the intended motion.',
52
+ reviewQuestion: 'Which property is meant to animate, and can the transition name only that property?',
53
+ },
54
+ 'repeated-kicker-treatment': {
55
+ reason: 'The same kicker-like treatment repeats across at least three content blocks.',
56
+ reviewQuestion: 'Does each repeated label carry useful hierarchy, or has it become a default decoration?',
57
+ },
58
+ 'animated-status-glow': {
59
+ reason: 'A live-state label combines luminous styling with a ping or pulse treatment.',
60
+ reviewQuestion: 'Does this state need continuous visual urgency, or would a quiet status remain legible?',
61
+ },
62
+ 'rounded-accent-callout': {
63
+ reason: 'A callout combines rounded corners with a thick coloured left edge.',
64
+ reviewQuestion: 'Which one treatment communicates the aside most clearly without stacking both?',
65
+ },
66
+ 'decorative-ordinal-run': {
67
+ reason: 'A display treatment repeats zero-padded ordinals across a three-step run.',
68
+ reviewQuestion: 'Do these ordinals carry navigation or sequence meaning, or are they ornamental structure?',
69
+ },
70
+ 'default-font-pair': {
71
+ reason: 'One source file declares Inter together with a second frequently paired interface family.',
72
+ reviewQuestion: 'What distinct role requires both families, and is that role visible in the type proof?',
73
+ },
74
+ 'global-terminal-styling': {
75
+ reason: 'Monospace terminal styling is applied to a global or primary interface surface.',
76
+ reviewQuestion: 'Is terminal language intrinsic to the product, or should monospace stay with code content?',
77
+ },
78
+ };
79
+
80
+ function slash(path: string): string {
81
+ return path.split(sep).join('/');
82
+ }
83
+
84
+ function lineAt(source: string, index: number): number {
85
+ let line = 1;
86
+ for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
87
+ return line;
88
+ }
89
+
90
+ function maskRange(chars: string[], start: number, end: number): void {
91
+ for (let i = start; i < end; i++) if (chars[i] !== '\n' && chars[i] !== '\r') chars[i] = ' ';
92
+ }
93
+
94
+ /** Remove comments while preserving offsets and line numbers. Strings remain intact. */
95
+ function maskComments(source: string, extension: string): string {
96
+ const chars = source.split('');
97
+ for (const match of source.matchAll(/<!--[\s\S]*?-->/g)) maskRange(chars, match.index!, match.index! + match[0].length);
98
+
99
+ let quote: '"' | "'" | '`' | null = null;
100
+ let escaped = false;
101
+ for (let i = 0; i < source.length; i++) {
102
+ const ch = source[i]!;
103
+ const next = source[i + 1];
104
+ if (quote) {
105
+ if (escaped) escaped = false;
106
+ else if (ch === '\\') escaped = true;
107
+ else if (ch === quote) quote = null;
108
+ continue;
109
+ }
110
+ if (ch === '"' || ch === "'" || ch === '`') {
111
+ quote = ch;
112
+ continue;
113
+ }
114
+ if (ch === '/' && next === '*') {
115
+ const end = source.indexOf('*/', i + 2);
116
+ const stop = end < 0 ? source.length : end + 2;
117
+ maskRange(chars, i, stop);
118
+ i = stop - 1;
119
+ continue;
120
+ }
121
+ const lineComments = ['.js', '.jsx', '.ts', '.tsx', '.scss'].includes(extension);
122
+ if (lineComments && ch === '/' && next === '/') {
123
+ const end = source.indexOf('\n', i + 2);
124
+ const stop = end < 0 ? source.length : end;
125
+ maskRange(chars, i, stop);
126
+ i = stop - 1;
127
+ }
128
+ }
129
+ return chars.join('');
130
+ }
131
+
132
+ interface ClassAttribute {
133
+ value: string;
134
+ index: number;
135
+ tag: string;
136
+ }
137
+
138
+ function classAttributes(source: string): ClassAttribute[] {
139
+ const found: ClassAttribute[] = [];
140
+ const tagPattern = /<([a-z][\w.-]*)\b[^>]*>/gi;
141
+ for (const tagMatch of source.matchAll(tagPattern)) {
142
+ const tagSource = tagMatch[0];
143
+ const attr = /\bclass(?:Name)?\s*=\s*(?:\{\s*)?(["'`])([\s\S]*?)\1\s*\}?/i.exec(tagSource);
144
+ if (!attr) continue;
145
+ found.push({
146
+ value: attr[2]!,
147
+ index: tagMatch.index! + (attr.index ?? 0),
148
+ tag: tagMatch[1]!.toLowerCase(),
149
+ });
150
+ }
151
+ return found;
152
+ }
153
+
154
+ function candidate(
155
+ candidateId: SlopSourceCandidateId,
156
+ path: string,
157
+ source: string,
158
+ index: number,
159
+ signals: string[],
160
+ ): SlopSourceCandidate {
161
+ return {
162
+ candidateId,
163
+ path,
164
+ line: lineAt(source, index),
165
+ signals,
166
+ ...REASONS[candidateId],
167
+ owner: 'hand',
168
+ };
169
+ }
170
+
171
+ function detectMidSentenceBreak(source: string, path: string): SlopSourceCandidate | null {
172
+ const element = /<(h1|h2|p)\b[^>]*>([\s\S]*?)<\/\1\s*>/gi;
173
+ for (const match of source.matchAll(element)) {
174
+ const body = match[2]!;
175
+ const bodyOffset = match.index! + match[0].indexOf(body);
176
+ for (const br of body.matchAll(/<br\s*\/?>/gi)) {
177
+ const visibleBefore = body.slice(0, br.index).replace(/<[^>]+>/g, '').trimEnd();
178
+ if (!visibleBefore || /[.!?。!?…:;]\s*$/.test(visibleBefore)) continue;
179
+ return candidate('mid-sentence-break', path, source, bodyOffset + br.index!, [
180
+ `element:${match[1]!.toLowerCase()}`, 'break:before-sentence-end',
181
+ ]);
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function detectAllTransition(source: string, path: string): SlopSourceCandidate | null {
188
+ const match = /\btransition-all\b|\btransition\s*:\s*all(?:\s|;|!|$)/i.exec(source);
189
+ return match ? candidate('all-property-transition', path, source, match.index, [
190
+ match[0].toLowerCase().includes('transition-all') ? 'syntax:utility' : 'syntax:declaration',
191
+ 'property:all',
192
+ ]) : null;
193
+ }
194
+
195
+ function detectKickerCluster(source: string, path: string): SlopSourceCandidate | null {
196
+ const occurrences = new Map<string, ClassAttribute[]>();
197
+ const trackedUppercase: ClassAttribute[] = [];
198
+ for (const attr of classAttributes(source)) {
199
+ const token = attr.value.match(/(?:^|\s)(kicker|overline|eyebrow)(?:[\w-]*)?(?=\s|$)/i)?.[1]?.toLowerCase();
200
+ if (token) {
201
+ const list = occurrences.get(token) ?? [];
202
+ list.push(attr);
203
+ occurrences.set(token, list);
204
+ }
205
+ const uppercase = /(?:^|\s)uppercase(?=\s|$)/i.test(attr.value);
206
+ const tracked = /(?:^|\s)tracking-(?:wide|wider|widest)(?=\s|$)/i.test(attr.value);
207
+ if (uppercase && tracked) trackedUppercase.push(attr);
208
+ }
209
+ for (const token of ['kicker', 'overline', 'eyebrow']) {
210
+ const matches = occurrences.get(token) ?? [];
211
+ if (matches.length >= 3) return candidate('repeated-kicker-treatment', path, source, matches[2]!.index, [
212
+ `role:${token}`, 'cluster:3-plus',
213
+ ]);
214
+ }
215
+ if (trackedUppercase.length >= 3) {
216
+ return candidate('repeated-kicker-treatment', path, source, trackedUppercase[2]!.index, [
217
+ 'role:tracked-uppercase', 'cluster:3-plus',
218
+ ]);
219
+ }
220
+ return null;
221
+ }
222
+
223
+ function detectAnimatedStatus(source: string, path: string): SlopSourceCandidate | null {
224
+ const blocks = source.matchAll(/<([a-z][\w.-]*)\b[^>]*>[\s\S]{0,900}?<\/\1\s*>/gi);
225
+ for (const match of blocks) {
226
+ const text = match[0];
227
+ const status = text.match(/\b(live|ready|online)\b/i)?.[1]?.toLowerCase();
228
+ const motion = text.match(/(?:animate[-_:]?)?(ping|pulse)\b/i)?.[1]?.toLowerCase();
229
+ const luminous = /\bglow(?:ing)?\b|(?:shadow|ring|drop-shadow)[^\s"'<>]*(?:emerald|green|lime|amber)|box-shadow\s*:/i.test(text);
230
+ if (status && motion && luminous) return candidate('animated-status-glow', path, source, match.index!, [
231
+ `state:${status}`, `motion:${motion}`, 'luminance:glow',
232
+ ]);
233
+ }
234
+ return null;
235
+ }
236
+
237
+ function detectRoundedCallout(source: string, path: string): SlopSourceCandidate | null {
238
+ for (const attr of classAttributes(source)) {
239
+ const rounded = /(?:^|\s)rounded(?:-[\w[\]./-]+)?(?=\s|$)/i.test(attr.value);
240
+ const thickLeft = /(?:^|\s)border-l-(?:[4-9]|[1-9]\d)(?=\s|$)/i.test(attr.value);
241
+ const colouredLeft = /(?:^|\s)border-l-(?![0-9]+(?:\s|$))(?:[a-z]+)-(?:[1-9]00|[a-z]+)(?=\s|$)/i.test(attr.value);
242
+ if (rounded && thickLeft && colouredLeft) return candidate('rounded-accent-callout', path, source, attr.index, [
243
+ 'shape:rounded', 'edge:left-thick', 'edge:coloured',
244
+ ]);
245
+ }
246
+ for (const block of source.matchAll(/[^{}]+\{([^{}]*)\}/g)) {
247
+ const declarations = block[1]!;
248
+ const rounded = /border-radius\s*:\s*(?!0(?:\D|$))[^;]+/i.test(declarations);
249
+ const left = /border-left\s*:\s*(?:[4-9]|[1-9]\d)px\s+(?:solid|double|dashed)\s+(?!transparent|currentcolor)[^;]+/i.test(declarations);
250
+ if (rounded && left) return candidate('rounded-accent-callout', path, source, block.index!, [
251
+ 'shape:rounded', 'edge:left-thick', 'edge:coloured',
252
+ ]);
253
+ }
254
+ return null;
255
+ }
256
+
257
+ function detectOrdinalRun(source: string, path: string): SlopSourceCandidate | null {
258
+ const outsideOrderedLists = source.replace(/<ol\b[\s\S]*?<\/ol\s*>/gi, (value) => value.replace(/[^\r\n]/g, ' '));
259
+ const found = new Map<string, number>();
260
+ for (const match of outsideOrderedLists.matchAll(/<([a-z][\w.-]*)\b([^>]*)>([\s\S]{0,180}?)<\/\1\s*>/gi)) {
261
+ const attrs = match[2]!;
262
+ const body = match[3]!;
263
+ const treatment = /(?:display[-_ ]?(?:number|ordinal)|(?:number|ordinal)[-_ ]?display|decorative[-_ ]?ordinal|chapter[-_ ]?index)/i.test(attrs)
264
+ || (/step[-_ ]?(?:number|index)/i.test(attrs) && /font[-_ ]?display/i.test(attrs));
265
+ if (!treatment) continue;
266
+ const ordinal = body.replace(/<[^>]+>/g, '').match(/\b(01|02|03)\b/)?.[1];
267
+ if (ordinal && !found.has(ordinal)) found.set(ordinal, match.index!);
268
+ }
269
+ if (found.size === 3) return candidate('decorative-ordinal-run', path, source, found.get('03')!, [
270
+ 'sequence:01-02-03', 'treatment:display-ordinal',
271
+ ]);
272
+ return null;
273
+ }
274
+
275
+ function findEscapedIdentifierCall(source: string, identifier: string): number | null {
276
+ const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
277
+ const match = new RegExp(`(^|[^\\w$])(${escaped})\\s*\\(`, 'm').exec(source);
278
+ return match ? match.index + match[1]!.length : null;
279
+ }
280
+
281
+ function detectFontPair(source: string, path: string): SlopSourceCandidate | null {
282
+ const inter = /(?:font-family\s*:[^;{}]*|fontFamily\s*[:=][^,;\n}]*|fonts?\.[a-z]+\s*\([^)]*)\bInter\b/i.exec(source);
283
+ const partner = /(?:font-family\s*:[^;{}]*|fontFamily\s*[:=][^,;\n}]*|fonts?\.[a-z]+\s*\([^)]*)\b(Space\s+Grotesk|Geist|Manrope|Plus\s+Jakarta(?:\s+Sans)?)\b/i.exec(source);
284
+ if (inter && partner) {
285
+ return candidate('default-font-pair', path, source, Math.max(inter.index, partner.index), [
286
+ 'family:inter', `family:${partner[1]!.toLowerCase().replace(/\s+/g, '-')}`, 'context:same-file',
287
+ ]);
288
+ }
289
+
290
+ for (const imported of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]/g)) {
291
+ const names = new Map<string, string>();
292
+ for (const item of imported[1]!.split(',')) {
293
+ const parsed = /^\s*(Inter|Space_Grotesk|Geist|Manrope|Plus_Jakarta_Sans)(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(item);
294
+ if (parsed) names.set(parsed[1]!, parsed[2] ?? parsed[1]!);
295
+ }
296
+ const interLocal = names.get('Inter');
297
+ if (!interLocal || findEscapedIdentifierCall(source, interLocal) === null) continue;
298
+ for (const family of ['Space_Grotesk', 'Geist', 'Manrope', 'Plus_Jakarta_Sans']) {
299
+ const local = names.get(family);
300
+ if (!local) continue;
301
+ const useIndex = findEscapedIdentifierCall(source, local);
302
+ if (useIndex === null) continue;
303
+ return candidate('default-font-pair', path, source, useIndex, [
304
+ 'family:inter', `family:${family.toLowerCase().replace(/_/g, '-')}`, 'context:next-font-google',
305
+ ]);
306
+ }
307
+ }
308
+ return null;
309
+ }
310
+
311
+ function detectTerminalChrome(source: string, path: string): SlopSourceCandidate | null {
312
+ for (const attr of classAttributes(source)) {
313
+ if (['body', 'main', 'nav', 'h1', 'h2'].includes(attr.tag) && /(?:^|\s)font-mono(?=\s|$)/i.test(attr.value)) {
314
+ return candidate('global-terminal-styling', path, source, attr.index, [
315
+ `surface:${attr.tag}`, 'type:monospace',
316
+ ]);
317
+ }
318
+ const mono = /(?:^|\s)font-mono(?=\s|$)/i.test(attr.value);
319
+ const dark = /(?:^|\s)bg-(?:black|zinc-9\d\d|neutral-9\d\d|stone-9\d\d|slate-9\d\d)(?=\s|$)/i.test(attr.value);
320
+ const warm = /(?:^|\s)text-(?:amber|orange|yellow|red)-(?:[2-8]\d\d)(?=\s|$)/i.test(attr.value);
321
+ const viewportShell = /(?:^|\s)(?:min-h-screen|h-screen|w-screen)(?=\s|$)/i.test(attr.value);
322
+ if (mono && dark && warm && viewportShell) {
323
+ return candidate('global-terminal-styling', path, source, attr.index, [
324
+ 'surface:viewport-shell', 'type:monospace', 'palette:dark-warm',
325
+ ]);
326
+ }
327
+ }
328
+ for (const block of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
329
+ const selectors = block[1]!.trim();
330
+ const declarations = block[2]!;
331
+ const primarySurface = /(?:^|,)\s*(?:html|body|main|nav|h1|h2|:root)(?:\b|\s|,|$)/i.test(selectors);
332
+ const globalShell = /(?:^|,)\s*(?:#(?:root|app)|\.(?:app|shell|site-shell))(?:\b|\s|,|$)/i.test(selectors);
333
+ if (!primarySurface && !globalShell) continue;
334
+ const mono = /font-family\s*:[^;{}]*(?:monospace|\bmono\b|Menlo|Monaco|Consolas|Fira\s+Code|JetBrains\s+Mono)/i.test(declarations);
335
+ if (primarySurface && mono) {
336
+ return candidate('global-terminal-styling', path, source, block.index!, [
337
+ 'surface:global-or-primary', 'type:monospace',
338
+ ]);
339
+ }
340
+ const dark = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]{2}|1[0-9a-f]{2}){1,2}|(?:black|rgb\(\s*(?:[0-2]?\d)\s*,\s*(?:[0-2]?\d)\s*,\s*(?:[0-2]?\d)\s*\)))/i.test(declarations);
341
+ const warm = /(?:^|;)\s*color\s*:\s*(?:#(?:f[0-9a-f]{2}|e[6-9a-f][0-9a-f])(?:[0-9a-f]{3})?|(?:orange|gold|amber|yellow))/i.test(declarations);
342
+ if (globalShell && mono && dark && warm) {
343
+ return candidate('global-terminal-styling', path, source, block.index!, [
344
+ 'surface:global-shell', 'type:monospace', 'palette:dark-warm',
345
+ ]);
346
+ }
347
+ }
348
+ return null;
349
+ }
350
+
351
+ function detectCandidates(source: string, path: string, extension: string): SlopSourceCandidate[] {
352
+ const masked = maskComments(source, extension);
353
+ const candidates = [
354
+ detectMidSentenceBreak(masked, path),
355
+ detectAllTransition(masked, path),
356
+ detectKickerCluster(masked, path),
357
+ detectAnimatedStatus(masked, path),
358
+ detectRoundedCallout(masked, path),
359
+ detectOrdinalRun(masked, path),
360
+ detectFontPair(masked, path),
361
+ detectTerminalChrome(masked, path),
362
+ ];
363
+ return candidates.filter((item): item is SlopSourceCandidate => item !== null);
364
+ }
365
+
366
+ function shouldSkipFile(name: string): boolean {
367
+ const lower = name.toLowerCase();
368
+ return LOCKFILES.has(lower) || /\.min\.[^.]+$/i.test(lower) || !SOURCE_EXTENSIONS.has(extname(lower));
369
+ }
370
+
371
+ function compareCodeUnits(a: SlopSourceCandidate, b: SlopSourceCandidate): number {
372
+ return a.path < b.path ? -1 : a.path > b.path ? 1
373
+ : a.line - b.line || (a.candidateId < b.candidateId ? -1 : a.candidateId > b.candidateId ? 1 : 0);
374
+ }
375
+
376
+ /**
377
+ * Read-only source scan. It uses a fixed skip policy and intentionally does not interpret
378
+ * .gitignore. Rendered IR remains a separate, authoritative review input.
379
+ */
380
+ export function scanSlopSource(rootInput = process.cwd()): SlopSourceScan {
381
+ const root = resolve(rootInput);
382
+ let rootStat;
383
+ try {
384
+ rootStat = lstatSync(root);
385
+ } catch (error) {
386
+ throw new Error(`slop scan root is not readable: ${root}`, { cause: error });
387
+ }
388
+ if (rootStat.isSymbolicLink()) throw new Error(`slop scan root cannot be a symlink: ${root}`);
389
+ if (!rootStat.isDirectory()) throw new Error(`slop scan root is not a directory: ${root}`);
390
+
391
+ let filesScanned = 0;
392
+ const candidates: SlopSourceCandidate[] = [];
393
+
394
+ const walk = (directory: string, relativeDirectory: string): void => {
395
+ const entries = readdirSync(directory, { withFileTypes: true })
396
+ .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
397
+ for (const entry of entries) {
398
+ const entryPath = resolve(directory, entry.name);
399
+ const entryRelative = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
400
+ if (entry.isSymbolicLink()) continue;
401
+ if (entry.isDirectory()) {
402
+ if (entry.name.startsWith('.') || SKIP_DIRECTORIES.has(entry.name)
403
+ || (!relativeDirectory && ROOT_GENERATED_DIRECTORIES.has(entry.name))) continue;
404
+ walk(entryPath, entryRelative);
405
+ continue;
406
+ }
407
+ if (!entry.isFile() || shouldSkipFile(entry.name)) continue;
408
+
409
+ const size = statSync(entryPath).size;
410
+ if (size > SLOP_SOURCE_MAX_BYTES) continue;
411
+ const bytes = readFileSync(entryPath);
412
+ if (bytes.includes(0)) continue;
413
+ let source: string;
414
+ try {
415
+ source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
416
+ } catch {
417
+ continue;
418
+ }
419
+ filesScanned++;
420
+ candidates.push(...detectCandidates(source, slash(relative(root, entryPath)), extname(entry.name).toLowerCase()));
421
+ }
422
+ };
423
+
424
+ walk(root, '');
425
+ candidates.sort(compareCodeUnits);
426
+ return { schemaVersion: SLOP_SOURCE_SCHEMA_VERSION, root, filesScanned, candidates };
427
+ }
@@ -0,0 +1,220 @@
1
+ export type TextSlopCandidateId =
2
+ | 'fast-paced-world'
3
+ | 'unlock-the-power'
4
+ | 'elevate-your'
5
+ | 'seamless-collocation'
6
+ | 'important-to-note'
7
+ | 'delve-into'
8
+ | 'game-changer'
9
+ | 'cutting-edge'
10
+ | 'revolutionize'
11
+ | 'end-of-the-day'
12
+ | 'ko-journey-metaphor'
13
+ | 'ko-story-vessel'
14
+ | 'ko-melt-in'
15
+ | 'ko-craft-mold'
16
+ | 'ko-bestow'
17
+ | 'supercharge-your'
18
+ | 'work-smarter'
19
+ | 'unlock-your'
20
+ | 'ai-powered'
21
+ | 'ten-x-hype'
22
+ | 'no-code-required'
23
+ | 'the-future-of'
24
+ | 'next-generation'
25
+ | 'heavy-lifting'
26
+ | 'effortless-creation';
27
+
28
+ export interface TextSlopCandidate {
29
+ candidateId: TextSlopCandidateId;
30
+ line: number;
31
+ phrase: string;
32
+ signals: string[];
33
+ reason: string;
34
+ reviewQuestion: string;
35
+ owner: 'writer';
36
+ gating: false;
37
+ }
38
+
39
+ const REASONS: Record<TextSlopCandidateId, Pick<TextSlopCandidate, 'reason' | 'reviewQuestion'>> = {
40
+ 'fast-paced-world': {
41
+ reason: '"In today\'s fast-paced world" is a stock AI-copy scene-setter that rarely carries product-specific meaning.',
42
+ reviewQuestion: 'Does this opener say anything a reader could not have guessed, or can it be cut entirely?',
43
+ },
44
+ 'unlock-the-power': {
45
+ reason: '"Unlock the power of" is a generic marketing template phrase used regardless of the underlying feature.',
46
+ reviewQuestion: 'What specific capability is being unlocked, and can the copy name it directly instead?',
47
+ },
48
+ 'elevate-your': {
49
+ reason: '"Elevate your" is a filler verb pairing that substitutes for a concrete benefit statement.',
50
+ reviewQuestion: 'What measurable outcome does this actually elevate, and can that be stated plainly?',
51
+ },
52
+ 'seamless-collocation': {
53
+ reason: '"Seamlessly integrate/experience" is a stock collocation that asserts smoothness without evidence.',
54
+ reviewQuestion: 'Is there a concrete detail (setup step, latency, compatibility) that demonstrates seamlessness?',
55
+ },
56
+ 'important-to-note': {
57
+ reason: '"It\'s important to note" is a throat-clearing hedge that delays the actual point.',
58
+ reviewQuestion: 'Can this sentence lead with the point directly instead of announcing its own importance?',
59
+ },
60
+ 'delve-into': {
61
+ reason: '"Delve into" is a well-known LLM tic used as a generic transition into any topic.',
62
+ reviewQuestion: 'Can this transition be replaced with a specific verb describing what actually happens next?',
63
+ },
64
+ 'game-changer': {
65
+ reason: '"Game-changer" is an inflated superlative asserted without supporting evidence.',
66
+ reviewQuestion: 'What specific change does this cause, and does the evidence support calling it a game-changer?',
67
+ },
68
+ 'cutting-edge': {
69
+ reason: '"Cutting-edge" is a vague technology claim that says nothing about what is actually new.',
70
+ reviewQuestion: 'What specific technique or capability makes this cutting-edge, and can that be named instead?',
71
+ },
72
+ 'revolutionize': {
73
+ reason: '"Revolutionize" is an inflated claim of category-level change rarely backed by the surrounding copy.',
74
+ reviewQuestion: 'Does the surrounding evidence support a claim this strong, or would a narrower verb be accurate?',
75
+ },
76
+ 'end-of-the-day': {
77
+ reason: '"At the end of the day" is a filler idiom that pads a sentence without adding meaning.',
78
+ reviewQuestion: 'Can this idiom be removed so the sentence states its conclusion directly?',
79
+ },
80
+ 'ko-journey-metaphor': {
81
+ reason: '"여정(journey)" as a growth/experience metaphor ("여정을 담다/그리다/시작하다") is a stock AI-portfolio flourish that adds no concrete fact.',
82
+ reviewQuestion: 'What concrete project, date, or outcome does this stand in for, and can the copy state that instead of the journey metaphor?',
83
+ },
84
+ 'ko-story-vessel': {
85
+ reason: '"이야기를 담다" (to contain a story) is an AI marketing collocation that asserts narrative without naming it.',
86
+ reviewQuestion: 'What is the specific story or fact, and can the copy show it rather than announce that it holds one?',
87
+ },
88
+ 'ko-melt-in': {
89
+ reason: '"녹여내다" (to melt/dissolve in) is overwrought AI-purple prose standing in for "included" or "expressed".',
90
+ reviewQuestion: 'Can this be replaced with the plain verb (담다/구현하다/설명하다) for what actually happened?',
91
+ },
92
+ 'ko-craft-mold': {
93
+ reason: '"빚어내다" (to craft/mold) is decorative AI-purple prose rarely grounded in a real making process.',
94
+ reviewQuestion: 'Was something literally shaped, or is this an inflated verb for "만들다/구축하다" a plainer word states better?',
95
+ },
96
+ 'ko-bestow': {
97
+ reason: '"선사하다" (to bestow/present) is stock Korean marketing copy that inflates a plain "제공하다/보여주다".',
98
+ reviewQuestion: 'What is actually provided, and can the copy say 제공/보여줌 plainly instead of the gift-giving flourish?',
99
+ },
100
+ 'supercharge-your': {
101
+ reason: '"Supercharge your <noun>" is a stock AI-SaaS landing headline that promises intensity without a concrete mechanism.',
102
+ reviewQuestion: 'What specifically gets faster or better, by how much, and can the copy state that instead of "supercharge"?',
103
+ },
104
+ 'work-smarter': {
105
+ reason: '"Work smarter, not harder" is a decades-old motivational cliché recycled by AI marketing copy.',
106
+ reviewQuestion: 'What concrete step does the product remove, and can the copy name it instead of the slogan?',
107
+ },
108
+ 'unlock-your': {
109
+ reason: '"Unlock your creativity/potential/productivity" is a generic AI-SaaS template promise with no specific claim.',
110
+ reviewQuestion: 'What was actually blocked before, and what unblocks it — can the copy say that plainly?',
111
+ },
112
+ 'ai-powered': {
113
+ reason: '"AI-powered" is a category label, not a benefit; it says the tech, not what the user gets.',
114
+ reviewQuestion: 'What does the AI actually do here that a reader cares about, and can the copy lead with that outcome?',
115
+ },
116
+ 'ten-x-hype': {
117
+ reason: '"10x your/faster" is an unverifiable order-of-magnitude hype figure typical of AI landing pages.',
118
+ reviewQuestion: 'Is there a measured before/after that supports a multiplier, or should the copy drop the number?',
119
+ },
120
+ 'no-code-required': {
121
+ reason: '"No code required" is a stock no-code/AI marketing tag that rarely reflects the actual workflow.',
122
+ reviewQuestion: 'What does setup actually take, and can the copy describe the real first step instead?',
123
+ },
124
+ 'the-future-of': {
125
+ reason: '"The future of <domain>" is an empty futurist frame common to AI product copy.',
126
+ reviewQuestion: 'What does the product do today, concretely, that the copy can state instead of gesturing at the future?',
127
+ },
128
+ 'next-generation': {
129
+ reason: '"Next-generation / next-gen" asserts novelty without naming what is actually new.',
130
+ reviewQuestion: 'What specific capability is new here, and can the copy name it instead of the generation claim?',
131
+ },
132
+ 'heavy-lifting': {
133
+ reason: '"Let AI do the heavy lifting" is a stock AI-assistant phrase that hides what the tool actually automates.',
134
+ reviewQuestion: 'What specific task is automated, and can the copy name that task instead of the idiom?',
135
+ },
136
+ 'effortless-creation': {
137
+ reason: '"Effortless creation/design/automation" is an AI-SaaS filler pairing that asserts ease without evidence.',
138
+ reviewQuestion: 'What concrete step is removed to make it easier, and can the copy show that rather than assert "effortless"?',
139
+ },
140
+ };
141
+
142
+ interface PatternRule {
143
+ id: TextSlopCandidateId;
144
+ pattern: RegExp;
145
+ }
146
+
147
+ const PATTERNS: PatternRule[] = [
148
+ { id: 'fast-paced-world', pattern: /\bin today['’]s fast-paced world\b/gi },
149
+ { id: 'unlock-the-power', pattern: /\bunlock the power of\b/gi },
150
+ { id: 'elevate-your', pattern: /\belevate your\b/gi },
151
+ { id: 'seamless-collocation', pattern: /\bseamlessly? (?:integrat\w*|experience\w*)\b/gi },
152
+ { id: 'important-to-note', pattern: /\bit['’]s important to note\b/gi },
153
+ { id: 'delve-into', pattern: /\bdelve into\b/gi },
154
+ { id: 'game-changer', pattern: /\bgame-?changers?\b/gi },
155
+ { id: 'cutting-edge', pattern: /\bcutting-edge\b/gi },
156
+ { id: 'revolutionize', pattern: /\brevolutioniz(?:e|es|ed|ing)\b/gi },
157
+ { id: 'end-of-the-day', pattern: /\bat the end of the day\b/gi },
158
+ { id: 'ko-journey-metaphor', pattern: /여정을?\s*(담|그리|시작|떠나|함께|이어)/g },
159
+ { id: 'ko-story-vessel', pattern: /이야기를\s*담(아|았|는|고|은)/g },
160
+ { id: 'ko-melt-in', pattern: /녹여\s*(내|낸|냈)/g },
161
+ { id: 'ko-craft-mold', pattern: /빚어\s*(내|낸|냈)/g },
162
+ { id: 'ko-bestow', pattern: /선사(합니다|한다|하는|했|해\s?드리)/g },
163
+ { id: 'supercharge-your', pattern: /\bsupercharge your\b/gi },
164
+ { id: 'work-smarter', pattern: /\bwork smarter,? not harder\b/gi },
165
+ { id: 'unlock-your', pattern: /\bunlock your (?:creativity|potential|productivity|workflow|ideas)\b/gi },
166
+ { id: 'ai-powered', pattern: /\bAI[-\s]powered\b/gi },
167
+ { id: 'ten-x-hype', pattern: /\b10x (?:your|faster|the|more)\b/gi },
168
+ { id: 'no-code-required', pattern: /\bno[-\s]code required\b/gi },
169
+ { id: 'the-future-of', pattern: /\bthe future of (?:work|design|creativity|productivity|content|building|software)\b/gi },
170
+ { id: 'next-generation', pattern: /\bnext[-\s]gen(?:eration)?\b/gi },
171
+ { id: 'heavy-lifting', pattern: /\b(?:do|does|let\w*|doing) .{0,20}the heavy lifting\b/gi },
172
+ { id: 'effortless-creation', pattern: /\beffortless(?:ly)? (?:creation|design|automation|workflow|editing)\b/gi },
173
+ ];
174
+
175
+ function lineAt(source: string, index: number): number {
176
+ let line = 1;
177
+ for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
178
+ return line;
179
+ }
180
+
181
+ function maskRange(chars: string[], start: number, end: number): void {
182
+ for (let i = start; i < end; i++) if (chars[i] !== '\n' && chars[i] !== '\r') chars[i] = ' ';
183
+ }
184
+
185
+ /** Mask fenced ```...``` blocks and inline `...` spans so their contents cannot trigger phrase matches. */
186
+ function maskCode(text: string): string {
187
+ const chars = text.split('');
188
+ for (const match of text.matchAll(/```[\s\S]*?```/g)) {
189
+ maskRange(chars, match.index!, match.index! + match[0].length);
190
+ }
191
+ const withoutFences = chars.join('');
192
+ for (const match of withoutFences.matchAll(/`[^`\n]*`/g)) {
193
+ maskRange(chars, match.index!, match.index! + match[0].length);
194
+ }
195
+ return chars.join('');
196
+ }
197
+
198
+ /** Scan copy-deck / rendered plain-or-markdown text for narrow AI-cliche phrases. Non-gating and advisory only. */
199
+ export function scanTextSlop(text: string): TextSlopCandidate[] {
200
+ if (!text) return [];
201
+ // Narrow by design: literal phrase patterns. Zero-width / invisible-unicode splits
202
+ // inserted inside a phrase can evade matching; acceptable for a non-gating advisory.
203
+ const masked = maskCode(text);
204
+ const found: TextSlopCandidate[] = [];
205
+ for (const rule of PATTERNS) {
206
+ for (const match of masked.matchAll(rule.pattern)) {
207
+ const index = match.index!;
208
+ found.push({
209
+ candidateId: rule.id,
210
+ line: lineAt(text, index),
211
+ phrase: text.slice(index, index + match[0].length),
212
+ signals: [rule.id],
213
+ ...REASONS[rule.id],
214
+ owner: 'writer',
215
+ gating: false,
216
+ });
217
+ }
218
+ }
219
+ return found.sort((a, b) => a.line - b.line || a.candidateId.localeCompare(b.candidateId));
220
+ }