differix 0.1.0a1__py3-none-any.whl

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 (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. tools/web_search.py +371 -0
@@ -0,0 +1,1043 @@
1
+ """
2
+ tools/palette_extractor.py
3
+ from plugin: webdev
4
+ --------------------------
5
+ Extract a DifferiX-compatible palette from a URL or a local HTML file.
6
+
7
+ Given a web page, this tool:
8
+ 1. Fetches the HTML (SSRF-guarded) and any linked stylesheets.
9
+ 2. Extracts every color, font-family, radius, and transition it finds.
10
+ 3. Clusters colors by role: backgrounds, accents, text, borders.
11
+ 4. Emits a `palettes/<name>.css` file that conforms to the DifferiX
12
+ palette contract (see skills/webdev.md).
13
+
14
+ The output is not a copy of the source CSS. It is a new file with our
15
+ variable names, filled with values derived from the source's visual
16
+ identity. Colors and fonts are not copyrightable; the file we write
17
+ contains none of the source's code.
18
+
19
+ Public API:
20
+ extract_palette(source, output_dir="palettes", name=None,
21
+ dry_run=False) -> PaletteResult
22
+
23
+ CLI:
24
+ python tools/palette_extractor.py <url-or-file> [-o DIR] [--name NAME] [--dry-run]
25
+
26
+ Never raises on network failure — returns a PaletteResult with
27
+ `success=False` and an `error` string. The caller decides what to do.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import logging
34
+ import os
35
+ import re
36
+ import sys
37
+ import time
38
+ import urllib.parse
39
+ import urllib.request
40
+ from dataclasses import dataclass, field, asdict
41
+ from html.parser import HTMLParser
42
+ from pathlib import Path
43
+ from typing import Dict, List, Optional, Tuple
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Lazy imports for optional / internal dependencies
50
+ # ---------------------------------------------------------------------------
51
+ def _import_web_search():
52
+ """Import the SSRF guards from web_search.py (same project)."""
53
+ try:
54
+ from tools import web_search as _ws
55
+ return _ws
56
+ except ImportError:
57
+ try:
58
+ import web_search as _ws # type: ignore
59
+ return _ws
60
+ except ImportError:
61
+ return None
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Constants
66
+ # ---------------------------------------------------------------------------
67
+
68
+ # A small named-color table. Only the common ones — the full CSS list is
69
+ # 148 entries and provides negligible additional value.
70
+ _NAMED_COLORS: Dict[str, Tuple[int, int, int]] = {
71
+ "black": (0, 0, 0), "white": (255, 255, 255),
72
+ "red": (255, 0, 0), "green": (0, 128, 0), "blue": (0, 0, 255),
73
+ "yellow": (255, 255, 0), "cyan": (0, 255, 255), "magenta": (255, 0, 255),
74
+ "gray": (128, 128, 128), "grey": (128, 128, 128),
75
+ "silver": (192, 192, 192), "maroon": (128, 0, 0),
76
+ "olive": (128, 128, 0), "lime": (0, 255, 0),
77
+ "aqua": (0, 255, 255), "teal": (0, 128, 128),
78
+ "navy": (0, 0, 128), "fuchsia": (255, 0, 255),
79
+ "purple": (128, 0, 128), "orange": (255, 165, 0),
80
+ "pink": (255, 192, 203), "brown": (165, 42, 42),
81
+ "gold": (255, 215, 0), "indigo": (75, 0, 130),
82
+ "crimson": (220, 20, 60), "coral": (255, 127, 80),
83
+ "salmon": (250, 128, 114), "tomato": (255, 99, 71),
84
+ "khaki": (240, 230, 140), "lavender": (230, 230, 250),
85
+ "beige": (245, 245, 220), "ivory": (255, 255, 240),
86
+ "transparent": (0, 0, 0),
87
+ "currentcolor": (0, 0, 0), "inherit": (0, 0, 0),
88
+ }
89
+
90
+ # Colors that should never be treated as accent candidates, even if they
91
+ # appear frequently. These are almost always utility or reset colors.
92
+ _IGNORE_AS_ACCENT = {
93
+ (0, 0, 0), (255, 255, 255),
94
+ }
95
+
96
+ # Frequency threshold — colors appearing fewer times than this are
97
+ # treated as one-off and excluded from the main clustering. Set to 2
98
+ # so that a color must appear in at least two rules to count.
99
+ _MIN_OCCURRENCES = 2
100
+
101
+ # CSS regexes — deliberately conservative. We would rather miss a color
102
+ # than misclassify a selector as a color.
103
+ _RE_HEX = re.compile(
104
+ r"#([0-9a-fA-F]{3,8})\b"
105
+ )
106
+ _RE_RGB = re.compile(
107
+ r"rgba?\(\s*"
108
+ r"([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)"
109
+ r"(?:\s*,\s*([\d.]+))?\s*\)",
110
+ re.IGNORECASE,
111
+ )
112
+ _RE_HSL = re.compile(
113
+ r"hsla?\(\s*"
114
+ r"([\d.]+)(?:deg)?\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%"
115
+ r"(?:\s*,\s*([\d.]+))?\s*\)",
116
+ re.IGNORECASE,
117
+ )
118
+ _RE_FONT_FAMILY = re.compile(
119
+ r"font-family\s*:\s*([^;}\n]+)",
120
+ re.IGNORECASE,
121
+ )
122
+ _RE_RULE = re.compile(
123
+ r"([^{}]+)\{([^{}]*)\}",
124
+ re.DOTALL,
125
+ )
126
+ _RE_CUSTOM_PROP = re.compile(
127
+ r"(--[a-zA-Z][\w-]*)\s*:\s*([^;}\n]+)",
128
+ )
129
+ _RE_STYLE_BLOCK = re.compile(
130
+ r"<style[^>]*>(.*?)</style>",
131
+ re.DOTALL | re.IGNORECASE,
132
+ )
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Data structures
137
+ # ---------------------------------------------------------------------------
138
+ @dataclass
139
+ class PaletteResult:
140
+ success: bool
141
+ palette_id: str = ""
142
+ output_path: str = ""
143
+ css_content: str = ""
144
+ colors_detected: int = 0
145
+ fonts_detected: int = 0
146
+ is_dark_mode: bool = False
147
+ error: str = ""
148
+ source: str = ""
149
+
150
+ def as_dict(self) -> Dict:
151
+ return asdict(self)
152
+
153
+
154
+ class PaletteExtractionError(Exception):
155
+ """Raised for unrecoverable errors during extraction."""
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Color utilities
160
+ # ---------------------------------------------------------------------------
161
+ def parse_color(s: str) -> Optional[Tuple[int, int, int]]:
162
+ """
163
+ Parse a color string into an (R, G, B) tuple. Returns None on failure.
164
+ Accepts #rgb, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl(), hsla(), and
165
+ the subset of CSS named colors in _NAMED_COLORS.
166
+ """
167
+ if not s:
168
+ return None
169
+ s = s.strip().lower()
170
+
171
+ # Named
172
+ if s in _NAMED_COLORS:
173
+ return _NAMED_COLORS[s]
174
+
175
+ # Hex
176
+ if s.startswith("#"):
177
+ h = s[1:]
178
+ try:
179
+ if len(h) == 3:
180
+ return tuple(int(c * 2, 16) for c in h) # type: ignore
181
+ if len(h) == 6:
182
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
183
+ if len(h) == 8:
184
+ # ignore alpha
185
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
186
+ except ValueError:
187
+ return None
188
+ return None
189
+
190
+ # rgb() / rgba()
191
+ m = _RE_RGB.fullmatch(s)
192
+ if m:
193
+ try:
194
+ return (
195
+ int(float(m.group(1))),
196
+ int(float(m.group(2))),
197
+ int(float(m.group(3))),
198
+ )
199
+ except (ValueError, TypeError):
200
+ return None
201
+
202
+ # hsl() / hsla()
203
+ m = _RE_HSL.fullmatch(s)
204
+ if m:
205
+ try:
206
+ h = float(m.group(1))
207
+ s_pct = float(m.group(2)) / 100.0
208
+ l_pct = float(m.group(3)) / 100.0
209
+ return hsl_to_rgb(h, s_pct, l_pct)
210
+ except (ValueError, TypeError):
211
+ return None
212
+
213
+ return None
214
+
215
+
216
+ def rgb_to_hsl(rgb: Tuple[int, int, int]) -> Tuple[float, float, float]:
217
+ """Return (H in [0,360), S in [0,1], L in [0,1])."""
218
+ r, g, b = [v / 255.0 for v in rgb]
219
+ mx = max(r, g, b)
220
+ mn = min(r, g, b)
221
+ l = (mx + mn) / 2.0
222
+ if mx == mn:
223
+ return (0.0, 0.0, l)
224
+ d = mx - mn
225
+ s = d / (2.0 - mx - mn) if l > 0.5 else d / (mx + mn)
226
+ if mx == r:
227
+ h = ((g - b) / d) % 6.0
228
+ elif mx == g:
229
+ h = (b - r) / d + 2.0
230
+ else:
231
+ h = (r - g) / d + 4.0
232
+ return (h * 60.0, s, l)
233
+
234
+
235
+ def hsl_to_rgb(h: float, s: float, l: float) -> Tuple[int, int, int]:
236
+ h = (h % 360.0) / 360.0
237
+ s = max(0.0, min(1.0, s))
238
+ l = max(0.0, min(1.0, l))
239
+ if s == 0.0:
240
+ v = int(round(l * 255.0))
241
+ return (v, v, v)
242
+ q = l * (1 + s) if l < 0.5 else l + s - l * s
243
+ p = 2 * l - q
244
+
245
+ def hue(t: float) -> float:
246
+ t = t % 1.0
247
+ if t < 1 / 6:
248
+ return p + (q - p) * 6 * t
249
+ if t < 1 / 2:
250
+ return q
251
+ if t < 2 / 3:
252
+ return p + (q - p) * (2 / 3 - t) * 6
253
+ return p
254
+
255
+ r = int(round(hue(h + 1 / 3) * 255))
256
+ g = int(round(hue(h) * 255))
257
+ b = int(round(hue(h - 1 / 3) * 255))
258
+ return (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
259
+
260
+
261
+ def rgb_to_hex(rgb: Tuple[int, int, int]) -> str:
262
+ return "#{:02x}{:02x}{:02x}".format(*rgb)
263
+
264
+
265
+ def rgba_string(rgb: Tuple[int, int, int], alpha: float) -> str:
266
+ a = max(0.0, min(1.0, alpha))
267
+ return f"rgba({rgb[0]}, {rgb[1]}, {rgb[2]}, {a:.2f})"
268
+
269
+
270
+ def luminance(rgb: Tuple[int, int, int]) -> float:
271
+ """Relative luminance approximation in [0, 1]."""
272
+ r, g, b = rgb
273
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
274
+
275
+
276
+ def _blend_toward(
277
+ color: Tuple[int, int, int],
278
+ target: Tuple[int, int, int],
279
+ t: float,
280
+ ) -> Tuple[int, int, int]:
281
+ """
282
+ Blend `color` toward `target` by factor `t` (0..1).
283
+ t=0 returns color, t=1 returns target.
284
+ """
285
+ t = max(0.0, min(1.0, t))
286
+ r1, g1, b1 = color
287
+ r2, g2, b2 = target
288
+ return (
289
+ int(round(r1 * (1 - t) + r2 * t)),
290
+ int(round(g1 * (1 - t) + g2 * t)),
291
+ int(round(b1 * (1 - t) + b2 * t)),
292
+ )
293
+
294
+
295
+ def adjust_lightness(rgb: Tuple[int, int, int], delta: float) -> Tuple[int, int, int]:
296
+ """Shift the L channel of an RGB color by delta (positive = lighter)."""
297
+ h, s, l = rgb_to_hsl(rgb)
298
+ return hsl_to_rgb(h, s, max(0.0, min(1.0, l + delta)))
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Fetching
303
+ # ---------------------------------------------------------------------------
304
+ def _is_remote(source: str) -> bool:
305
+ return source.startswith("http://") or source.startswith("https://")
306
+
307
+
308
+ def _fetch_url(url: str, timeout: float = 15.0) -> str:
309
+ """SSRF-guarded fetch. Returns the raw HTML."""
310
+ ws = _import_web_search()
311
+ if ws is None:
312
+ raise PaletteExtractionError(
313
+ "web_search module not importable — cannot fetch remote URLs"
314
+ )
315
+
316
+ safe, reason = ws._is_url_safe(url) # type: ignore[attr-defined]
317
+ if not safe:
318
+ raise PaletteExtractionError(f"Blocked unsafe URL: {reason}")
319
+
320
+ opener = ws._build_safe_opener() # type: ignore[attr-defined]
321
+ req = urllib.request.Request(
322
+ url,
323
+ headers={
324
+ "User-Agent": "DifferiX/0.4 palette-extractor",
325
+ "Accept": "text/html,application/xhtml+xml",
326
+ },
327
+ )
328
+ try:
329
+ with opener.open(req, timeout=timeout) as response:
330
+ raw = ws._read_capped(response) # type: ignore[attr-defined]
331
+ except Exception as exc:
332
+ raise PaletteExtractionError(f"Failed to fetch {url}: {exc}") from exc
333
+
334
+ return raw.decode("utf-8", errors="replace")
335
+
336
+
337
+ def _fetch_css(url: str, timeout: float = 10.0) -> str:
338
+ """Fetch a linked stylesheet. Returns "" on failure (non-fatal)."""
339
+ ws = _import_web_search()
340
+ if ws is None:
341
+ return ""
342
+ safe, _ = ws._is_url_safe(url) # type: ignore[attr-defined]
343
+ if not safe:
344
+ return ""
345
+ opener = ws._build_safe_opener() # type: ignore[attr-defined]
346
+ req = urllib.request.Request(
347
+ url,
348
+ headers={"User-Agent": "DifferiX/0.4 palette-extractor"},
349
+ )
350
+ try:
351
+ with opener.open(req, timeout=timeout) as response:
352
+ raw = ws._read_capped(response, max_bytes=2_000_000) # type: ignore[attr-defined]
353
+ return raw.decode("utf-8", errors="replace")
354
+ except Exception as exc:
355
+ logger.debug("Skipped stylesheet %s: %s", url, exc)
356
+ return ""
357
+
358
+
359
+ def _read_local(path: str) -> str:
360
+ try:
361
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
362
+ return f.read()
363
+ except OSError as exc:
364
+ raise PaletteExtractionError(f"Failed to read {path}: {exc}") from exc
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # HTML parsing
369
+ # ---------------------------------------------------------------------------
370
+ class _StylesheetLinkCollector(HTMLParser):
371
+ """Collects <link rel="stylesheet"> hrefs and inline <style> bodies."""
372
+
373
+ def __init__(self) -> None:
374
+ super().__init__()
375
+ self.link_hrefs: List[str] = []
376
+ self.inline_styles: List[str] = []
377
+ self._in_style = False
378
+ self._style_buffer: List[str] = []
379
+
380
+ def handle_starttag(self, tag: str, attrs) -> None:
381
+ tag_lower = tag.lower()
382
+ attr_dict = {k.lower(): (v or "") for k, v in attrs}
383
+ if tag_lower == "link":
384
+ rel = attr_dict.get("rel", "").lower()
385
+ href = attr_dict.get("href", "")
386
+ if "stylesheet" in rel and href:
387
+ self.link_hrefs.append(href)
388
+ elif tag_lower == "style":
389
+ self._in_style = True
390
+ self._style_buffer = []
391
+
392
+ def handle_endtag(self, tag: str) -> None:
393
+ if tag.lower() == "style" and self._in_style:
394
+ self.inline_styles.append("".join(self._style_buffer))
395
+ self._in_style = False
396
+ self._style_buffer = []
397
+
398
+ def handle_data(self, data: str) -> None:
399
+ if self._in_style:
400
+ self._style_buffer.append(data)
401
+
402
+
403
+ def _collect_css(html: str, base_url: Optional[str]) -> str:
404
+ """
405
+ Combine all CSS sources: inline <style> blocks and linked stylesheets.
406
+ Returns a single concatenated CSS string.
407
+ """
408
+ collector = _StylesheetLinkCollector()
409
+ try:
410
+ collector.feed(html)
411
+ except Exception as exc:
412
+ logger.warning("HTML parsing partial: %s", exc)
413
+
414
+ parts: List[str] = list(collector.inline_styles)
415
+
416
+ # Also do a regex pass for <style> in case HTMLParser missed something
417
+ parts.extend(_RE_STYLE_BLOCK.findall(html))
418
+
419
+ # Fetch linked stylesheets
420
+ for href in collector.link_hrefs:
421
+ if not base_url:
422
+ continue
423
+ full = urllib.parse.urljoin(base_url, href)
424
+ # Skip well-known font CDNs — they don't contain palette data
425
+ if any(cdn in full for cdn in (
426
+ "fonts.googleapis.com", "fonts.gstatic.com",
427
+ "use.typekit.net", "cdn.jsdelivr.net",
428
+ )):
429
+ continue
430
+ css = _fetch_css(full)
431
+ if css:
432
+ parts.append(css)
433
+
434
+ return "\n\n".join(parts)
435
+
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # CSS parsing
439
+ # ---------------------------------------------------------------------------
440
+ def _extract_color_occurrences(css: str) -> Dict[str, int]:
441
+ """
442
+ Return a mapping from normalized hex -> occurrence count.
443
+ Parses hex, rgb(), rgba(), hsl(), hsla(), and named colors.
444
+ """
445
+ counts: Dict[str, int] = {}
446
+
447
+ # Hex
448
+ for m in _RE_HEX.finditer(css):
449
+ raw = "#" + m.group(1).lower()
450
+ rgb = parse_color(raw)
451
+ if rgb:
452
+ key = rgb_to_hex(rgb)
453
+ counts[key] = counts.get(key, 0) + 1
454
+
455
+ # rgb / rgba
456
+ for m in _RE_RGB.finditer(css):
457
+ raw = m.group(0)
458
+ rgb = parse_color(raw)
459
+ if rgb:
460
+ key = rgb_to_hex(rgb)
461
+ counts[key] = counts.get(key, 0) + 1
462
+
463
+ # hsl / hsla
464
+ for m in _RE_HSL.finditer(css):
465
+ raw = m.group(0)
466
+ rgb = parse_color(raw)
467
+ if rgb:
468
+ key = rgb_to_hex(rgb)
469
+ counts[key] = counts.get(key, 0) + 1
470
+
471
+ # Named colors — do a word-boundary search on the CSS
472
+ for name, rgb in _NAMED_COLORS.items():
473
+ pattern = re.compile(r"\b" + re.escape(name) + r"\b")
474
+ n = len(pattern.findall(css))
475
+ if n > 0 and name not in ("transparent", "currentcolor", "inherit"):
476
+ key = rgb_to_hex(rgb)
477
+ counts[key] = counts.get(key, 0) + n
478
+
479
+ return counts
480
+
481
+
482
+ def _extract_custom_properties(css: str) -> Dict[str, str]:
483
+ """Extract --var: value pairs from any rule."""
484
+ out: Dict[str, str] = {}
485
+ for m in _RE_CUSTOM_PROP.finditer(css):
486
+ name = m.group(1).strip()
487
+ value = m.group(2).strip()
488
+ # Keep the first definition of a given variable
489
+ if name not in out:
490
+ out[name] = value
491
+ return out
492
+
493
+
494
+ def _extract_fonts_by_context(
495
+ css: str,
496
+ custom_props: Optional[Dict[str, str]] = None,
497
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
498
+ """
499
+ Return (heading_font, body_font, mono_font) inferred from the CSS.
500
+ Resolves var() references against `custom_props` when provided.
501
+ """
502
+ if custom_props is None:
503
+ custom_props = {}
504
+
505
+ heading_font: Optional[str] = None
506
+ body_font: Optional[str] = None
507
+ mono_font: Optional[str] = None
508
+ font_counter: Dict[str, int] = {}
509
+
510
+ for m in _RE_RULE.finditer(css):
511
+ selector = m.group(1).strip().lower()
512
+ body = m.group(2)
513
+
514
+ for fm in _RE_FONT_FAMILY.finditer(body):
515
+ family = fm.group(1).strip().rstrip(";").strip()
516
+ if not family:
517
+ continue
518
+
519
+ primary = _first_font_name(family)
520
+ if not primary:
521
+ # Maybe a bare var() reference — resolve it
522
+ primary = _resolve_font_var(family, custom_props)
523
+ if not primary:
524
+ continue
525
+
526
+ font_counter[primary] = font_counter.get(primary, 0) + 1
527
+
528
+ if "mono" in primary.lower() or "code" in primary.lower():
529
+ if mono_font is None:
530
+ mono_font = primary
531
+
532
+ if heading_font is None:
533
+ if any(s in selector for s in (
534
+ "h1", "h2", "h3", ".heading", ".title", ".headline",
535
+ )):
536
+ heading_font = primary
537
+
538
+ if font_counter:
539
+ sorted_fonts = sorted(
540
+ font_counter.items(),
541
+ key=lambda kv: (-kv[1], kv[0].lower()),
542
+ )
543
+ for name, _ in sorted_fonts:
544
+ if not _is_generic_font(name):
545
+ body_font = name
546
+ break
547
+ if body_font is None:
548
+ body_font = sorted_fonts[0][0]
549
+
550
+ # Resolve any remaining var() references
551
+ heading_font = _resolve_font_var(heading_font, custom_props)
552
+ body_font = _resolve_font_var(body_font, custom_props)
553
+ mono_font = _resolve_font_var(mono_font, custom_props)
554
+
555
+ return (heading_font, body_font, mono_font)
556
+
557
+ _GENERIC_FONT_NAMES = {
558
+ "serif", "sans-serif", "monospace", "cursive", "fantasy",
559
+ "system-ui", "-apple-system", "blinkmacsystemfont",
560
+ "inherit", "initial", "unset",
561
+ }
562
+
563
+
564
+ def _first_font_name(family_list: str) -> Optional[str]:
565
+ """
566
+ Given `"Inter", system-ui, sans-serif`, return `Inter`.
567
+ Rejects CSS variable references — they are not font names.
568
+ """
569
+ parts = [p.strip().strip("'\"") for p in family_list.split(",")]
570
+ for p in parts:
571
+ p = p.strip()
572
+ if not p:
573
+ continue
574
+ # Reject CSS variable references — the caller must resolve them
575
+ if p.startswith("var(") or p.startswith("--"):
576
+ continue
577
+ if p.lower() in _GENERIC_FONT_NAMES:
578
+ continue
579
+ return p
580
+ return parts[0] if parts and parts[0] else None
581
+
582
+ def _resolve_font_var(name: Optional[str], custom_props: Dict[str, str]) -> Optional[str]:
583
+ """
584
+ If `name` is a bare `var(--foo)` reference, resolve it against
585
+ custom_props and return the first concrete font name inside.
586
+ Returns None when resolution fails.
587
+ """
588
+ if not name:
589
+ return None
590
+ m = re.match(r"var\(\s*(--[\w-]+)\s*\)", name.strip())
591
+ if not m:
592
+ return name
593
+ ref = m.group(1)
594
+ resolved_value = custom_props.get(ref)
595
+ if not resolved_value:
596
+ return None
597
+ # resolved_value is itself a font-family list — take the first concrete name
598
+ return _first_font_name(resolved_value)
599
+
600
+
601
+ def _is_generic_font(name: str) -> bool:
602
+ return name.lower() in _GENERIC_FONT_NAMES
603
+
604
+
605
+ # ---------------------------------------------------------------------------
606
+ # Clustering
607
+ # ---------------------------------------------------------------------------
608
+ def _classify_colors(
609
+ color_counts: Dict[str, int],
610
+ ) -> Tuple[bool, List[str], List[str], List[str], List[str]]:
611
+ """
612
+ Given a hex->count mapping, return:
613
+ (is_dark_mode, backgrounds, accents, text_colors, neutrals)
614
+ Each list is sorted by descending frequency.
615
+ """
616
+ # Filter: require minimum occurrence
617
+ filtered = [
618
+ (hex_str, parse_color(hex_str), count)
619
+ for hex_str, count in color_counts.items()
620
+ ]
621
+ filtered = [(h, rgb, c) for h, rgb, c in filtered if rgb is not None and c >= _MIN_OCCURRENCES]
622
+
623
+ if not filtered:
624
+ # Fallback to all colors if nothing meets the threshold
625
+ filtered = [
626
+ (hex_str, parse_color(hex_str), count)
627
+ for hex_str, count in color_counts.items()
628
+ ]
629
+ filtered = [(h, rgb, c) for h, rgb, c in filtered if rgb is not None]
630
+
631
+ if not filtered:
632
+ return (True, [], [], [], [])
633
+
634
+ # Sort by count desc
635
+ filtered.sort(key=lambda t: -t[2])
636
+
637
+ # Determine mode from the top colors
638
+ top = filtered[:5]
639
+ avg_lum = sum(luminance(rgb) for _, rgb, _ in top) / len(top)
640
+ is_dark = avg_lum < 0.5
641
+
642
+ backgrounds: List[str] = []
643
+ accents: List[str] = []
644
+ text_colors: List[str] = []
645
+ neutrals: List[str] = []
646
+
647
+ for hex_str, rgb, _ in filtered:
648
+ h, s, l = rgb_to_hsl(rgb)
649
+ lum = luminance(rgb)
650
+
651
+ is_chromatic = s > 0.20
652
+
653
+ if is_chromatic:
654
+ # Chromatic colors become accent candidates if reasonably saturated
655
+ if s > 0.35 and rgb not in _IGNORE_AS_ACCENT:
656
+ accents.append(hex_str)
657
+ else:
658
+ neutrals.append(hex_str)
659
+ else:
660
+ # Neutral: classify by luminance relative to the theme
661
+ if is_dark:
662
+ if lum < 0.12:
663
+ backgrounds.append(hex_str)
664
+ elif lum > 0.75:
665
+ text_colors.append(hex_str)
666
+ else:
667
+ neutrals.append(hex_str)
668
+ else:
669
+ if lum > 0.92:
670
+ backgrounds.append(hex_str)
671
+ elif lum < 0.25:
672
+ text_colors.append(hex_str)
673
+ else:
674
+ neutrals.append(hex_str)
675
+
676
+ return (is_dark, backgrounds, accents, text_colors, neutrals)
677
+
678
+
679
+ # ---------------------------------------------------------------------------
680
+ # Palette construction
681
+ # ---------------------------------------------------------------------------
682
+ def _pick(lst: List[str], fallback: str, idx: int = 0) -> str:
683
+ if len(lst) > idx:
684
+ return lst[idx]
685
+ if lst:
686
+ return lst[-1]
687
+ return fallback
688
+
689
+
690
+ def _match_custom_props_to_roles(props: Dict[str, str]) -> Dict[str, str]:
691
+ """
692
+ Try to guess the role of each custom property from its name, and map
693
+ it to our token names. Returns {our_token: hex_value} for matches.
694
+ Only includes matches where the value parses to a color.
695
+ """
696
+ role_to_hex: Dict[str, str] = {}
697
+
698
+ def _value_to_hex(v: str) -> Optional[str]:
699
+ rgb = parse_color(v.strip())
700
+ return rgb_to_hex(rgb) if rgb else None
701
+
702
+ patterns: List[Tuple[str, List[str]]] = [
703
+ ("accent", ["primary", "brand", "accent", "main", "highlight", "link"]),
704
+ ("bg-primary", ["bg", "background", "surface", "base", "body"]),
705
+ ("text-main", ["text", "foreground", "fg", "content", "primary-text"]),
706
+ ("border-subtle", ["border", "divider", "outline"]),
707
+ ]
708
+
709
+ for our_token, keywords in patterns:
710
+ for name, value in props.items():
711
+ lname = name.lower().lstrip("-")
712
+ if any(kw in lname for kw in keywords):
713
+ hex_val = _value_to_hex(value)
714
+ if hex_val and our_token not in role_to_hex:
715
+ role_to_hex[our_token] = hex_val
716
+ break
717
+
718
+ return role_to_hex
719
+
720
+
721
+ def build_palette(
722
+ color_counts: Dict[str, int],
723
+ fonts: Tuple[Optional[str], Optional[str], Optional[str]],
724
+ custom_props: Dict[str, str],
725
+ ) -> Tuple[Dict[str, str], bool]:
726
+ """
727
+ Build the palette dictionary. Returns (palette, is_dark_mode).
728
+ """
729
+ is_dark, bgs, accents, texts, neutrals = _classify_colors(color_counts)
730
+ hinted = _match_custom_props_to_roles(custom_props)
731
+
732
+ # ---- Backgrounds ----
733
+ if is_dark:
734
+ bg_default = "#0a0c10"
735
+ bg_secondary_default = "#12161f"
736
+ bg_tertiary_default = "#181d28"
737
+ bg_card_default = "#141821"
738
+ text_default = "#f3f4f6"
739
+ text_muted_default = "#9ca3af"
740
+ text_dim_default = "#6b7280"
741
+ text_inverse_default = "#0a0c10"
742
+ else:
743
+ bg_default = "#ffffff"
744
+ bg_secondary_default = "#f5f5f7"
745
+ bg_tertiary_default = "#e8e8ec"
746
+ bg_card_default = "#ffffff"
747
+ text_default = "#111318"
748
+ text_muted_default = "#4b5563"
749
+ text_dim_default = "#6b7280"
750
+ text_inverse_default = "#ffffff"
751
+
752
+ bg_primary = hinted.get("bg-primary") or _pick(bgs, bg_default, 0)
753
+ bg_secondary = _pick(bgs, bg_secondary_default, 1) if len(bgs) > 1 else bg_secondary_default
754
+ bg_tertiary = _pick(bgs, bg_tertiary_default, 2) if len(bgs) > 2 else bg_tertiary_default
755
+
756
+ # ---- Accent ----
757
+ accent_hex = hinted.get("accent") or _pick(accents, "#f59e0b", 0)
758
+ accent_rgb = parse_color(accent_hex) or (245, 158, 11)
759
+ accent_light_rgb = adjust_lightness(accent_rgb, 0.12)
760
+ accent_dark_rgb = adjust_lightness(accent_rgb, -0.12)
761
+
762
+ # ---- Text ----
763
+ text_main_hex = hinted.get("text-main") or _pick(texts, text_default, 0)
764
+ text_main_rgb = parse_color(text_main_hex) or parse_color(text_default) or (243, 244, 246)
765
+
766
+ bg_rgb = parse_color(bg_primary) or ((10, 12, 16) if is_dark else (255, 255, 255))
767
+
768
+ # Derive muted and dim by blending the main text color toward the
769
+ # background. This works in both light and dark mode and never
770
+ # collapses to a single value, which adjust_lightness did when the
771
+ # starting color was already at lightness 0 or 1.
772
+ text_muted_rgb = _blend_toward(text_main_rgb, bg_rgb, 0.45)
773
+ text_dim_rgb = _blend_toward(text_main_rgb, bg_rgb, 0.65)
774
+
775
+ # ---- Borders ----
776
+ border_subtle = hinted.get("border-subtle") or rgba_string(text_main_rgb, 0.08)
777
+ border_medium = rgba_string(text_main_rgb, 0.16)
778
+ border_accent = rgba_string(accent_rgb, 0.35)
779
+
780
+ # ---- Fonts ----
781
+ heading_font, body_font, mono_font = fonts
782
+ heading_font = heading_font or body_font or "Space Grotesk"
783
+ body_font = body_font or "Inter"
784
+ mono_font = mono_font or "JetBrains Mono"
785
+
786
+ # ---- Inverse text (used on top of the accent surface) ----
787
+ accent_lum = luminance(accent_rgb)
788
+ text_inverse_hex = "#0a0c10" if accent_lum > 0.5 else "#ffffff"
789
+
790
+ palette: Dict[str, str] = {
791
+ "--bg-primary": bg_primary,
792
+ "--bg-secondary": bg_secondary,
793
+ "--bg-tertiary": bg_tertiary,
794
+ "--bg-card": bg_card_default,
795
+ "--bg-glass": rgba_string(bg_rgb, 0.72),
796
+
797
+ "--accent": rgb_to_hex(accent_rgb),
798
+ "--accent-light": rgb_to_hex(accent_light_rgb),
799
+ "--accent-dark": rgb_to_hex(accent_dark_rgb),
800
+ "--accent-glow": rgba_string(accent_rgb, 0.25),
801
+ "--accent-soft": rgba_string(accent_rgb, 0.10),
802
+
803
+ "--text-main": rgb_to_hex(text_main_rgb),
804
+ "--text-muted": rgb_to_hex(text_muted_rgb),
805
+ "--text-dim": rgb_to_hex(text_dim_rgb),
806
+ "--text-inverse": text_inverse_hex,
807
+
808
+ "--border-subtle": border_subtle,
809
+ "--border-medium": border_medium,
810
+ "--border-accent": border_accent,
811
+
812
+ "--font-heading": f"'{heading_font}', system-ui, sans-serif",
813
+ "--font-body": f"'{body_font}', system-ui, sans-serif",
814
+ "--font-mono": f"'{mono_font}', monospace",
815
+
816
+ "--transition-fast": "0.15s cubic-bezier(0.16, 1, 0.3, 1)",
817
+ "--transition-smooth": "0.3s cubic-bezier(0.16, 1, 0.3, 1)",
818
+ }
819
+ return (palette, is_dark)
820
+
821
+
822
+ # ---------------------------------------------------------------------------
823
+ # Output
824
+ # ---------------------------------------------------------------------------
825
+ def _slug_from_source(source: str) -> str:
826
+ if _is_remote(source):
827
+ try:
828
+ host = urllib.parse.urlparse(source).hostname or ""
829
+ except Exception:
830
+ host = source
831
+ host = host.replace("www.", "")
832
+ slug = re.sub(r"[^a-z0-9]+", "-", host.lower()).strip("-")
833
+ return slug or "extracted"
834
+ else:
835
+ base = os.path.splitext(os.path.basename(source))[0]
836
+ slug = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-")
837
+ return slug or "extracted"
838
+
839
+
840
+ def _render_palette_css(
841
+ palette: Dict[str, str],
842
+ name: str,
843
+ source: str,
844
+ is_dark: bool,
845
+ ) -> str:
846
+ mood = "dark, high-contrast" if is_dark else "light, editorial"
847
+ groups = [
848
+ ("Surfaces", ["--bg-primary", "--bg-secondary", "--bg-tertiary",
849
+ "--bg-card", "--bg-glass"]),
850
+ ("Accent", ["--accent", "--accent-light", "--accent-dark",
851
+ "--accent-glow", "--accent-soft"]),
852
+ ("Text", ["--text-main", "--text-muted", "--text-dim",
853
+ "--text-inverse"]),
854
+ ("Borders", ["--border-subtle", "--border-medium", "--border-accent"]),
855
+ ("Typography", ["--font-heading", "--font-body", "--font-mono"]),
856
+ ("Motion", ["--transition-fast", "--transition-smooth"]),
857
+ ]
858
+
859
+ lines = [
860
+ f"/* Palette: {name}",
861
+ f" Mood: {mood}",
862
+ f" Source: {source}",
863
+ f" Extracted: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
864
+ f"",
865
+ f" This file was generated by tools/palette_extractor.py.",
866
+ f" The values are derived from the source's visual identity —",
867
+ f" they are not copied from the source's CSS. Edit freely.",
868
+ f"*/",
869
+ "",
870
+ ":root {",
871
+ ]
872
+
873
+ for i, (label, keys) in enumerate(groups):
874
+ if i > 0:
875
+ lines.append("")
876
+ lines.append(f" /* {label} */")
877
+ for key in keys:
878
+ # Pad `key + ':'` to 22 chars so values align
879
+ padded = f"{key}:".ljust(22)
880
+ lines.append(f" {padded} {palette[key]};")
881
+
882
+ lines.append("}")
883
+ lines.append("")
884
+ return "\n".join(lines)
885
+
886
+ def extract_palette(
887
+ source: str,
888
+ output_dir: str = "palettes",
889
+ name: Optional[str] = None,
890
+ dry_run: bool = False,
891
+ ) -> PaletteResult:
892
+ """
893
+ Extract a palette from a URL or local HTML file.
894
+
895
+ Args:
896
+ source: URL or path to local HTML file.
897
+ output_dir: directory to write the palette file (created if missing).
898
+ name: palette slug. Defaults to a slug derived from the source.
899
+ dry_run: if True, don't write to disk — just return the CSS in the
900
+ result.
901
+
902
+ Returns:
903
+ PaletteResult. Never raises for network or parse errors; check
904
+ `.success` and `.error` fields.
905
+ """
906
+ t0 = time.time()
907
+ result = PaletteResult(success=False, source=source)
908
+
909
+ if not source:
910
+ result.error = "Empty source"
911
+ return result
912
+
913
+ # ---- Fetch HTML ----
914
+ try:
915
+ if _is_remote(source):
916
+ html = _fetch_url(source)
917
+ base_url: Optional[str] = source
918
+ else:
919
+ html = _read_local(source)
920
+ base_url = None
921
+ except PaletteExtractionError as exc:
922
+ result.error = str(exc)
923
+ return result
924
+
925
+ if not html or len(html) < 50:
926
+ result.error = "Fetched content is too small to be a valid page"
927
+ return result
928
+
929
+ # ---- Collect CSS ----
930
+ css = _collect_css(html, base_url)
931
+ if not css:
932
+ result.error = "No CSS found (no <style> blocks and no linked stylesheets)"
933
+ return result
934
+
935
+ # ---- Extract ----
936
+ try:
937
+ color_counts = _extract_color_occurrences(css)
938
+ custom_props = _extract_custom_properties(css)
939
+ fonts = _extract_fonts_by_context(css, custom_props=custom_props)
940
+ except Exception as exc:
941
+ logger.exception("Extraction failed")
942
+ result.error = f"Extraction error: {exc}"
943
+ return result
944
+
945
+ if not color_counts:
946
+ result.error = "No colors detected in CSS"
947
+ return result
948
+
949
+ # ---- Build palette ----
950
+ palette, is_dark = build_palette(color_counts, fonts, custom_props)
951
+
952
+ palette_name = name or _slug_from_source(source)
953
+ css_content = _render_palette_css(palette, palette_name, source, is_dark)
954
+
955
+ result.palette_id = palette_name
956
+ result.css_content = css_content
957
+ result.colors_detected = len(color_counts)
958
+ result.fonts_detected = sum(1 for f in fonts if f)
959
+ result.is_dark_mode = is_dark
960
+
961
+ # ---- Write file ----
962
+ if not dry_run:
963
+ try:
964
+ out_dir = Path(output_dir)
965
+ out_dir.mkdir(parents=True, exist_ok=True)
966
+ out_path = out_dir / f"{palette_name}.css"
967
+ out_path.write_text(css_content, encoding="utf-8")
968
+ result.output_path = str(out_path)
969
+ except OSError as exc:
970
+ result.error = f"Failed to write palette file: {exc}"
971
+ return result
972
+
973
+ result.success = True
974
+ logger.info(
975
+ "Extracted palette %r from %s in %.2fs (%d colors, %d fonts, %s mode)",
976
+ palette_name, source, time.time() - t0,
977
+ result.colors_detected, result.fonts_detected,
978
+ "dark" if is_dark else "light",
979
+ )
980
+ return result
981
+
982
+
983
+ # ---------------------------------------------------------------------------
984
+ # CLI
985
+ # ---------------------------------------------------------------------------
986
+ def _cli() -> int:
987
+ import argparse
988
+
989
+ parser = argparse.ArgumentParser(
990
+ description="Extract a DifferiX palette from a URL or local HTML file.",
991
+ )
992
+ parser.add_argument("source", help="URL or path to an HTML file")
993
+ parser.add_argument(
994
+ "-o", "--output-dir", default="palettes",
995
+ help="Directory to write the palette file (default: palettes/)",
996
+ )
997
+ parser.add_argument(
998
+ "--name", default=None,
999
+ help="Palette slug (default: derived from the source domain)",
1000
+ )
1001
+ parser.add_argument(
1002
+ "--dry-run", action="store_true",
1003
+ help="Print the palette to stdout without writing a file",
1004
+ )
1005
+ parser.add_argument(
1006
+ "--verbose", "-v", action="store_true",
1007
+ help="Enable debug logging",
1008
+ )
1009
+
1010
+ args = parser.parse_args()
1011
+
1012
+ logging.basicConfig(
1013
+ level=logging.DEBUG if args.verbose else logging.INFO,
1014
+ format="%(levelname)s %(message)s",
1015
+ )
1016
+
1017
+ result = extract_palette(
1018
+ source=args.source,
1019
+ output_dir=args.output_dir,
1020
+ name=args.name,
1021
+ dry_run=args.dry_run,
1022
+ )
1023
+
1024
+ if not result.success:
1025
+ print(f"ERROR: {result.error}", file=sys.stderr)
1026
+ return 1
1027
+
1028
+ print(f"Palette: {result.palette_id}")
1029
+ print(f"Colors detected: {result.colors_detected}")
1030
+ print(f"Fonts detected: {result.fonts_detected}")
1031
+ print(f"Mode: {'dark' if result.is_dark_mode else 'light'}")
1032
+
1033
+ if args.dry_run:
1034
+ print()
1035
+ print(result.css_content)
1036
+ else:
1037
+ print(f"Written to: {result.output_path}")
1038
+
1039
+ return 0
1040
+
1041
+
1042
+ if __name__ == "__main__":
1043
+ sys.exit(_cli())