designlang 11.1.0 → 11.3.0

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/src/chat.js ADDED
@@ -0,0 +1,356 @@
1
+ // designlang chat — REPL over a live extraction.
2
+ //
3
+ // Heuristic-only in v12.0: the operations below cover the cases real users
4
+ // reach for first. LLM fallback ships in v12.1 (--smart). The router parses
5
+ // natural-ish English ("sharpen radii", "make it brutalist", "swap primary
6
+ // to #ff4800") into structured operations on the design object, re-derives
7
+ // tokens, and prints a tight diff.
8
+
9
+ import { createInterface } from 'readline';
10
+ import { stdin as input, stdout as output } from 'process';
11
+ import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs';
12
+ import { join, resolve } from 'path';
13
+ import chalk from 'chalk';
14
+ import { extractDesignLanguage } from './index.js';
15
+ import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
16
+ import { formatDesignMd } from './formatters/design-md.js';
17
+ import { formatTailwind } from './formatters/tailwind.js';
18
+ import { formatCssVars } from './formatters/css-vars.js';
19
+ import { nameFromUrl } from './utils.js';
20
+
21
+ function isHex(s) {
22
+ return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s.trim());
23
+ }
24
+
25
+ function hexToRgb(hex) {
26
+ const m = String(hex).trim().toLowerCase().replace(/^#/, '');
27
+ const full = m.length === 3 ? m.split('').map((c) => c + c).join('') : m.slice(0, 6);
28
+ return {
29
+ r: parseInt(full.slice(0, 2), 16) || 0,
30
+ g: parseInt(full.slice(2, 4), 16) || 0,
31
+ b: parseInt(full.slice(4, 6), 16) || 0,
32
+ };
33
+ }
34
+
35
+ function rgbToHex({ r, g, b }) {
36
+ return '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
37
+ }
38
+
39
+ function opSharpenRadii(design, factor = 0.5) {
40
+ const radii = design.borders?.radii || [];
41
+ const next = radii.map((r) => ({ ...r, value: Math.max(0, Math.round((r.value || 0) * factor)) }));
42
+ const changes = next.map((r, i) => `${r.label || 'r' + i}: ${radii[i].value}px → ${r.value}px`);
43
+ return {
44
+ design: { ...design, borders: { ...(design.borders || {}), radii: next } },
45
+ changes: ['radii sharpened', ...changes],
46
+ };
47
+ }
48
+
49
+ function opSoftenRadii(design, factor = 2) {
50
+ const radii = design.borders?.radii || [];
51
+ const next = radii.map((r) => ({ ...r, value: Math.min(64, Math.round((r.value || 0) * factor) || 4) }));
52
+ const changes = next.map((r, i) => `${r.label || 'r' + i}: ${radii[i].value}px → ${r.value}px`);
53
+ return {
54
+ design: { ...design, borders: { ...(design.borders || {}), radii: next } },
55
+ changes: ['radii softened', ...changes],
56
+ };
57
+ }
58
+
59
+ function opDarkMode(design) {
60
+ const colors = design.colors || {};
61
+ const bgs = colors.backgrounds || ['#ffffff'];
62
+ const txt = colors.text || ['#171717'];
63
+ const swapped = { ...colors, backgrounds: txt.slice(), text: bgs.slice() };
64
+ return {
65
+ design: { ...design, colors: swapped },
66
+ changes: [
67
+ `background: ${bgs[0]} → ${txt[0]}`,
68
+ `foreground: ${txt[0]} → ${bgs[0]}`,
69
+ ],
70
+ };
71
+ }
72
+
73
+ function opMakeBrutalist(design) {
74
+ const radii = (design.borders?.radii || []).map((r) => ({ ...r, value: 0 }));
75
+ const shadows = (design.shadows?.values || []).map((s) => ({
76
+ ...s,
77
+ raw: '4px 4px 0 0 currentColor',
78
+ value: '4px 4px 0 0 currentColor',
79
+ }));
80
+ const families = (design.typography?.families || []).slice();
81
+ const monoFam = families.find((f) => /mono|consol|courier|jet|sf-mono|geist mono/i.test(f.name)) || { name: 'JetBrains Mono', count: 1, weights: [400] };
82
+ return {
83
+ design: {
84
+ ...design,
85
+ borders: { ...(design.borders || {}), radii },
86
+ shadows: { ...(design.shadows || {}), values: shadows },
87
+ typography: {
88
+ ...(design.typography || {}),
89
+ families: [monoFam, ...families.filter((f) => f !== monoFam)].slice(0, 3),
90
+ },
91
+ materialLanguage: { ...(design.materialLanguage || {}), label: 'brutalist', confidence: 1.0 },
92
+ },
93
+ changes: [
94
+ 'radii → 0 (sharp corners)',
95
+ 'shadows → hard offset (4px 4px 0 0)',
96
+ 'primary font → mono',
97
+ 'material → brutalist',
98
+ ],
99
+ };
100
+ }
101
+
102
+ function opMakeGlass(design) {
103
+ const radii = (design.borders?.radii || []).map((r) => ({
104
+ ...r,
105
+ value: Math.max(r.value || 8, 16),
106
+ }));
107
+ const shadows = (design.shadows?.values || []).map((s, i) => ({
108
+ ...s,
109
+ raw: `0 ${8 + i * 4}px ${24 + i * 8}px rgba(0,0,0,0.08)`,
110
+ value: `0 ${8 + i * 4}px ${24 + i * 8}px rgba(0,0,0,0.08)`,
111
+ }));
112
+ return {
113
+ design: {
114
+ ...design,
115
+ borders: { ...(design.borders || {}), radii },
116
+ shadows: { ...(design.shadows || {}), values: shadows },
117
+ materialLanguage: { ...(design.materialLanguage || {}), label: 'glass', confidence: 1.0 },
118
+ },
119
+ changes: [
120
+ 'radii ≥ 16px (rounded)',
121
+ 'shadows → soft, depth-stacked',
122
+ 'material → glass',
123
+ ],
124
+ };
125
+ }
126
+
127
+ function opSwapColor(design, role, hex) {
128
+ if (!isHex(hex)) return { design, changes: [`error: ${hex} is not a hex color`] };
129
+ const colors = { ...(design.colors || {}) };
130
+ const before = colors[role]?.hex;
131
+ if (!before) {
132
+ return { design, changes: [`error: no ${role} color in this extraction (try primary, secondary, accent)`] };
133
+ }
134
+ const next = { ...colors[role], hex };
135
+ return {
136
+ design: { ...design, colors: { ...colors, [role]: next } },
137
+ changes: [`${role}: ${before} → ${hex}`],
138
+ };
139
+ }
140
+
141
+ function opSwapFont(design, name) {
142
+ const families = (design.typography?.families || []).slice();
143
+ const before = families[0]?.name || '—';
144
+ const replaced = [{ name, count: families[0]?.count || 0, weights: families[0]?.weights || [400, 600] }, ...families.slice(1)];
145
+ return {
146
+ design: { ...design, typography: { ...(design.typography || {}), families: replaced } },
147
+ changes: [`primary font: ${before} → ${name}`],
148
+ };
149
+ }
150
+
151
+ function opReset(_design, original) {
152
+ return { design: structuredClone(original), changes: ['reset to original extraction'] };
153
+ }
154
+
155
+ function parseCommand(line) {
156
+ const s = String(line).trim().toLowerCase();
157
+ if (!s) return null;
158
+
159
+ if (s === 'help' || s === '?') return { kind: 'help' };
160
+ if (s === 'quit' || s === 'exit' || s === ':q') return { kind: 'quit' };
161
+ if (s === 'reset' || s === 'undo all') return { kind: 'reset' };
162
+ if (s === 'save' || s === 'export' || s === 'write') return { kind: 'save' };
163
+ if (s === 'show' || s === 'print' || s === 'state') return { kind: 'state' };
164
+ if (s.startsWith('show ') || s.startsWith('print ')) {
165
+ return { kind: 'show', what: s.split(/\s+/)[1] };
166
+ }
167
+
168
+ if (/(make it |make this |go )?brutalist/.test(s)) return { kind: 'op', op: 'brutalist' };
169
+ if (/(make it |make this |go )?glass(morph)?/.test(s)) return { kind: 'op', op: 'glass' };
170
+ if (/(dark mode|dark theme|invert|go dark)/.test(s)) return { kind: 'op', op: 'dark' };
171
+ if (/sharp(en)?( radii| corners)?/.test(s)) return { kind: 'op', op: 'sharpen' };
172
+ if (/(soft|round)(en)?( radii| corners)?/.test(s)) return { kind: 'op', op: 'soften' };
173
+
174
+ const colorRe = /(primary|secondary|accent)\s*(?:to|=|:)?\s*(#[0-9a-f]{3,8})/i;
175
+ const cm = colorRe.exec(line);
176
+ if (cm) return { kind: 'op', op: 'swap-color', role: cm[1].toLowerCase(), hex: cm[2] };
177
+
178
+ const fontRe = /(?:font|typeface)\s*(?:to|=|:)?\s*([A-Za-z][\w\s-]{1,40})/i;
179
+ const fm = fontRe.exec(line);
180
+ if (fm) return { kind: 'op', op: 'swap-font', name: fm[1].trim() };
181
+
182
+ return { kind: 'unknown', input: line };
183
+ }
184
+
185
+ function printHelp() {
186
+ console.log('');
187
+ console.log(chalk.bold(' Commands:'));
188
+ const rows = [
189
+ ['sharpen / soften', 'halve / double every radius'],
190
+ ['dark mode', 'swap background ↔ foreground'],
191
+ ['brutalist', 'radii → 0, hard shadows, mono font'],
192
+ ['glass', 'rounded radii, soft layered shadows'],
193
+ ['primary #ff4800', 'swap a role color (primary | secondary | accent)'],
194
+ ['font Inter', 'swap the primary font family'],
195
+ ['show / state', 'print current palette + tokens'],
196
+ ['reset', 'restore the original extraction'],
197
+ ['save', 'write DTCG, Tailwind, CSS vars, DESIGN.md to ./chat-output'],
198
+ ['quit', 'exit'],
199
+ ];
200
+ for (const [cmd, desc] of rows) {
201
+ console.log(' ' + chalk.cyan(cmd.padEnd(28)) + chalk.gray(desc));
202
+ }
203
+ console.log('');
204
+ }
205
+
206
+ function printState(design) {
207
+ const c = design.colors || {};
208
+ const t = design.typography || {};
209
+ const r = design.borders?.radii || [];
210
+ console.log('');
211
+ console.log(chalk.bold(' Current state'));
212
+ console.log(' ' + chalk.gray('palette:'.padEnd(14)) + [c.primary?.hex, c.secondary?.hex, c.accent?.hex, c.backgrounds?.[0], c.text?.[0]].filter(Boolean).join(' · '));
213
+ console.log(' ' + chalk.gray('font:'.padEnd(14)) + (t.families?.[0]?.name || '—'));
214
+ console.log(' ' + chalk.gray('radii:'.padEnd(14)) + (r.map((x) => `${x.label || '?'}=${x.value}`).join(' · ') || '—'));
215
+ console.log(' ' + chalk.gray('material:'.padEnd(14)) + (design.materialLanguage?.label || 'flat'));
216
+ console.log('');
217
+ }
218
+
219
+ function applyOp(parsed, current) {
220
+ switch (parsed.op) {
221
+ case 'sharpen': return opSharpenRadii(current);
222
+ case 'soften': return opSoftenRadii(current);
223
+ case 'dark': return opDarkMode(current);
224
+ case 'brutalist': return opMakeBrutalist(current);
225
+ case 'glass': return opMakeGlass(current);
226
+ case 'swap-color': return opSwapColor(current, parsed.role, parsed.hex);
227
+ case 'swap-font': return opSwapFont(current, parsed.name);
228
+ default: return { design: current, changes: ['no-op'] };
229
+ }
230
+ }
231
+
232
+ function saveDesign(design, outDir) {
233
+ mkdirSync(outDir, { recursive: true });
234
+ const url = design.meta?.url || 'extraction';
235
+ const prefix = nameFromUrl(url);
236
+ const dtcg = formatDtcgTokens(design);
237
+ const written = [];
238
+ const write = (name, content) => {
239
+ const p = join(outDir, name);
240
+ writeFileSync(p, content, 'utf-8');
241
+ written.push(p);
242
+ };
243
+ write(`${prefix}-design-tokens.json`, JSON.stringify(dtcg, null, 2));
244
+ write(`${prefix}-tailwind.config.js`, formatTailwind(design));
245
+ write(`${prefix}-variables.css`, formatCssVars(design));
246
+ write(`${prefix}-DESIGN.md`, formatDesignMd(design));
247
+ return written;
248
+ }
249
+
250
+ function synthesizeDesignFromTokens(tokens, sourcePath) {
251
+ const findHex = (...paths) => {
252
+ for (const p of paths) {
253
+ const parts = p.split('.');
254
+ let v = tokens;
255
+ for (const k of parts) {
256
+ v = v?.[k];
257
+ if (!v) break;
258
+ }
259
+ if (v && typeof v.$value === 'string') return v.$value;
260
+ }
261
+ return null;
262
+ };
263
+ const primary = findHex('color.primary', 'primitive.color.brand.primary', 'primitive.color.primary');
264
+ const secondary = findHex('color.secondary');
265
+ const accent = findHex('color.accent', 'primitive.color.brand.accent');
266
+ const bg = findHex('color.background', 'primitive.color.background.bg0', 'primitive.color.neutral.n100');
267
+ const fg = findHex('color.foreground', 'primitive.color.text.text0', 'primitive.color.foreground');
268
+ return {
269
+ meta: { url: `file://${sourcePath}`, title: 'imported tokens' },
270
+ colors: {
271
+ primary: primary ? { hex: primary, count: 1 } : null,
272
+ secondary: secondary ? { hex: secondary, count: 1 } : null,
273
+ accent: accent ? { hex: accent, count: 1 } : null,
274
+ backgrounds: bg ? [bg] : ['#ffffff'],
275
+ text: fg ? [fg] : ['#171717'],
276
+ neutrals: [],
277
+ all: [],
278
+ },
279
+ typography: { families: [{ name: 'system-ui', count: 1, weights: [400, 600] }], headings: [], body: { size: 16 } },
280
+ spacing: { base: 4, scale: [4, 8, 12, 16, 24, 32, 48, 64] },
281
+ shadows: { values: [{ label: 'md', raw: '0 4px 6px rgba(0,0,0,0.1)', value: '0 4px 6px rgba(0,0,0,0.1)' }] },
282
+ borders: { radii: [{ label: 'md', value: 8 }] },
283
+ breakpoints: [],
284
+ components: {},
285
+ variables: {},
286
+ materialLanguage: { label: 'flat', confidence: 0.5 },
287
+ };
288
+ }
289
+
290
+ export async function runChat(target, opts = {}) {
291
+ const outDir = resolve(opts.out || './chat-output');
292
+
293
+ let design;
294
+ if (target && /\.json$/.test(target) && existsSync(target)) {
295
+ console.log(chalk.gray(` Loading tokens from ${target}…`));
296
+ const tokens = JSON.parse(readFileSync(target, 'utf-8'));
297
+ design = synthesizeDesignFromTokens(tokens, target);
298
+ } else {
299
+ let url = String(target);
300
+ if (!url.startsWith('http')) url = `https://${url}`;
301
+ console.log(chalk.gray(` Extracting ${url}… (this takes a few seconds)`));
302
+ design = await extractDesignLanguage(url);
303
+ }
304
+
305
+ const original = structuredClone(design);
306
+
307
+ console.log('');
308
+ console.log(chalk.bold(' designlang chat'));
309
+ console.log(chalk.gray(' type "help" for commands · Ctrl+D to quit'));
310
+ printState(design);
311
+
312
+ const rl = createInterface({ input, output, prompt: chalk.gray('> ') });
313
+ rl.prompt();
314
+
315
+ for await (const line of rl) {
316
+ const parsed = parseCommand(line);
317
+ if (!parsed) { rl.prompt(); continue; }
318
+
319
+ if (parsed.kind === 'help') { printHelp(); rl.prompt(); continue; }
320
+ if (parsed.kind === 'quit') { rl.close(); break; }
321
+ if (parsed.kind === 'state' || parsed.kind === 'show') { printState(design); rl.prompt(); continue; }
322
+ if (parsed.kind === 'reset') {
323
+ const r = opReset(design, original);
324
+ design = r.design;
325
+ r.changes.forEach((c) => console.log(' ' + chalk.gray('•') + ' ' + c));
326
+ printState(design);
327
+ rl.prompt();
328
+ continue;
329
+ }
330
+ if (parsed.kind === 'save') {
331
+ const files = saveDesign(design, outDir);
332
+ console.log('');
333
+ for (const f of files) console.log(' ' + chalk.green('✓') + ' ' + f);
334
+ console.log('');
335
+ rl.prompt();
336
+ continue;
337
+ }
338
+ if (parsed.kind === 'unknown') {
339
+ console.log(chalk.yellow(` Didn't catch that. Try "help" for commands.`));
340
+ rl.prompt();
341
+ continue;
342
+ }
343
+
344
+ if (parsed.kind === 'op') {
345
+ const r = applyOp(parsed, design);
346
+ design = r.design;
347
+ console.log('');
348
+ r.changes.forEach((c) => console.log(' ' + chalk.green('•') + ' ' + c));
349
+ console.log('');
350
+ rl.prompt();
351
+ }
352
+ }
353
+
354
+ console.log('');
355
+ console.log(chalk.gray(' bye'));
356
+ }
package/src/clone.js CHANGED
@@ -21,7 +21,11 @@ function dedupeConsecutive(order) {
21
21
  }
22
22
 
23
23
  function sanitize(str, fallback = '') {
24
- return String(str ?? fallback).replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
24
+ // Escape backslash FIRST so the subsequent escapes don't get re-escaped.
25
+ return String(str ?? fallback)
26
+ .replace(/\\/g, '\\\\')
27
+ .replace(/`/g, '\\`')
28
+ .replace(/\$\{/g, '\\${');
25
29
  }
26
30
 
27
31
  function titleFromUrl(url = '') {
@@ -352,7 +356,7 @@ button { font-family: inherit; }
352
356
 
353
357
  // layout.js
354
358
  writeFileSync(join(projectDir, 'src/app/layout.js'), `export const metadata = {
355
- title: '${(design.meta.title || 'Cloned Design').replace(/'/g, "\\'")} · cloned',
359
+ title: '${(design.meta.title || 'Cloned Design').replace(/\\/g, '\\\\').replace(/'/g, "\\'")} · cloned',
356
360
  description: 'Design cloned from ${url} with designlang.',
357
361
  };
358
362
 
package/src/crawler.js CHANGED
@@ -26,6 +26,7 @@ export async function crawlPage(url, options = {}) {
26
26
  deepInteract = false,
27
27
  selector,
28
28
  channel,
29
+ wsEndpoint, // Remote browser (e.g. Browserless). When set, skips local launch.
29
30
  } = options;
30
31
 
31
32
  const launchArgs = [
@@ -38,14 +39,23 @@ export async function crawlPage(url, options = {}) {
38
39
  launchArgs.push('--ignore-certificate-errors', '--ignore-ssl-errors');
39
40
  }
40
41
 
41
- const browser = await chromium.launch({
42
- headless: true,
43
- ...(executablePath && { executablePath }),
44
- // channel: 'chrome' forces Playwright to use the system Chrome install
45
- // instead of the 150MB bundled Chromium see --system-chrome.
46
- ...(channel && { channel }),
47
- args: launchArgs,
48
- });
42
+ // Prefer remote browser when wsEndpoint is provided (Browserless v2 / any
43
+ // Playwright-protocol WSS). Skips the @sparticuz/chromium 150MB cold-start
44
+ // tax on Vercel Functions entirely.
45
+ const usingRemote = !!wsEndpoint;
46
+ // Browserless v2 speaks CDP at the root endpointconnectOverCDP works
47
+ // across Browserless and any other CDP-compatible service. connect() would
48
+ // require Playwright's protocol on a path like /playwright/chromium.
49
+ const browser = usingRemote
50
+ ? await chromium.connectOverCDP(wsEndpoint, { timeout: 30000 })
51
+ : await chromium.launch({
52
+ headless: true,
53
+ ...(executablePath && { executablePath }),
54
+ // channel: 'chrome' forces Playwright to use the system Chrome install
55
+ // instead of the 150MB bundled Chromium — see --system-chrome.
56
+ ...(channel && { channel }),
57
+ args: launchArgs,
58
+ });
49
59
  try {
50
60
  const context = await browser.newContext({
51
61
  viewport: { width, height },
@@ -7,18 +7,29 @@
7
7
  // Pure function — reads `rawData.light.computedStyles`, which every extractor
8
8
  // already has access to, plus the `modernColors` and any collected svgs.
9
9
 
10
+ // All pattern detectors operate on a length-capped string. Adversarial CSS
11
+ // background-image values (data URIs in particular) can run several KB; cap
12
+ // to 4KB so the regexes can never run quadratic over megabyte payloads.
13
+ const MAX_BG_LEN = 4096;
14
+ function cap(s) {
15
+ return typeof s === 'string' ? s.slice(0, MAX_BG_LEN) : '';
16
+ }
17
+
10
18
  function looksLikeDotGrid(image) {
11
- return /radial-gradient\(.*\)/i.test(image) && /repeat/i.test(image) && /(\d+px\s*\d+px)/.test(image);
19
+ const s = cap(image);
20
+ // Bounded inner content (.{0,256}) instead of unbounded .* — no nested quantifier risk.
21
+ return /radial-gradient\([^)]{0,256}\)/i.test(s) && /repeat/i.test(s) && /\d{1,4}px\s{0,4}\d{1,4}px/.test(s);
12
22
  }
13
23
 
14
24
  function looksLikeLineGrid(image) {
15
25
  // repeating-linear-gradient with a narrow colored band.
16
- return /repeating-linear-gradient/i.test(image);
26
+ return /repeating-linear-gradient/i.test(cap(image));
17
27
  }
18
28
 
19
29
  function looksLikeNoise(image) {
20
- // data URI SVG with feTurbulence filter, or a well-known noise png path.
21
- return /feTurbulence|data:image\/svg.+fractalNoise/i.test(image) || /noise\.(png|svg|webp)/i.test(image);
30
+ const s = cap(image);
31
+ // Bounded character class instead of .+ `.+` could backtrack on long data URIs.
32
+ return /feTurbulence|data:image\/svg[^"']{0,2048}fractalNoise/i.test(s) || /noise\.(png|svg|webp)/i.test(s);
22
33
  }
23
34
 
24
35
  function countRadialGradients(image) {
@@ -51,11 +51,13 @@ function shadowComplexity(shadowValues) {
51
51
  if (!shadowValues.length) return { profile: 'none', avgBlur: 0, maxBlur: 0, insetCount: 0, hardShadowCount: 0, hasPair: false };
52
52
  let insetCount = 0, hardShadowCount = 0, totalBlur = 0, maxBlur = 0, pairCount = 0;
53
53
  for (const v of shadowValues) {
54
- const raw = typeof v === 'string' ? v : (v.value || '');
54
+ // Cap to defang ReDoS on adversarial CSS shadow values. Real values are <500 chars.
55
+ const raw = (typeof v === 'string' ? v : (v.value || '')).slice(0, 2000);
55
56
  if (/inset/i.test(raw)) insetCount++;
56
57
  // Blur is the third length in `offset-x offset-y blur [spread] color`. The
57
58
  // `px` unit is common but optional — `0 0` is a valid zero-blur shadow.
58
- const blurs = [...raw.matchAll(/(-?\d+(?:\.\d+)?)(?:px)?\s+(-?\d+(?:\.\d+)?)(?:px)?\s+(\d+(?:\.\d+)?)(?:px)?/g)];
59
+ // Bounded digit counts prevent polynomial backtracking on long digit runs.
60
+ const blurs = [...raw.matchAll(/(-?\d{1,8}(?:\.\d{1,4})?)(?:px)?\s+(-?\d{1,8}(?:\.\d{1,4})?)(?:px)?\s+(\d{1,8}(?:\.\d{1,4})?)(?:px)?/g)];
59
61
  for (const m of blurs) {
60
62
  const blur = parseFloat(m[3]);
61
63
  totalBlur += blur;
@@ -92,18 +92,25 @@ export function extractMotion(computedStyles, keyframes = []) {
92
92
 
93
93
  for (const el of computedStyles) {
94
94
  let isAnimating = false;
95
- if (el.transition && el.transition !== 'all 0s ease 0s' && el.transition !== 'none') {
96
- transitions.add(el.transition);
95
+ // Cap inputs to defang any pathological CSS that could trigger
96
+ // polynomial-time regex backtracking. Real values are <200 chars.
97
+ const transition = (el.transition || '').slice(0, 2000);
98
+ if (transition && transition !== 'all 0s ease 0s' && transition !== 'none') {
99
+ transitions.add(transition);
97
100
  isAnimating = true;
98
- for (const m of el.transition.matchAll(/(?<![(\d])(\d+\.?\d*m?s)(?![)\w])/g)) durations.push(MS(m[1]));
99
- for (const m of el.transition.matchAll(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]+\)|steps\([^)]+\))/g)) easingRaw.add(m[1]);
100
- for (const part of el.transition.split(',')) {
101
+ // Tightened: bounded \d{1,8} and bounded fractional part — no nested quantifiers.
102
+ for (const m of transition.matchAll(/(?<![(\d])(\d{1,8}(?:\.\d{1,4})?m?s)(?![)\w])/g)) durations.push(MS(m[1]));
103
+ // Tightened: limit cubic-bezier/steps inner content to 64 chars.
104
+ for (const m of transition.matchAll(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]{1,64}\)|steps\([^)]{1,64}\))/g)) easingRaw.add(m[1]);
105
+ for (const part of transition.split(',')) {
101
106
  const prop = part.trim().split(/\s+/)[0];
102
107
  if (prop && prop !== 'all') transitionedProps[prop] = (transitionedProps[prop] || 0) + 1;
103
108
  }
104
109
  }
105
- if (el.animation && el.animation !== 'none 0s ease 0s 1 normal none running' && el.animation !== 'none') {
106
- const nameMatch = el.animation.match(/([a-zA-Z_][\w-]*)\s*$/) || el.animation.match(/^([a-zA-Z_][\w-]*)/);
110
+ const animation = (el.animation || '').slice(0, 2000);
111
+ if (animation && animation !== 'none 0s ease 0s 1 normal none running' && animation !== 'none') {
112
+ // Tightened: bound the identifier length so backtracking is linear.
113
+ const nameMatch = animation.match(/([a-zA-Z_][\w-]{0,127})$/) || animation.match(/^([a-zA-Z_][\w-]{0,127})/);
107
114
  if (nameMatch) {
108
115
  const name = nameMatch[1];
109
116
  if (name !== 'none' && name !== 'running' && name !== 'paused') {