Upload app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,2084 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Design System Extractor v2 β Main Application
|
| 3 |
+
==============================================
|
| 4 |
+
|
| 5 |
+
Flow:
|
| 6 |
+
1. User enters URL
|
| 7 |
+
2. Agent 1 discovers pages β User confirms
|
| 8 |
+
3. Agent 1 extracts tokens (Desktop + Mobile)
|
| 9 |
+
4. Agent 2 normalizes tokens
|
| 10 |
+
5. Stage 1 UI: User reviews tokens (accept/reject, DesktopβMobile toggle)
|
| 11 |
+
6. Agent 3 proposes upgrades
|
| 12 |
+
7. Stage 2 UI: User selects options with live preview
|
| 13 |
+
8. Agent 4 generates JSON
|
| 14 |
+
9. Stage 3 UI: User exports
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import asyncio
|
| 19 |
+
import json
|
| 20 |
+
import gradio as gr
|
| 21 |
+
from datetime import datetime
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
# Get HF token from environment
|
| 25 |
+
HF_TOKEN_FROM_ENV = os.getenv("HF_TOKEN", "")
|
| 26 |
+
|
| 27 |
+
# =============================================================================
|
| 28 |
+
# GLOBAL STATE
|
| 29 |
+
# =============================================================================
|
| 30 |
+
|
| 31 |
+
class AppState:
|
| 32 |
+
"""Global application state."""
|
| 33 |
+
def __init__(self):
|
| 34 |
+
self.reset()
|
| 35 |
+
|
| 36 |
+
def reset(self):
|
| 37 |
+
self.discovered_pages = []
|
| 38 |
+
self.base_url = ""
|
| 39 |
+
self.desktop_raw = None # ExtractedTokens
|
| 40 |
+
self.mobile_raw = None # ExtractedTokens
|
| 41 |
+
self.desktop_normalized = None # NormalizedTokens
|
| 42 |
+
self.mobile_normalized = None # NormalizedTokens
|
| 43 |
+
self.upgrade_recommendations = None # UpgradeRecommendations
|
| 44 |
+
self.selected_upgrades = {} # User selections
|
| 45 |
+
self.logs = []
|
| 46 |
+
|
| 47 |
+
def log(self, message: str):
|
| 48 |
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
| 49 |
+
self.logs.append(f"[{timestamp}] {message}")
|
| 50 |
+
if len(self.logs) > 100:
|
| 51 |
+
self.logs.pop(0)
|
| 52 |
+
|
| 53 |
+
def get_logs(self) -> str:
|
| 54 |
+
return "\n".join(self.logs)
|
| 55 |
+
|
| 56 |
+
state = AppState()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# =============================================================================
|
| 60 |
+
# LAZY IMPORTS
|
| 61 |
+
# =============================================================================
|
| 62 |
+
|
| 63 |
+
def get_crawler():
|
| 64 |
+
import agents.crawler
|
| 65 |
+
return agents.crawler
|
| 66 |
+
|
| 67 |
+
def get_extractor():
|
| 68 |
+
import agents.extractor
|
| 69 |
+
return agents.extractor
|
| 70 |
+
|
| 71 |
+
def get_normalizer():
|
| 72 |
+
import agents.normalizer
|
| 73 |
+
return agents.normalizer
|
| 74 |
+
|
| 75 |
+
def get_advisor():
|
| 76 |
+
import agents.advisor
|
| 77 |
+
return agents.advisor
|
| 78 |
+
|
| 79 |
+
def get_schema():
|
| 80 |
+
import core.token_schema
|
| 81 |
+
return core.token_schema
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# =============================================================================
|
| 85 |
+
# PHASE 1: DISCOVER PAGES
|
| 86 |
+
# =============================================================================
|
| 87 |
+
|
| 88 |
+
async def discover_pages(url: str, progress=gr.Progress()):
|
| 89 |
+
"""Discover pages from URL."""
|
| 90 |
+
state.reset()
|
| 91 |
+
|
| 92 |
+
if not url or not url.startswith(("http://", "https://")):
|
| 93 |
+
return "β Please enter a valid URL", "", None
|
| 94 |
+
|
| 95 |
+
state.log(f"π Starting discovery for: {url}")
|
| 96 |
+
progress(0.1, desc="π Discovering pages...")
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
crawler = get_crawler()
|
| 100 |
+
discoverer = crawler.PageDiscoverer()
|
| 101 |
+
|
| 102 |
+
pages = await discoverer.discover(url)
|
| 103 |
+
|
| 104 |
+
state.discovered_pages = pages
|
| 105 |
+
state.base_url = url
|
| 106 |
+
|
| 107 |
+
state.log(f"β
Found {len(pages)} pages")
|
| 108 |
+
|
| 109 |
+
# Format for display
|
| 110 |
+
pages_data = []
|
| 111 |
+
for page in pages:
|
| 112 |
+
pages_data.append([
|
| 113 |
+
True, # Selected by default
|
| 114 |
+
page.url,
|
| 115 |
+
page.title if page.title else "(No title)",
|
| 116 |
+
page.page_type.value,
|
| 117 |
+
"β" if not page.error else f"β {page.error}"
|
| 118 |
+
])
|
| 119 |
+
|
| 120 |
+
progress(1.0, desc="β
Discovery complete!")
|
| 121 |
+
|
| 122 |
+
status = f"β
Found {len(pages)} pages. Review and click 'Extract Tokens' to continue."
|
| 123 |
+
|
| 124 |
+
return status, state.get_logs(), pages_data
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
import traceback
|
| 128 |
+
state.log(f"β Error: {str(e)}")
|
| 129 |
+
return f"β Error: {str(e)}", state.get_logs(), None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# =============================================================================
|
| 133 |
+
# PHASE 2: EXTRACT TOKENS
|
| 134 |
+
# =============================================================================
|
| 135 |
+
|
| 136 |
+
async def extract_tokens(pages_data, progress=gr.Progress()):
|
| 137 |
+
"""Extract tokens from selected pages (both viewports)."""
|
| 138 |
+
|
| 139 |
+
state.log(f"π₯ Received pages_data type: {type(pages_data)}")
|
| 140 |
+
|
| 141 |
+
if pages_data is None:
|
| 142 |
+
return "β Please discover pages first", state.get_logs(), None, None
|
| 143 |
+
|
| 144 |
+
# Get selected URLs - handle pandas DataFrame
|
| 145 |
+
selected_urls = []
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
# Check if it's a pandas DataFrame
|
| 149 |
+
if hasattr(pages_data, 'iterrows'):
|
| 150 |
+
state.log(f"π₯ DataFrame with {len(pages_data)} rows, columns: {list(pages_data.columns)}")
|
| 151 |
+
|
| 152 |
+
for idx, row in pages_data.iterrows():
|
| 153 |
+
# Get values by column name or position
|
| 154 |
+
try:
|
| 155 |
+
# Try column names first
|
| 156 |
+
is_selected = row.get('Select', row.iloc[0] if len(row) > 0 else False)
|
| 157 |
+
url = row.get('URL', row.iloc[1] if len(row) > 1 else '')
|
| 158 |
+
except:
|
| 159 |
+
# Fallback to positional
|
| 160 |
+
is_selected = row.iloc[0] if len(row) > 0 else False
|
| 161 |
+
url = row.iloc[1] if len(row) > 1 else ''
|
| 162 |
+
|
| 163 |
+
if is_selected and url:
|
| 164 |
+
selected_urls.append(url)
|
| 165 |
+
|
| 166 |
+
# If it's a dict (Gradio sometimes sends this)
|
| 167 |
+
elif isinstance(pages_data, dict):
|
| 168 |
+
state.log(f"π₯ Dict with keys: {list(pages_data.keys())}")
|
| 169 |
+
data = pages_data.get('data', [])
|
| 170 |
+
for row in data:
|
| 171 |
+
if isinstance(row, (list, tuple)) and len(row) >= 2 and row[0]:
|
| 172 |
+
selected_urls.append(row[1])
|
| 173 |
+
|
| 174 |
+
# If it's a list
|
| 175 |
+
elif isinstance(pages_data, (list, tuple)):
|
| 176 |
+
state.log(f"π₯ List with {len(pages_data)} items")
|
| 177 |
+
for row in pages_data:
|
| 178 |
+
if isinstance(row, (list, tuple)) and len(row) >= 2 and row[0]:
|
| 179 |
+
selected_urls.append(row[1])
|
| 180 |
+
|
| 181 |
+
except Exception as e:
|
| 182 |
+
state.log(f"β Error parsing pages_data: {str(e)}")
|
| 183 |
+
import traceback
|
| 184 |
+
state.log(traceback.format_exc())
|
| 185 |
+
|
| 186 |
+
state.log(f"π Found {len(selected_urls)} selected URLs")
|
| 187 |
+
|
| 188 |
+
# If still no URLs, try using stored discovered pages
|
| 189 |
+
if not selected_urls and state.discovered_pages:
|
| 190 |
+
state.log("β οΈ No URLs from table, using all discovered pages")
|
| 191 |
+
selected_urls = [p.url for p in state.discovered_pages if not p.error][:10]
|
| 192 |
+
|
| 193 |
+
if not selected_urls:
|
| 194 |
+
return "β No pages selected. Please select pages or rediscover.", state.get_logs(), None, None
|
| 195 |
+
|
| 196 |
+
# Limit to 10 pages for performance
|
| 197 |
+
selected_urls = selected_urls[:10]
|
| 198 |
+
|
| 199 |
+
state.log(f"π Extracting from {len(selected_urls)} pages:")
|
| 200 |
+
for url in selected_urls[:3]:
|
| 201 |
+
state.log(f" β’ {url}")
|
| 202 |
+
if len(selected_urls) > 3:
|
| 203 |
+
state.log(f" ... and {len(selected_urls) - 3} more")
|
| 204 |
+
|
| 205 |
+
progress(0.05, desc="π Starting extraction...")
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
schema = get_schema()
|
| 209 |
+
extractor_mod = get_extractor()
|
| 210 |
+
normalizer_mod = get_normalizer()
|
| 211 |
+
|
| 212 |
+
# === DESKTOP EXTRACTION ===
|
| 213 |
+
state.log("")
|
| 214 |
+
state.log("=" * 60)
|
| 215 |
+
state.log("π₯οΈ DESKTOP EXTRACTION (1440px)")
|
| 216 |
+
state.log("=" * 60)
|
| 217 |
+
state.log("")
|
| 218 |
+
state.log("π‘ Enhanced extraction from 5 sources:")
|
| 219 |
+
state.log(" 1. DOM computed styles (getComputedStyle)")
|
| 220 |
+
state.log(" 2. CSS variables (:root { --color: })")
|
| 221 |
+
state.log(" 3. SVG colors (fill, stroke)")
|
| 222 |
+
state.log(" 4. Inline styles (style='color:')")
|
| 223 |
+
state.log(" 5. Stylesheet rules (CSS files)")
|
| 224 |
+
state.log("")
|
| 225 |
+
|
| 226 |
+
progress(0.1, desc="π₯οΈ Extracting desktop tokens...")
|
| 227 |
+
|
| 228 |
+
desktop_extractor = extractor_mod.TokenExtractor(viewport=schema.Viewport.DESKTOP)
|
| 229 |
+
|
| 230 |
+
def desktop_progress(p):
|
| 231 |
+
progress(0.1 + (p * 0.35), desc=f"π₯οΈ Desktop... {int(p*100)}%")
|
| 232 |
+
|
| 233 |
+
state.desktop_raw = await desktop_extractor.extract(selected_urls, progress_callback=desktop_progress)
|
| 234 |
+
|
| 235 |
+
# Log extraction details
|
| 236 |
+
state.log("π EXTRACTION RESULTS:")
|
| 237 |
+
state.log(f" Colors: {len(state.desktop_raw.colors)} unique")
|
| 238 |
+
state.log(f" Typography: {len(state.desktop_raw.typography)} styles")
|
| 239 |
+
state.log(f" Spacing: {len(state.desktop_raw.spacing)} values")
|
| 240 |
+
state.log(f" Radius: {len(state.desktop_raw.radius)} values")
|
| 241 |
+
state.log(f" Shadows: {len(state.desktop_raw.shadows)} values")
|
| 242 |
+
|
| 243 |
+
# Log CSS variables if found
|
| 244 |
+
if hasattr(desktop_extractor, 'css_variables') and desktop_extractor.css_variables:
|
| 245 |
+
state.log("")
|
| 246 |
+
state.log(f"π¨ CSS Variables found: {len(desktop_extractor.css_variables)}")
|
| 247 |
+
for var_name, var_value in list(desktop_extractor.css_variables.items())[:5]:
|
| 248 |
+
state.log(f" {var_name}: {var_value}")
|
| 249 |
+
if len(desktop_extractor.css_variables) > 5:
|
| 250 |
+
state.log(f" ... and {len(desktop_extractor.css_variables) - 5} more")
|
| 251 |
+
|
| 252 |
+
# Log warnings if any
|
| 253 |
+
if desktop_extractor.warnings:
|
| 254 |
+
state.log("")
|
| 255 |
+
state.log("β οΈ Warnings:")
|
| 256 |
+
for w in desktop_extractor.warnings[:3]:
|
| 257 |
+
state.log(f" {w}")
|
| 258 |
+
|
| 259 |
+
# Normalize desktop
|
| 260 |
+
state.log("")
|
| 261 |
+
state.log("π Normalizing (deduping, naming)...")
|
| 262 |
+
state.desktop_normalized = normalizer_mod.normalize_tokens(state.desktop_raw)
|
| 263 |
+
state.log(f" β
Normalized: {len(state.desktop_normalized.colors)} colors, {len(state.desktop_normalized.typography)} typography, {len(state.desktop_normalized.spacing)} spacing")
|
| 264 |
+
|
| 265 |
+
# === MOBILE EXTRACTION ===
|
| 266 |
+
state.log("")
|
| 267 |
+
state.log("=" * 60)
|
| 268 |
+
state.log("π± MOBILE EXTRACTION (375px)")
|
| 269 |
+
state.log("=" * 60)
|
| 270 |
+
state.log("")
|
| 271 |
+
|
| 272 |
+
progress(0.5, desc="π± Extracting mobile tokens...")
|
| 273 |
+
|
| 274 |
+
mobile_extractor = extractor_mod.TokenExtractor(viewport=schema.Viewport.MOBILE)
|
| 275 |
+
|
| 276 |
+
def mobile_progress(p):
|
| 277 |
+
progress(0.5 + (p * 0.35), desc=f"π± Mobile... {int(p*100)}%")
|
| 278 |
+
|
| 279 |
+
state.mobile_raw = await mobile_extractor.extract(selected_urls, progress_callback=mobile_progress)
|
| 280 |
+
|
| 281 |
+
# Log extraction details
|
| 282 |
+
state.log("π EXTRACTION RESULTS:")
|
| 283 |
+
state.log(f" Colors: {len(state.mobile_raw.colors)} unique")
|
| 284 |
+
state.log(f" Typography: {len(state.mobile_raw.typography)} styles")
|
| 285 |
+
state.log(f" Spacing: {len(state.mobile_raw.spacing)} values")
|
| 286 |
+
state.log(f" Radius: {len(state.mobile_raw.radius)} values")
|
| 287 |
+
state.log(f" Shadows: {len(state.mobile_raw.shadows)} values")
|
| 288 |
+
|
| 289 |
+
# Normalize mobile
|
| 290 |
+
state.log("")
|
| 291 |
+
state.log("π Normalizing...")
|
| 292 |
+
state.mobile_normalized = normalizer_mod.normalize_tokens(state.mobile_raw)
|
| 293 |
+
state.log(f" β
Normalized: {len(state.mobile_normalized.colors)} colors, {len(state.mobile_normalized.typography)} typography, {len(state.mobile_normalized.spacing)} spacing")
|
| 294 |
+
|
| 295 |
+
progress(0.95, desc="π Preparing results...")
|
| 296 |
+
|
| 297 |
+
# Format results for Stage 1 UI
|
| 298 |
+
desktop_data = format_tokens_for_display(state.desktop_normalized)
|
| 299 |
+
mobile_data = format_tokens_for_display(state.mobile_normalized)
|
| 300 |
+
|
| 301 |
+
# Generate visual previews - AS-IS for Stage 1 (no ramps, no enhancements)
|
| 302 |
+
state.log("")
|
| 303 |
+
state.log("π¨ Generating AS-IS visual previews...")
|
| 304 |
+
|
| 305 |
+
from core.preview_generator import (
|
| 306 |
+
generate_typography_preview_html,
|
| 307 |
+
generate_colors_asis_preview_html,
|
| 308 |
+
generate_spacing_asis_preview_html,
|
| 309 |
+
generate_radius_asis_preview_html,
|
| 310 |
+
generate_shadows_asis_preview_html,
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
# Get detected font
|
| 314 |
+
fonts = get_detected_fonts()
|
| 315 |
+
primary_font = fonts.get("primary", "Open Sans")
|
| 316 |
+
|
| 317 |
+
# Convert typography tokens to dict format for preview
|
| 318 |
+
typo_dict = {}
|
| 319 |
+
for name, t in state.desktop_normalized.typography.items():
|
| 320 |
+
typo_dict[name] = {
|
| 321 |
+
"font_size": t.font_size,
|
| 322 |
+
"font_weight": t.font_weight,
|
| 323 |
+
"line_height": t.line_height or "1.5",
|
| 324 |
+
"letter_spacing": "0",
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
# Convert color tokens to dict format for preview (with full metadata)
|
| 328 |
+
color_dict = {}
|
| 329 |
+
for name, c in state.desktop_normalized.colors.items():
|
| 330 |
+
color_dict[name] = {
|
| 331 |
+
"value": c.value,
|
| 332 |
+
"frequency": c.frequency,
|
| 333 |
+
"contexts": c.contexts[:3] if c.contexts else [],
|
| 334 |
+
"contrast_white": c.contrast_white,
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
# Convert spacing tokens to dict format
|
| 338 |
+
spacing_dict = {}
|
| 339 |
+
for name, s in state.desktop_normalized.spacing.items():
|
| 340 |
+
spacing_dict[name] = {
|
| 341 |
+
"value": s.value,
|
| 342 |
+
"value_px": s.value_px,
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
# Convert radius tokens to dict format
|
| 346 |
+
radius_dict = {}
|
| 347 |
+
for name, r in state.desktop_normalized.radius.items():
|
| 348 |
+
radius_dict[name] = {"value": r.value}
|
| 349 |
+
|
| 350 |
+
# Convert shadow tokens to dict format
|
| 351 |
+
shadow_dict = {}
|
| 352 |
+
for name, s in state.desktop_normalized.shadows.items():
|
| 353 |
+
shadow_dict[name] = {"value": s.value}
|
| 354 |
+
|
| 355 |
+
# Generate AS-IS previews (Stage 1 - raw extracted values)
|
| 356 |
+
typography_preview_html = generate_typography_preview_html(
|
| 357 |
+
typography_tokens=typo_dict,
|
| 358 |
+
font_family=primary_font,
|
| 359 |
+
sample_text="The quick brown fox jumps over the lazy dog",
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
# AS-IS color preview (no ramps)
|
| 363 |
+
colors_asis_preview_html = generate_colors_asis_preview_html(
|
| 364 |
+
color_tokens=color_dict,
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
# AS-IS spacing preview
|
| 368 |
+
spacing_asis_preview_html = generate_spacing_asis_preview_html(
|
| 369 |
+
spacing_tokens=spacing_dict,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
# AS-IS radius preview
|
| 373 |
+
radius_asis_preview_html = generate_radius_asis_preview_html(
|
| 374 |
+
radius_tokens=radius_dict,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
# AS-IS shadows preview
|
| 378 |
+
shadows_asis_preview_html = generate_shadows_asis_preview_html(
|
| 379 |
+
shadow_tokens=shadow_dict,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
state.log(" β
Typography preview generated")
|
| 383 |
+
state.log(" β
Colors AS-IS preview generated (no ramps)")
|
| 384 |
+
state.log(" β
Spacing AS-IS preview generated")
|
| 385 |
+
state.log(" β
Radius AS-IS preview generated")
|
| 386 |
+
state.log(" β
Shadows AS-IS preview generated")
|
| 387 |
+
|
| 388 |
+
state.log("")
|
| 389 |
+
state.log("=" * 50)
|
| 390 |
+
state.log("β
EXTRACTION COMPLETE!")
|
| 391 |
+
state.log(f" Enhanced extraction captured:")
|
| 392 |
+
state.log(f" β’ {len(state.desktop_normalized.colors)} colors (DOM + CSS vars + SVG + inline)")
|
| 393 |
+
state.log(f" β’ {len(state.desktop_normalized.typography)} typography styles")
|
| 394 |
+
state.log(f" β’ {len(state.desktop_normalized.spacing)} spacing values")
|
| 395 |
+
state.log(f" β’ {len(state.desktop_normalized.radius)} radius values")
|
| 396 |
+
state.log(f" β’ {len(state.desktop_normalized.shadows)} shadow values")
|
| 397 |
+
state.log("=" * 50)
|
| 398 |
+
|
| 399 |
+
progress(1.0, desc="β
Complete!")
|
| 400 |
+
|
| 401 |
+
status = f"""## β
Extraction Complete!
|
| 402 |
+
|
| 403 |
+
| Viewport | Colors | Typography | Spacing | Radius | Shadows |
|
| 404 |
+
|----------|--------|------------|---------|--------|---------|
|
| 405 |
+
| Desktop | {len(state.desktop_normalized.colors)} | {len(state.desktop_normalized.typography)} | {len(state.desktop_normalized.spacing)} | {len(state.desktop_normalized.radius)} | {len(state.desktop_normalized.shadows)} |
|
| 406 |
+
| Mobile | {len(state.mobile_normalized.colors)} | {len(state.mobile_normalized.typography)} | {len(state.mobile_normalized.spacing)} | {len(state.mobile_normalized.radius)} | {len(state.mobile_normalized.shadows)} |
|
| 407 |
+
|
| 408 |
+
**Primary Font:** {primary_font}
|
| 409 |
+
|
| 410 |
+
**Enhanced Extraction:** DOM styles + CSS Variables + SVG colors + Inline styles + Stylesheets
|
| 411 |
+
|
| 412 |
+
**Next:** Review the tokens below. Accept or reject, then proceed to Stage 2.
|
| 413 |
+
"""
|
| 414 |
+
|
| 415 |
+
# Return all AS-IS previews
|
| 416 |
+
return (
|
| 417 |
+
status,
|
| 418 |
+
state.get_logs(),
|
| 419 |
+
desktop_data,
|
| 420 |
+
mobile_data,
|
| 421 |
+
typography_preview_html,
|
| 422 |
+
colors_asis_preview_html,
|
| 423 |
+
spacing_asis_preview_html,
|
| 424 |
+
radius_asis_preview_html,
|
| 425 |
+
shadows_asis_preview_html,
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
except Exception as e:
|
| 429 |
+
import traceback
|
| 430 |
+
state.log(f"β Error: {str(e)}")
|
| 431 |
+
state.log(traceback.format_exc())
|
| 432 |
+
return f"β Error: {str(e)}", state.get_logs(), None, None, "", "", "", "", ""
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def format_tokens_for_display(normalized) -> dict:
|
| 436 |
+
"""Format normalized tokens for Gradio display."""
|
| 437 |
+
if normalized is None:
|
| 438 |
+
return {"colors": [], "typography": [], "spacing": []}
|
| 439 |
+
|
| 440 |
+
# Colors are now a dict
|
| 441 |
+
colors = []
|
| 442 |
+
color_items = list(normalized.colors.values()) if isinstance(normalized.colors, dict) else normalized.colors
|
| 443 |
+
for c in sorted(color_items, key=lambda x: -x.frequency)[:50]:
|
| 444 |
+
colors.append([
|
| 445 |
+
True, # Accept checkbox
|
| 446 |
+
c.value,
|
| 447 |
+
c.suggested_name or "",
|
| 448 |
+
c.frequency,
|
| 449 |
+
c.confidence.value if c.confidence else "medium",
|
| 450 |
+
f"{c.contrast_white:.1f}:1" if c.contrast_white else "N/A",
|
| 451 |
+
"β" if c.wcag_aa_small_text else "β",
|
| 452 |
+
", ".join(c.contexts[:2]) if c.contexts else "",
|
| 453 |
+
])
|
| 454 |
+
|
| 455 |
+
# Typography
|
| 456 |
+
typography = []
|
| 457 |
+
typo_items = list(normalized.typography.values()) if isinstance(normalized.typography, dict) else normalized.typography
|
| 458 |
+
for t in sorted(typo_items, key=lambda x: -x.frequency)[:30]:
|
| 459 |
+
typography.append([
|
| 460 |
+
True, # Accept checkbox
|
| 461 |
+
t.font_family,
|
| 462 |
+
t.font_size,
|
| 463 |
+
str(t.font_weight),
|
| 464 |
+
t.line_height or "",
|
| 465 |
+
t.suggested_name or "",
|
| 466 |
+
t.frequency,
|
| 467 |
+
t.confidence.value if t.confidence else "medium",
|
| 468 |
+
])
|
| 469 |
+
|
| 470 |
+
# Spacing
|
| 471 |
+
spacing = []
|
| 472 |
+
spacing_items = list(normalized.spacing.values()) if isinstance(normalized.spacing, dict) else normalized.spacing
|
| 473 |
+
for s in sorted(spacing_items, key=lambda x: x.value_px)[:20]:
|
| 474 |
+
spacing.append([
|
| 475 |
+
True, # Accept checkbox
|
| 476 |
+
s.value,
|
| 477 |
+
f"{s.value_px}px",
|
| 478 |
+
s.suggested_name or "",
|
| 479 |
+
s.frequency,
|
| 480 |
+
"β" if s.fits_base_8 else "",
|
| 481 |
+
s.confidence.value if s.confidence else "medium",
|
| 482 |
+
])
|
| 483 |
+
|
| 484 |
+
return {
|
| 485 |
+
"colors": colors,
|
| 486 |
+
"typography": typography,
|
| 487 |
+
"spacing": spacing,
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def switch_viewport(viewport: str):
|
| 492 |
+
"""Switch between desktop and mobile view."""
|
| 493 |
+
if viewport == "Desktop (1440px)":
|
| 494 |
+
data = format_tokens_for_display(state.desktop_normalized)
|
| 495 |
+
else:
|
| 496 |
+
data = format_tokens_for_display(state.mobile_normalized)
|
| 497 |
+
|
| 498 |
+
return data["colors"], data["typography"], data["spacing"]
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
# =============================================================================
|
| 502 |
+
# STAGE 2: AI ANALYSIS (Multi-Agent)
|
| 503 |
+
# =============================================================================
|
| 504 |
+
|
| 505 |
+
async def run_stage2_analysis(competitors_str: str = "", progress=gr.Progress()):
|
| 506 |
+
"""Run multi-agent analysis on extracted tokens."""
|
| 507 |
+
|
| 508 |
+
if not state.desktop_normalized or not state.mobile_normalized:
|
| 509 |
+
return ("β Please complete Stage 1 first", "", "", "", None, None, None, "", "", "", "")
|
| 510 |
+
|
| 511 |
+
# Parse competitors from input
|
| 512 |
+
default_competitors = [
|
| 513 |
+
"Material Design 3",
|
| 514 |
+
"Apple Human Interface Guidelines",
|
| 515 |
+
"Shopify Polaris",
|
| 516 |
+
"IBM Carbon",
|
| 517 |
+
"Atlassian Design System"
|
| 518 |
+
]
|
| 519 |
+
|
| 520 |
+
if competitors_str and competitors_str.strip():
|
| 521 |
+
competitors = [c.strip() for c in competitors_str.split(",") if c.strip()]
|
| 522 |
+
else:
|
| 523 |
+
competitors = default_competitors
|
| 524 |
+
|
| 525 |
+
progress(0.05, desc="π€ Initializing multi-agent analysis...")
|
| 526 |
+
|
| 527 |
+
try:
|
| 528 |
+
# Import the multi-agent workflow
|
| 529 |
+
from agents.stage2_graph import run_stage2_multi_agent
|
| 530 |
+
|
| 531 |
+
# Convert normalized tokens to dict for the workflow
|
| 532 |
+
desktop_dict = normalized_to_dict(state.desktop_normalized)
|
| 533 |
+
mobile_dict = normalized_to_dict(state.mobile_normalized)
|
| 534 |
+
|
| 535 |
+
# Run multi-agent analysis
|
| 536 |
+
progress(0.1, desc="π Running parallel LLM analysis...")
|
| 537 |
+
|
| 538 |
+
result = await run_stage2_multi_agent(
|
| 539 |
+
desktop_tokens=desktop_dict,
|
| 540 |
+
mobile_tokens=mobile_dict,
|
| 541 |
+
competitors=competitors,
|
| 542 |
+
log_callback=state.log,
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
progress(0.8, desc="π Processing results...")
|
| 546 |
+
|
| 547 |
+
# Extract results
|
| 548 |
+
final_recs = result.get("final_recommendations", {})
|
| 549 |
+
llm1_analysis = result.get("llm1_analysis", {})
|
| 550 |
+
llm2_analysis = result.get("llm2_analysis", {})
|
| 551 |
+
rule_calculations = result.get("rule_calculations", {})
|
| 552 |
+
cost_tracking = result.get("cost_tracking", {})
|
| 553 |
+
|
| 554 |
+
# Store for later use
|
| 555 |
+
state.upgrade_recommendations = final_recs
|
| 556 |
+
state.multi_agent_result = result
|
| 557 |
+
|
| 558 |
+
# Get font info
|
| 559 |
+
fonts = get_detected_fonts()
|
| 560 |
+
base_size = get_base_font_size()
|
| 561 |
+
|
| 562 |
+
progress(0.9, desc="π Formatting results...")
|
| 563 |
+
|
| 564 |
+
# Build status markdown
|
| 565 |
+
status = build_analysis_status(final_recs, cost_tracking, result.get("errors", []))
|
| 566 |
+
|
| 567 |
+
# Format brand/competitor comparison from LLM analyses
|
| 568 |
+
brand_md = format_multi_agent_comparison(llm1_analysis, llm2_analysis, final_recs)
|
| 569 |
+
|
| 570 |
+
# Format font families display
|
| 571 |
+
font_families_md = format_font_families_display(fonts)
|
| 572 |
+
|
| 573 |
+
# Format typography with BOTH desktop and mobile
|
| 574 |
+
typography_desktop_data = format_typography_comparison_viewport(
|
| 575 |
+
state.desktop_normalized, base_size, "desktop"
|
| 576 |
+
)
|
| 577 |
+
typography_mobile_data = format_typography_comparison_viewport(
|
| 578 |
+
state.mobile_normalized, base_size, "mobile"
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
# Format spacing comparison table
|
| 582 |
+
spacing_data = format_spacing_comparison_from_rules(rule_calculations)
|
| 583 |
+
|
| 584 |
+
# Format color display: BASE colors + ramps separately
|
| 585 |
+
base_colors_md = format_base_colors()
|
| 586 |
+
color_ramps_md = format_color_ramps_from_rules(rule_calculations)
|
| 587 |
+
|
| 588 |
+
# Format radius display (with token suggestions)
|
| 589 |
+
radius_md = format_radius_with_tokens()
|
| 590 |
+
|
| 591 |
+
# Format shadows display (with token suggestions)
|
| 592 |
+
shadows_md = format_shadows_with_tokens()
|
| 593 |
+
|
| 594 |
+
# Generate visual previews for Stage 2
|
| 595 |
+
state.log("")
|
| 596 |
+
state.log("π¨ Generating visual previews...")
|
| 597 |
+
|
| 598 |
+
from core.preview_generator import generate_typography_preview_html, generate_color_ramps_preview_html
|
| 599 |
+
|
| 600 |
+
primary_font = fonts.get("primary", "Open Sans")
|
| 601 |
+
|
| 602 |
+
# Convert typography tokens to dict format for preview
|
| 603 |
+
typo_dict = {}
|
| 604 |
+
for name, t in state.desktop_normalized.typography.items():
|
| 605 |
+
typo_dict[name] = {
|
| 606 |
+
"font_size": t.font_size,
|
| 607 |
+
"font_weight": t.font_weight,
|
| 608 |
+
"line_height": t.line_height or "1.5",
|
| 609 |
+
"letter_spacing": "0",
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
# Convert color tokens to dict format for preview
|
| 613 |
+
color_dict = {}
|
| 614 |
+
for name, c in state.desktop_normalized.colors.items():
|
| 615 |
+
color_dict[name] = {"value": c.value}
|
| 616 |
+
|
| 617 |
+
typography_preview_html = generate_typography_preview_html(
|
| 618 |
+
typography_tokens=typo_dict,
|
| 619 |
+
font_family=primary_font,
|
| 620 |
+
sample_text="The quick brown fox jumps over the lazy dog",
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
color_ramps_preview_html = generate_color_ramps_preview_html(
|
| 624 |
+
color_tokens=color_dict,
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
state.log(" β
Visual previews generated")
|
| 628 |
+
|
| 629 |
+
progress(1.0, desc="β
Analysis complete!")
|
| 630 |
+
|
| 631 |
+
return (status, state.get_logs(), brand_md, font_families_md,
|
| 632 |
+
typography_desktop_data, typography_mobile_data, spacing_data,
|
| 633 |
+
base_colors_md, color_ramps_md, radius_md, shadows_md,
|
| 634 |
+
typography_preview_html, color_ramps_preview_html)
|
| 635 |
+
|
| 636 |
+
except Exception as e:
|
| 637 |
+
import traceback
|
| 638 |
+
state.log(f"β Error: {str(e)}")
|
| 639 |
+
state.log(traceback.format_exc())
|
| 640 |
+
return (f"β Analysis failed: {str(e)}", state.get_logs(), "", "", None, None, None, "", "", "", "", "", "")
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def normalized_to_dict(normalized) -> dict:
|
| 644 |
+
"""Convert NormalizedTokens to dict for workflow."""
|
| 645 |
+
if not normalized:
|
| 646 |
+
return {}
|
| 647 |
+
|
| 648 |
+
result = {
|
| 649 |
+
"colors": {},
|
| 650 |
+
"typography": {},
|
| 651 |
+
"spacing": {},
|
| 652 |
+
"radius": {},
|
| 653 |
+
"shadows": {},
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
# Colors
|
| 657 |
+
for name, c in normalized.colors.items():
|
| 658 |
+
result["colors"][name] = {
|
| 659 |
+
"value": c.value,
|
| 660 |
+
"frequency": c.frequency,
|
| 661 |
+
"suggested_name": c.suggested_name,
|
| 662 |
+
"contrast_white": c.contrast_white,
|
| 663 |
+
"contrast_black": c.contrast_black,
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
# Typography
|
| 667 |
+
for name, t in normalized.typography.items():
|
| 668 |
+
result["typography"][name] = {
|
| 669 |
+
"font_family": t.font_family,
|
| 670 |
+
"font_size": t.font_size,
|
| 671 |
+
"font_weight": t.font_weight,
|
| 672 |
+
"line_height": t.line_height,
|
| 673 |
+
"frequency": t.frequency,
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
# Spacing
|
| 677 |
+
for name, s in normalized.spacing.items():
|
| 678 |
+
result["spacing"][name] = {
|
| 679 |
+
"value": s.value,
|
| 680 |
+
"value_px": s.value_px,
|
| 681 |
+
"frequency": s.frequency,
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
# Radius
|
| 685 |
+
for name, r in normalized.radius.items():
|
| 686 |
+
result["radius"][name] = {
|
| 687 |
+
"value": r.value,
|
| 688 |
+
"frequency": r.frequency,
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
# Shadows
|
| 692 |
+
for name, s in normalized.shadows.items():
|
| 693 |
+
result["shadows"][name] = {
|
| 694 |
+
"value": s.value,
|
| 695 |
+
"frequency": s.frequency,
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
return result
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def build_analysis_status(final_recs: dict, cost_tracking: dict, errors: list) -> str:
|
| 702 |
+
"""Build status markdown from analysis results."""
|
| 703 |
+
|
| 704 |
+
lines = ["## π§ Multi-Agent Analysis Complete!"]
|
| 705 |
+
lines.append("")
|
| 706 |
+
|
| 707 |
+
# Cost summary
|
| 708 |
+
if cost_tracking:
|
| 709 |
+
total_cost = cost_tracking.get("total_cost", 0)
|
| 710 |
+
lines.append(f"### π° Cost Summary")
|
| 711 |
+
lines.append(f"**Total estimated cost:** ${total_cost:.4f}")
|
| 712 |
+
lines.append(f"*(Free tier: $0.10/mo | Pro: $2.00/mo)*")
|
| 713 |
+
lines.append("")
|
| 714 |
+
|
| 715 |
+
# Final recommendations
|
| 716 |
+
if final_recs and "final_recommendations" in final_recs:
|
| 717 |
+
recs = final_recs["final_recommendations"]
|
| 718 |
+
lines.append("### π Recommendations")
|
| 719 |
+
|
| 720 |
+
if recs.get("type_scale"):
|
| 721 |
+
lines.append(f"**Type Scale:** {recs['type_scale']}")
|
| 722 |
+
if recs.get("type_scale_rationale"):
|
| 723 |
+
lines.append(f" *{recs['type_scale_rationale'][:100]}*")
|
| 724 |
+
|
| 725 |
+
if recs.get("spacing_base"):
|
| 726 |
+
lines.append(f"**Spacing:** {recs['spacing_base']}")
|
| 727 |
+
|
| 728 |
+
lines.append("")
|
| 729 |
+
|
| 730 |
+
# Summary
|
| 731 |
+
if final_recs.get("summary"):
|
| 732 |
+
lines.append("### π Summary")
|
| 733 |
+
lines.append(final_recs["summary"])
|
| 734 |
+
lines.append("")
|
| 735 |
+
|
| 736 |
+
# Confidence
|
| 737 |
+
if final_recs.get("overall_confidence"):
|
| 738 |
+
lines.append(f"**Confidence:** {final_recs['overall_confidence']}%")
|
| 739 |
+
|
| 740 |
+
# Errors
|
| 741 |
+
if errors:
|
| 742 |
+
lines.append("")
|
| 743 |
+
lines.append("### β οΈ Warnings")
|
| 744 |
+
for err in errors[:3]:
|
| 745 |
+
lines.append(f"- {err[:100]}")
|
| 746 |
+
|
| 747 |
+
return "\n".join(lines)
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
def format_multi_agent_comparison(llm1: dict, llm2: dict, final: dict) -> str:
|
| 751 |
+
"""Format comparison from multi-agent analysis."""
|
| 752 |
+
|
| 753 |
+
lines = ["### π Multi-Agent Analysis Comparison"]
|
| 754 |
+
lines.append("")
|
| 755 |
+
|
| 756 |
+
# Agreements
|
| 757 |
+
if final.get("agreements"):
|
| 758 |
+
lines.append("#### β
Agreements (High Confidence)")
|
| 759 |
+
for a in final["agreements"][:5]:
|
| 760 |
+
topic = a.get("topic", "?")
|
| 761 |
+
finding = a.get("finding", "?")[:80]
|
| 762 |
+
lines.append(f"- **{topic}**: {finding}")
|
| 763 |
+
lines.append("")
|
| 764 |
+
|
| 765 |
+
# Disagreements and resolutions
|
| 766 |
+
if final.get("disagreements"):
|
| 767 |
+
lines.append("#### π Resolved Disagreements")
|
| 768 |
+
for d in final["disagreements"][:3]:
|
| 769 |
+
topic = d.get("topic", "?")
|
| 770 |
+
resolution = d.get("resolution", "?")[:100]
|
| 771 |
+
lines.append(f"- **{topic}**: {resolution}")
|
| 772 |
+
lines.append("")
|
| 773 |
+
|
| 774 |
+
# Score comparison
|
| 775 |
+
lines.append("#### π Score Comparison")
|
| 776 |
+
lines.append("")
|
| 777 |
+
lines.append("| Category | LLM 1 (Qwen) | LLM 2 (Llama) |")
|
| 778 |
+
lines.append("|----------|--------------|---------------|")
|
| 779 |
+
|
| 780 |
+
categories = ["typography", "colors", "accessibility", "spacing"]
|
| 781 |
+
for cat in categories:
|
| 782 |
+
llm1_score = llm1.get(cat, {}).get("score", "?") if isinstance(llm1.get(cat), dict) else "?"
|
| 783 |
+
llm2_score = llm2.get(cat, {}).get("score", "?") if isinstance(llm2.get(cat), dict) else "?"
|
| 784 |
+
lines.append(f"| {cat.title()} | {llm1_score}/10 | {llm2_score}/10 |")
|
| 785 |
+
|
| 786 |
+
return "\n".join(lines)
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def format_spacing_comparison_from_rules(rule_calculations: dict) -> list:
|
| 790 |
+
"""Format spacing comparison from rule engine."""
|
| 791 |
+
if not rule_calculations:
|
| 792 |
+
return []
|
| 793 |
+
|
| 794 |
+
spacing_options = rule_calculations.get("spacing_options", {})
|
| 795 |
+
|
| 796 |
+
data = []
|
| 797 |
+
for i in range(10):
|
| 798 |
+
current = f"{(i+1) * 4}px" if i < 5 else f"{(i+1) * 8}px"
|
| 799 |
+
grid_8 = spacing_options.get("8px", [])
|
| 800 |
+
grid_4 = spacing_options.get("4px", [])
|
| 801 |
+
|
| 802 |
+
val_8 = f"{grid_8[i+1]}px" if i+1 < len(grid_8) else "β"
|
| 803 |
+
val_4 = f"{grid_4[i+1]}px" if i+1 < len(grid_4) else "β"
|
| 804 |
+
|
| 805 |
+
data.append([current, val_8, val_4])
|
| 806 |
+
|
| 807 |
+
return data
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def format_color_ramps_from_rules(rule_calculations: dict) -> str:
|
| 811 |
+
"""Format color ramps from rule engine."""
|
| 812 |
+
if not rule_calculations:
|
| 813 |
+
return "*No color ramps generated*"
|
| 814 |
+
|
| 815 |
+
ramps = rule_calculations.get("color_ramps", {})
|
| 816 |
+
if not ramps:
|
| 817 |
+
return "*No color ramps generated*"
|
| 818 |
+
|
| 819 |
+
lines = ["### π Generated Color Ramps"]
|
| 820 |
+
lines.append("")
|
| 821 |
+
|
| 822 |
+
for name, ramp in list(ramps.items())[:6]:
|
| 823 |
+
lines.append(f"**{name}**")
|
| 824 |
+
if isinstance(ramp, list) and len(ramp) >= 10:
|
| 825 |
+
lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
|
| 826 |
+
lines.append("|---|---|---|---|---|---|---|---|---|---|")
|
| 827 |
+
row = "| " + " | ".join([f"`{ramp[i]}`" for i in range(10)]) + " |"
|
| 828 |
+
lines.append(row)
|
| 829 |
+
lines.append("")
|
| 830 |
+
|
| 831 |
+
return "\n".join(lines)
|
| 832 |
+
|
| 833 |
+
|
| 834 |
+
def get_detected_fonts() -> dict:
|
| 835 |
+
"""Get detected font information."""
|
| 836 |
+
if not state.desktop_normalized:
|
| 837 |
+
return {"primary": "Unknown", "weights": []}
|
| 838 |
+
|
| 839 |
+
fonts = {}
|
| 840 |
+
weights = set()
|
| 841 |
+
|
| 842 |
+
for t in state.desktop_normalized.typography.values():
|
| 843 |
+
family = t.font_family
|
| 844 |
+
weight = t.font_weight
|
| 845 |
+
|
| 846 |
+
if family not in fonts:
|
| 847 |
+
fonts[family] = 0
|
| 848 |
+
fonts[family] += t.frequency
|
| 849 |
+
|
| 850 |
+
if weight:
|
| 851 |
+
try:
|
| 852 |
+
weights.add(int(weight))
|
| 853 |
+
except:
|
| 854 |
+
pass
|
| 855 |
+
|
| 856 |
+
primary = max(fonts.items(), key=lambda x: x[1])[0] if fonts else "Unknown"
|
| 857 |
+
|
| 858 |
+
return {
|
| 859 |
+
"primary": primary,
|
| 860 |
+
"weights": sorted(weights) if weights else [400],
|
| 861 |
+
"all_fonts": fonts,
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
def get_base_font_size() -> int:
|
| 866 |
+
"""Detect base font size from typography."""
|
| 867 |
+
if not state.desktop_normalized:
|
| 868 |
+
return 16
|
| 869 |
+
|
| 870 |
+
# Find most common size in body range (14-18px)
|
| 871 |
+
sizes = {}
|
| 872 |
+
for t in state.desktop_normalized.typography.values():
|
| 873 |
+
size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
|
| 874 |
+
try:
|
| 875 |
+
size = float(size_str)
|
| 876 |
+
if 14 <= size <= 18:
|
| 877 |
+
sizes[size] = sizes.get(size, 0) + t.frequency
|
| 878 |
+
except:
|
| 879 |
+
pass
|
| 880 |
+
|
| 881 |
+
if sizes:
|
| 882 |
+
return int(max(sizes.items(), key=lambda x: x[1])[0])
|
| 883 |
+
return 16
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
def format_brand_comparison(recommendations) -> str:
|
| 887 |
+
"""Format brand comparison as markdown table."""
|
| 888 |
+
if not recommendations.brand_analysis:
|
| 889 |
+
return "*Brand analysis not available*"
|
| 890 |
+
|
| 891 |
+
lines = [
|
| 892 |
+
"### π Design System Comparison (5 Top Brands)",
|
| 893 |
+
"",
|
| 894 |
+
"| Brand | Type Ratio | Base Size | Spacing | Notes |",
|
| 895 |
+
"|-------|------------|-----------|---------|-------|",
|
| 896 |
+
]
|
| 897 |
+
|
| 898 |
+
for brand in recommendations.brand_analysis[:5]:
|
| 899 |
+
name = brand.get("brand", "Unknown")
|
| 900 |
+
ratio = brand.get("ratio", "?")
|
| 901 |
+
base = brand.get("base", "?")
|
| 902 |
+
spacing = brand.get("spacing", "?")
|
| 903 |
+
notes = brand.get("notes", "")[:50] + ("..." if len(brand.get("notes", "")) > 50 else "")
|
| 904 |
+
lines.append(f"| {name} | {ratio} | {base}px | {spacing} | {notes} |")
|
| 905 |
+
|
| 906 |
+
return "\n".join(lines)
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
def format_font_families_display(fonts: dict) -> str:
|
| 910 |
+
"""Format detected font families for display."""
|
| 911 |
+
lines = []
|
| 912 |
+
|
| 913 |
+
primary = fonts.get("primary", "Unknown")
|
| 914 |
+
weights = fonts.get("weights", [400])
|
| 915 |
+
all_fonts = fonts.get("all_fonts", {})
|
| 916 |
+
|
| 917 |
+
lines.append(f"### Primary Font: **{primary}**")
|
| 918 |
+
lines.append("")
|
| 919 |
+
lines.append(f"**Weights detected:** {', '.join(map(str, weights))}")
|
| 920 |
+
lines.append("")
|
| 921 |
+
|
| 922 |
+
if all_fonts and len(all_fonts) > 1:
|
| 923 |
+
lines.append("### All Fonts Detected")
|
| 924 |
+
lines.append("")
|
| 925 |
+
lines.append("| Font Family | Usage Count |")
|
| 926 |
+
lines.append("|-------------|-------------|")
|
| 927 |
+
|
| 928 |
+
sorted_fonts = sorted(all_fonts.items(), key=lambda x: -x[1])
|
| 929 |
+
for font, count in sorted_fonts[:5]:
|
| 930 |
+
lines.append(f"| {font} | {count:,} |")
|
| 931 |
+
|
| 932 |
+
lines.append("")
|
| 933 |
+
lines.append("*Note: This analysis focuses on English typography only.*")
|
| 934 |
+
|
| 935 |
+
return "\n".join(lines)
|
| 936 |
+
|
| 937 |
+
|
| 938 |
+
def format_typography_comparison_viewport(normalized_tokens, base_size: int, viewport: str) -> list:
|
| 939 |
+
"""Format typography comparison for a specific viewport."""
|
| 940 |
+
if not normalized_tokens:
|
| 941 |
+
return []
|
| 942 |
+
|
| 943 |
+
# Get current typography sorted by size
|
| 944 |
+
current_typo = list(normalized_tokens.typography.values())
|
| 945 |
+
|
| 946 |
+
# Parse and sort sizes
|
| 947 |
+
def parse_size(t):
|
| 948 |
+
size_str = str(t.font_size).replace('px', '').replace('rem', '').replace('em', '')
|
| 949 |
+
try:
|
| 950 |
+
return float(size_str)
|
| 951 |
+
except:
|
| 952 |
+
return 16
|
| 953 |
+
|
| 954 |
+
current_typo.sort(key=lambda t: -parse_size(t))
|
| 955 |
+
sizes = [parse_size(t) for t in current_typo]
|
| 956 |
+
|
| 957 |
+
# Use detected base or default
|
| 958 |
+
base = base_size if base_size else 16
|
| 959 |
+
|
| 960 |
+
# Scale factors for mobile (typically 0.85-0.9 of desktop)
|
| 961 |
+
mobile_factor = 0.875 if viewport == "mobile" else 1.0
|
| 962 |
+
|
| 963 |
+
# Token names (13 levels)
|
| 964 |
+
token_names = [
|
| 965 |
+
"display.2xl", "display.xl", "display.lg", "display.md",
|
| 966 |
+
"heading.xl", "heading.lg", "heading.md", "heading.sm",
|
| 967 |
+
"body.lg", "body.md", "body.sm",
|
| 968 |
+
"caption", "overline"
|
| 969 |
+
]
|
| 970 |
+
|
| 971 |
+
# Generate scales - use base size and round to sensible values
|
| 972 |
+
def round_to_even(val):
|
| 973 |
+
"""Round to even numbers for cleaner type scales."""
|
| 974 |
+
return int(round(val / 2) * 2)
|
| 975 |
+
|
| 976 |
+
scales = {
|
| 977 |
+
"1.2": [round_to_even(base * mobile_factor * (1.2 ** (8-i))) for i in range(13)],
|
| 978 |
+
"1.25": [round_to_even(base * mobile_factor * (1.25 ** (8-i))) for i in range(13)],
|
| 979 |
+
"1.333": [round_to_even(base * mobile_factor * (1.333 ** (8-i))) for i in range(13)],
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
# Build comparison table
|
| 983 |
+
data = []
|
| 984 |
+
for i, name in enumerate(token_names):
|
| 985 |
+
current = f"{int(sizes[i])}px" if i < len(sizes) else "β"
|
| 986 |
+
s12 = f"{scales['1.2'][i]}px"
|
| 987 |
+
s125 = f"{scales['1.25'][i]}px"
|
| 988 |
+
s133 = f"{scales['1.333'][i]}px"
|
| 989 |
+
keep = current
|
| 990 |
+
data.append([name, current, s12, s125, s133, keep])
|
| 991 |
+
|
| 992 |
+
return data
|
| 993 |
+
|
| 994 |
+
|
| 995 |
+
def format_base_colors() -> str:
|
| 996 |
+
"""Format base colors (detected) separately from ramps."""
|
| 997 |
+
if not state.desktop_normalized:
|
| 998 |
+
return "*No colors detected*"
|
| 999 |
+
|
| 1000 |
+
colors = list(state.desktop_normalized.colors.values())
|
| 1001 |
+
colors.sort(key=lambda c: -c.frequency)
|
| 1002 |
+
|
| 1003 |
+
lines = [
|
| 1004 |
+
"### π¨ Base Colors (Detected)",
|
| 1005 |
+
"",
|
| 1006 |
+
"These are the primary colors extracted from your website:",
|
| 1007 |
+
"",
|
| 1008 |
+
"| Color | Hex | Role | Frequency | Contrast |",
|
| 1009 |
+
"|-------|-----|------|-----------|----------|",
|
| 1010 |
+
]
|
| 1011 |
+
|
| 1012 |
+
for color in colors[:10]:
|
| 1013 |
+
hex_val = color.value
|
| 1014 |
+
role = "Primary" if color.suggested_name and "primary" in color.suggested_name.lower() else \
|
| 1015 |
+
"Text" if color.suggested_name and "text" in color.suggested_name.lower() else \
|
| 1016 |
+
"Background" if color.suggested_name and "background" in color.suggested_name.lower() else \
|
| 1017 |
+
"Border" if color.suggested_name and "border" in color.suggested_name.lower() else \
|
| 1018 |
+
"Accent"
|
| 1019 |
+
freq = f"{color.frequency:,}"
|
| 1020 |
+
contrast = f"{color.contrast_white:.1f}:1" if color.contrast_white else "β"
|
| 1021 |
+
|
| 1022 |
+
# Create a simple color indicator
|
| 1023 |
+
lines.append(f"| π¦ | `{hex_val}` | {role} | {freq} | {contrast} |")
|
| 1024 |
+
|
| 1025 |
+
return "\n".join(lines)
|
| 1026 |
+
|
| 1027 |
+
|
| 1028 |
+
def format_color_ramps_visual(recommendations) -> str:
|
| 1029 |
+
"""Format color ramps with visual display showing all shades."""
|
| 1030 |
+
if not state.desktop_normalized:
|
| 1031 |
+
return "*No colors to display*"
|
| 1032 |
+
|
| 1033 |
+
colors = list(state.desktop_normalized.colors.values())
|
| 1034 |
+
colors.sort(key=lambda c: -c.frequency)
|
| 1035 |
+
|
| 1036 |
+
lines = [
|
| 1037 |
+
"### π Generated Color Ramps",
|
| 1038 |
+
"",
|
| 1039 |
+
"Full ramp (50-950) generated for each base color:",
|
| 1040 |
+
"",
|
| 1041 |
+
]
|
| 1042 |
+
|
| 1043 |
+
from core.color_utils import generate_color_ramp
|
| 1044 |
+
|
| 1045 |
+
for color in colors[:6]: # Top 6 colors
|
| 1046 |
+
hex_val = color.value
|
| 1047 |
+
role = color.suggested_name.split('.')[1] if color.suggested_name and '.' in color.suggested_name else "color"
|
| 1048 |
+
|
| 1049 |
+
# Generate ramp
|
| 1050 |
+
try:
|
| 1051 |
+
ramp = generate_color_ramp(hex_val)
|
| 1052 |
+
|
| 1053 |
+
lines.append(f"**{role.upper()}** (base: `{hex_val}`)")
|
| 1054 |
+
lines.append("")
|
| 1055 |
+
lines.append("| 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 |")
|
| 1056 |
+
lines.append("|---|---|---|---|---|---|---|---|---|---|")
|
| 1057 |
+
|
| 1058 |
+
# Create row with hex values
|
| 1059 |
+
row = "|"
|
| 1060 |
+
for i in range(10):
|
| 1061 |
+
if i < len(ramp):
|
| 1062 |
+
row += f" `{ramp[i]}` |"
|
| 1063 |
+
else:
|
| 1064 |
+
row += " β |"
|
| 1065 |
+
lines.append(row)
|
| 1066 |
+
lines.append("")
|
| 1067 |
+
|
| 1068 |
+
except Exception as e:
|
| 1069 |
+
lines.append(f"**{role}** (`{hex_val}`) β Could not generate ramp: {str(e)}")
|
| 1070 |
+
lines.append("")
|
| 1071 |
+
|
| 1072 |
+
return "\n".join(lines)
|
| 1073 |
+
|
| 1074 |
+
|
| 1075 |
+
def format_radius_with_tokens() -> str:
|
| 1076 |
+
"""Format radius with token name suggestions."""
|
| 1077 |
+
if not state.desktop_normalized or not state.desktop_normalized.radius:
|
| 1078 |
+
return "*No border radius values detected.*"
|
| 1079 |
+
|
| 1080 |
+
radii = list(state.desktop_normalized.radius.values())
|
| 1081 |
+
|
| 1082 |
+
lines = [
|
| 1083 |
+
"### π Border Radius Tokens",
|
| 1084 |
+
"",
|
| 1085 |
+
"| Detected | Suggested Token | Usage |",
|
| 1086 |
+
"|----------|-----------------|-------|",
|
| 1087 |
+
]
|
| 1088 |
+
|
| 1089 |
+
# Sort by pixel value
|
| 1090 |
+
def parse_radius(r):
|
| 1091 |
+
val = str(r.value).replace('px', '').replace('%', '')
|
| 1092 |
+
try:
|
| 1093 |
+
return float(val)
|
| 1094 |
+
except:
|
| 1095 |
+
return 999
|
| 1096 |
+
|
| 1097 |
+
radii.sort(key=lambda r: parse_radius(r))
|
| 1098 |
+
|
| 1099 |
+
token_map = {
|
| 1100 |
+
(0, 2): ("radius.none", "Sharp corners"),
|
| 1101 |
+
(2, 4): ("radius.xs", "Subtle rounding"),
|
| 1102 |
+
(4, 6): ("radius.sm", "Small elements"),
|
| 1103 |
+
(6, 10): ("radius.md", "Buttons, cards"),
|
| 1104 |
+
(10, 16): ("radius.lg", "Modals, panels"),
|
| 1105 |
+
(16, 32): ("radius.xl", "Large containers"),
|
| 1106 |
+
(32, 100): ("radius.2xl", "Pill shapes"),
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
for r in radii[:8]:
|
| 1110 |
+
val = str(r.value)
|
| 1111 |
+
px = parse_radius(r)
|
| 1112 |
+
|
| 1113 |
+
if "%" in str(r.value) or px >= 50:
|
| 1114 |
+
token = "radius.full"
|
| 1115 |
+
usage = "Circles, avatars"
|
| 1116 |
+
else:
|
| 1117 |
+
token = "radius.md"
|
| 1118 |
+
usage = "General use"
|
| 1119 |
+
for (low, high), (t, u) in token_map.items():
|
| 1120 |
+
if low <= px < high:
|
| 1121 |
+
token = t
|
| 1122 |
+
usage = u
|
| 1123 |
+
break
|
| 1124 |
+
|
| 1125 |
+
lines.append(f"| {val} | `{token}` | {usage} |")
|
| 1126 |
+
|
| 1127 |
+
return "\n".join(lines)
|
| 1128 |
+
|
| 1129 |
+
|
| 1130 |
+
def format_shadows_with_tokens() -> str:
|
| 1131 |
+
"""Format shadows with token name suggestions."""
|
| 1132 |
+
if not state.desktop_normalized or not state.desktop_normalized.shadows:
|
| 1133 |
+
return "*No shadow values detected.*"
|
| 1134 |
+
|
| 1135 |
+
shadows = list(state.desktop_normalized.shadows.values())
|
| 1136 |
+
|
| 1137 |
+
lines = [
|
| 1138 |
+
"### π«οΈ Shadow Tokens",
|
| 1139 |
+
"",
|
| 1140 |
+
"| Detected Value | Suggested Token | Use Case |",
|
| 1141 |
+
"|----------------|-----------------|----------|",
|
| 1142 |
+
]
|
| 1143 |
+
|
| 1144 |
+
shadow_sizes = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
|
| 1145 |
+
|
| 1146 |
+
for i, s in enumerate(shadows[:6]):
|
| 1147 |
+
val = str(s.value)[:40] + ("..." if len(str(s.value)) > 40 else "")
|
| 1148 |
+
token = shadow_sizes[i] if i < len(shadow_sizes) else f"shadow.custom-{i}"
|
| 1149 |
+
|
| 1150 |
+
# Guess use case based on index
|
| 1151 |
+
use_cases = ["Subtle elevation", "Cards, dropdowns", "Modals, dialogs", "Popovers", "Floating elements", "Dramatic effect"]
|
| 1152 |
+
use = use_cases[i] if i < len(use_cases) else "Custom"
|
| 1153 |
+
|
| 1154 |
+
lines.append(f"| `{val}` | `{token}` | {use} |")
|
| 1155 |
+
|
| 1156 |
+
return "\n".join(lines)
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
def format_spacing_comparison(recommendations) -> list:
|
| 1160 |
+
"""Format spacing comparison table."""
|
| 1161 |
+
if not state.desktop_normalized:
|
| 1162 |
+
return []
|
| 1163 |
+
|
| 1164 |
+
# Get current spacing
|
| 1165 |
+
current_spacing = list(state.desktop_normalized.spacing.values())
|
| 1166 |
+
current_spacing.sort(key=lambda s: s.value_px)
|
| 1167 |
+
|
| 1168 |
+
data = []
|
| 1169 |
+
for s in current_spacing[:10]:
|
| 1170 |
+
current = f"{s.value_px}px"
|
| 1171 |
+
grid_8 = f"{snap_to_grid(s.value_px, 8)}px"
|
| 1172 |
+
grid_4 = f"{snap_to_grid(s.value_px, 4)}px"
|
| 1173 |
+
|
| 1174 |
+
# Mark if value fits
|
| 1175 |
+
if s.value_px == snap_to_grid(s.value_px, 8):
|
| 1176 |
+
grid_8 += " β"
|
| 1177 |
+
if s.value_px == snap_to_grid(s.value_px, 4):
|
| 1178 |
+
grid_4 += " β"
|
| 1179 |
+
|
| 1180 |
+
data.append([current, grid_8, grid_4])
|
| 1181 |
+
|
| 1182 |
+
return data
|
| 1183 |
+
|
| 1184 |
+
|
| 1185 |
+
def snap_to_grid(value: float, base: int) -> int:
|
| 1186 |
+
"""Snap value to grid."""
|
| 1187 |
+
return round(value / base) * base
|
| 1188 |
+
|
| 1189 |
+
|
| 1190 |
+
def apply_selected_upgrades(type_choice: str, spacing_choice: str, apply_ramps: bool):
|
| 1191 |
+
"""Apply selected upgrade options."""
|
| 1192 |
+
if not state.upgrade_recommendations:
|
| 1193 |
+
return "β Run analysis first", ""
|
| 1194 |
+
|
| 1195 |
+
state.log("β¨ Applying selected upgrades...")
|
| 1196 |
+
|
| 1197 |
+
# Store selections
|
| 1198 |
+
state.selected_upgrades = {
|
| 1199 |
+
"type_scale": type_choice,
|
| 1200 |
+
"spacing": spacing_choice,
|
| 1201 |
+
"color_ramps": apply_ramps,
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
+
state.log(f" Type Scale: {type_choice}")
|
| 1205 |
+
state.log(f" Spacing: {spacing_choice}")
|
| 1206 |
+
state.log(f" Color Ramps: {'Yes' if apply_ramps else 'No'}")
|
| 1207 |
+
|
| 1208 |
+
state.log("β
Upgrades applied! Proceed to Stage 3 for export.")
|
| 1209 |
+
|
| 1210 |
+
return "β
Upgrades applied! Proceed to Stage 3 to export.", state.get_logs()
|
| 1211 |
+
|
| 1212 |
+
|
| 1213 |
+
def export_stage1_json():
|
| 1214 |
+
"""Export Stage 1 tokens (as-is extraction) to JSON - FLAT structure for Figma Tokens Studio."""
|
| 1215 |
+
if not state.desktop_normalized:
|
| 1216 |
+
return json.dumps({"error": "No tokens extracted. Please run extraction first."}, indent=2)
|
| 1217 |
+
|
| 1218 |
+
# FLAT structure for Figma Tokens Studio compatibility
|
| 1219 |
+
result = {
|
| 1220 |
+
"metadata": {
|
| 1221 |
+
"source_url": state.base_url,
|
| 1222 |
+
"extracted_at": datetime.now().isoformat(),
|
| 1223 |
+
"version": "v1-stage1-as-is",
|
| 1224 |
+
"stage": "extraction",
|
| 1225 |
+
"description": "Raw extracted tokens before upgrades - Figma Tokens Studio compatible",
|
| 1226 |
+
},
|
| 1227 |
+
"fonts": {},
|
| 1228 |
+
"colors": {},
|
| 1229 |
+
"typography": {}, # FLAT: font.display.xl.desktop, font.display.xl.mobile
|
| 1230 |
+
"spacing": {}, # FLAT: space.1.desktop, space.1.mobile
|
| 1231 |
+
"radius": {},
|
| 1232 |
+
"shadows": {},
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
# =========================================================================
|
| 1236 |
+
# FONTS
|
| 1237 |
+
# =========================================================================
|
| 1238 |
+
fonts_info = get_detected_fonts()
|
| 1239 |
+
result["fonts"] = {
|
| 1240 |
+
"primary": fonts_info.get("primary", "Unknown"),
|
| 1241 |
+
"weights": fonts_info.get("weights", [400]),
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
+
# =========================================================================
|
| 1245 |
+
# COLORS (viewport-agnostic - same across devices)
|
| 1246 |
+
# =========================================================================
|
| 1247 |
+
if state.desktop_normalized and state.desktop_normalized.colors:
|
| 1248 |
+
for name, c in state.desktop_normalized.colors.items():
|
| 1249 |
+
# Use semantic name or create one from value
|
| 1250 |
+
base_name = c.suggested_name or name
|
| 1251 |
+
# Clean up the name for Figma compatibility
|
| 1252 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").lower()
|
| 1253 |
+
if not clean_name.startswith("color."):
|
| 1254 |
+
clean_name = f"color.{clean_name}"
|
| 1255 |
+
|
| 1256 |
+
result["colors"][clean_name] = {
|
| 1257 |
+
"value": c.value,
|
| 1258 |
+
"type": "color",
|
| 1259 |
+
"source": "detected",
|
| 1260 |
+
}
|
| 1261 |
+
|
| 1262 |
+
# =========================================================================
|
| 1263 |
+
# TYPOGRAPHY - FLAT structure with viewport suffix
|
| 1264 |
+
# =========================================================================
|
| 1265 |
+
# Desktop typography
|
| 1266 |
+
if state.desktop_normalized and state.desktop_normalized.typography:
|
| 1267 |
+
for name, t in state.desktop_normalized.typography.items():
|
| 1268 |
+
base_name = t.suggested_name or name
|
| 1269 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1270 |
+
if not clean_name.startswith("font."):
|
| 1271 |
+
clean_name = f"font.{clean_name}"
|
| 1272 |
+
|
| 1273 |
+
# Add .desktop suffix
|
| 1274 |
+
token_key = f"{clean_name}.desktop"
|
| 1275 |
+
|
| 1276 |
+
result["typography"][token_key] = {
|
| 1277 |
+
"value": t.font_size,
|
| 1278 |
+
"type": "dimension",
|
| 1279 |
+
"fontFamily": t.font_family,
|
| 1280 |
+
"fontWeight": str(t.font_weight),
|
| 1281 |
+
"lineHeight": t.line_height or "1.5",
|
| 1282 |
+
"source": "detected",
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
# Mobile typography
|
| 1286 |
+
if state.mobile_normalized and state.mobile_normalized.typography:
|
| 1287 |
+
for name, t in state.mobile_normalized.typography.items():
|
| 1288 |
+
base_name = t.suggested_name or name
|
| 1289 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1290 |
+
if not clean_name.startswith("font."):
|
| 1291 |
+
clean_name = f"font.{clean_name}"
|
| 1292 |
+
|
| 1293 |
+
# Add .mobile suffix
|
| 1294 |
+
token_key = f"{clean_name}.mobile"
|
| 1295 |
+
|
| 1296 |
+
result["typography"][token_key] = {
|
| 1297 |
+
"value": t.font_size,
|
| 1298 |
+
"type": "dimension",
|
| 1299 |
+
"fontFamily": t.font_family,
|
| 1300 |
+
"fontWeight": str(t.font_weight),
|
| 1301 |
+
"lineHeight": t.line_height or "1.5",
|
| 1302 |
+
"source": "detected",
|
| 1303 |
+
}
|
| 1304 |
+
|
| 1305 |
+
# =========================================================================
|
| 1306 |
+
# SPACING - FLAT structure with viewport suffix
|
| 1307 |
+
# =========================================================================
|
| 1308 |
+
# Desktop spacing
|
| 1309 |
+
if state.desktop_normalized and state.desktop_normalized.spacing:
|
| 1310 |
+
for name, s in state.desktop_normalized.spacing.items():
|
| 1311 |
+
base_name = s.suggested_name or name
|
| 1312 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1313 |
+
if not clean_name.startswith("space."):
|
| 1314 |
+
clean_name = f"space.{clean_name}"
|
| 1315 |
+
|
| 1316 |
+
# Add .desktop suffix
|
| 1317 |
+
token_key = f"{clean_name}.desktop"
|
| 1318 |
+
|
| 1319 |
+
result["spacing"][token_key] = {
|
| 1320 |
+
"value": s.value,
|
| 1321 |
+
"type": "dimension",
|
| 1322 |
+
"source": "detected",
|
| 1323 |
+
}
|
| 1324 |
+
|
| 1325 |
+
# Mobile spacing
|
| 1326 |
+
if state.mobile_normalized and state.mobile_normalized.spacing:
|
| 1327 |
+
for name, s in state.mobile_normalized.spacing.items():
|
| 1328 |
+
base_name = s.suggested_name or name
|
| 1329 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1330 |
+
if not clean_name.startswith("space."):
|
| 1331 |
+
clean_name = f"space.{clean_name}"
|
| 1332 |
+
|
| 1333 |
+
# Add .mobile suffix
|
| 1334 |
+
token_key = f"{clean_name}.mobile"
|
| 1335 |
+
|
| 1336 |
+
result["spacing"][token_key] = {
|
| 1337 |
+
"value": s.value,
|
| 1338 |
+
"type": "dimension",
|
| 1339 |
+
"source": "detected",
|
| 1340 |
+
}
|
| 1341 |
+
|
| 1342 |
+
# =========================================================================
|
| 1343 |
+
# RADIUS (viewport-agnostic)
|
| 1344 |
+
# =========================================================================
|
| 1345 |
+
if state.desktop_normalized and state.desktop_normalized.radius:
|
| 1346 |
+
for name, r in state.desktop_normalized.radius.items():
|
| 1347 |
+
clean_name = name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1348 |
+
if not clean_name.startswith("radius."):
|
| 1349 |
+
clean_name = f"radius.{clean_name}"
|
| 1350 |
+
|
| 1351 |
+
result["radius"][clean_name] = {
|
| 1352 |
+
"value": r.value,
|
| 1353 |
+
"type": "dimension",
|
| 1354 |
+
"source": "detected",
|
| 1355 |
+
}
|
| 1356 |
+
|
| 1357 |
+
# =========================================================================
|
| 1358 |
+
# SHADOWS (viewport-agnostic)
|
| 1359 |
+
# =========================================================================
|
| 1360 |
+
if state.desktop_normalized and state.desktop_normalized.shadows:
|
| 1361 |
+
for name, s in state.desktop_normalized.shadows.items():
|
| 1362 |
+
clean_name = name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1363 |
+
if not clean_name.startswith("shadow."):
|
| 1364 |
+
clean_name = f"shadow.{clean_name}"
|
| 1365 |
+
|
| 1366 |
+
result["shadows"][clean_name] = {
|
| 1367 |
+
"value": s.value,
|
| 1368 |
+
"type": "boxShadow",
|
| 1369 |
+
"source": "detected",
|
| 1370 |
+
}
|
| 1371 |
+
|
| 1372 |
+
return json.dumps(result, indent=2, default=str)
|
| 1373 |
+
|
| 1374 |
+
|
| 1375 |
+
def export_tokens_json():
|
| 1376 |
+
"""Export final tokens with selected upgrades applied - FLAT structure for Figma Tokens Studio."""
|
| 1377 |
+
if not state.desktop_normalized:
|
| 1378 |
+
return json.dumps({"error": "No tokens extracted. Please run extraction first."}, indent=2)
|
| 1379 |
+
|
| 1380 |
+
# Get selected upgrades
|
| 1381 |
+
upgrades = getattr(state, 'selected_upgrades', {})
|
| 1382 |
+
type_scale_choice = upgrades.get('type_scale', 'Keep Current')
|
| 1383 |
+
spacing_choice = upgrades.get('spacing', 'Keep Current')
|
| 1384 |
+
apply_ramps = upgrades.get('color_ramps', True)
|
| 1385 |
+
|
| 1386 |
+
# Determine ratio from choice
|
| 1387 |
+
ratio = None
|
| 1388 |
+
if "1.2" in type_scale_choice:
|
| 1389 |
+
ratio = 1.2
|
| 1390 |
+
elif "1.25" in type_scale_choice:
|
| 1391 |
+
ratio = 1.25
|
| 1392 |
+
elif "1.333" in type_scale_choice:
|
| 1393 |
+
ratio = 1.333
|
| 1394 |
+
|
| 1395 |
+
# Determine spacing base
|
| 1396 |
+
spacing_base = None
|
| 1397 |
+
if "8px" in spacing_choice:
|
| 1398 |
+
spacing_base = 8
|
| 1399 |
+
elif "4px" in spacing_choice:
|
| 1400 |
+
spacing_base = 4
|
| 1401 |
+
|
| 1402 |
+
# FLAT structure for Figma Tokens Studio compatibility
|
| 1403 |
+
result = {
|
| 1404 |
+
"metadata": {
|
| 1405 |
+
"source_url": state.base_url,
|
| 1406 |
+
"extracted_at": datetime.now().isoformat(),
|
| 1407 |
+
"version": "v2-upgraded",
|
| 1408 |
+
"stage": "final",
|
| 1409 |
+
"description": "Upgraded tokens - Figma Tokens Studio compatible",
|
| 1410 |
+
"upgrades_applied": {
|
| 1411 |
+
"type_scale": type_scale_choice,
|
| 1412 |
+
"spacing": spacing_choice,
|
| 1413 |
+
"color_ramps": apply_ramps,
|
| 1414 |
+
},
|
| 1415 |
+
},
|
| 1416 |
+
"fonts": {},
|
| 1417 |
+
"colors": {},
|
| 1418 |
+
"typography": {}, # FLAT: font.display.xl.desktop, font.display.xl.mobile
|
| 1419 |
+
"spacing": {}, # FLAT: space.1.desktop, space.1.mobile
|
| 1420 |
+
"radius": {},
|
| 1421 |
+
"shadows": {},
|
| 1422 |
+
}
|
| 1423 |
+
|
| 1424 |
+
# =========================================================================
|
| 1425 |
+
# FONTS
|
| 1426 |
+
# =========================================================================
|
| 1427 |
+
fonts_info = get_detected_fonts()
|
| 1428 |
+
result["fonts"] = {
|
| 1429 |
+
"primary": fonts_info.get("primary", "Unknown"),
|
| 1430 |
+
"weights": fonts_info.get("weights", [400]),
|
| 1431 |
+
}
|
| 1432 |
+
primary_font = fonts_info.get("primary", "sans-serif")
|
| 1433 |
+
|
| 1434 |
+
# =========================================================================
|
| 1435 |
+
# COLORS with optional ramps
|
| 1436 |
+
# =========================================================================
|
| 1437 |
+
if state.desktop_normalized and state.desktop_normalized.colors:
|
| 1438 |
+
from core.color_utils import generate_color_ramp
|
| 1439 |
+
|
| 1440 |
+
for name, c in state.desktop_normalized.colors.items():
|
| 1441 |
+
base_name = c.suggested_name or name
|
| 1442 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").lower()
|
| 1443 |
+
if not clean_name.startswith("color."):
|
| 1444 |
+
clean_name = f"color.{clean_name}"
|
| 1445 |
+
|
| 1446 |
+
if apply_ramps:
|
| 1447 |
+
# Generate full ramp (50-950)
|
| 1448 |
+
try:
|
| 1449 |
+
ramp = generate_color_ramp(c.value)
|
| 1450 |
+
shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"]
|
| 1451 |
+
for i, shade in enumerate(shades):
|
| 1452 |
+
if i < len(ramp):
|
| 1453 |
+
shade_key = f"{clean_name}.{shade}"
|
| 1454 |
+
result["colors"][shade_key] = {
|
| 1455 |
+
"value": ramp[i] if isinstance(ramp[i], str) else ramp[i].get("hex", c.value),
|
| 1456 |
+
"type": "color",
|
| 1457 |
+
"source": "upgraded" if shade != "500" else "detected",
|
| 1458 |
+
}
|
| 1459 |
+
except:
|
| 1460 |
+
result["colors"][clean_name] = {
|
| 1461 |
+
"value": c.value,
|
| 1462 |
+
"type": "color",
|
| 1463 |
+
"source": "detected",
|
| 1464 |
+
}
|
| 1465 |
+
else:
|
| 1466 |
+
result["colors"][clean_name] = {
|
| 1467 |
+
"value": c.value,
|
| 1468 |
+
"type": "color",
|
| 1469 |
+
"source": "detected",
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
# =========================================================================
|
| 1473 |
+
# TYPOGRAPHY - FLAT structure with viewport suffix
|
| 1474 |
+
# =========================================================================
|
| 1475 |
+
base_size = get_base_font_size()
|
| 1476 |
+
token_names = [
|
| 1477 |
+
"font.display.2xl", "font.display.xl", "font.display.lg", "font.display.md",
|
| 1478 |
+
"font.heading.xl", "font.heading.lg", "font.heading.md", "font.heading.sm",
|
| 1479 |
+
"font.body.lg", "font.body.md", "font.body.sm", "font.caption", "font.overline"
|
| 1480 |
+
]
|
| 1481 |
+
|
| 1482 |
+
# Desktop typography
|
| 1483 |
+
if ratio:
|
| 1484 |
+
# Apply type scale
|
| 1485 |
+
scales = [int(round(base_size * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
|
| 1486 |
+
for i, token_name in enumerate(token_names):
|
| 1487 |
+
desktop_key = f"{token_name}.desktop"
|
| 1488 |
+
result["typography"][desktop_key] = {
|
| 1489 |
+
"value": f"{scales[i]}px",
|
| 1490 |
+
"type": "dimension",
|
| 1491 |
+
"fontFamily": primary_font,
|
| 1492 |
+
"source": "upgraded",
|
| 1493 |
+
}
|
| 1494 |
+
elif state.desktop_normalized and state.desktop_normalized.typography:
|
| 1495 |
+
# Keep original with flat structure
|
| 1496 |
+
for name, t in state.desktop_normalized.typography.items():
|
| 1497 |
+
base_name = t.suggested_name or name
|
| 1498 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1499 |
+
if not clean_name.startswith("font."):
|
| 1500 |
+
clean_name = f"font.{clean_name}"
|
| 1501 |
+
|
| 1502 |
+
desktop_key = f"{clean_name}.desktop"
|
| 1503 |
+
result["typography"][desktop_key] = {
|
| 1504 |
+
"value": t.font_size,
|
| 1505 |
+
"type": "dimension",
|
| 1506 |
+
"fontFamily": t.font_family,
|
| 1507 |
+
"fontWeight": str(t.font_weight),
|
| 1508 |
+
"lineHeight": t.line_height or "1.5",
|
| 1509 |
+
"source": "detected",
|
| 1510 |
+
}
|
| 1511 |
+
|
| 1512 |
+
# Mobile typography
|
| 1513 |
+
if ratio:
|
| 1514 |
+
# Apply type scale with mobile factor
|
| 1515 |
+
mobile_factor = 0.875
|
| 1516 |
+
scales = [int(round(base_size * mobile_factor * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
|
| 1517 |
+
for i, token_name in enumerate(token_names):
|
| 1518 |
+
mobile_key = f"{token_name}.mobile"
|
| 1519 |
+
result["typography"][mobile_key] = {
|
| 1520 |
+
"value": f"{scales[i]}px",
|
| 1521 |
+
"type": "dimension",
|
| 1522 |
+
"fontFamily": primary_font,
|
| 1523 |
+
"source": "upgraded",
|
| 1524 |
+
}
|
| 1525 |
+
elif state.mobile_normalized and state.mobile_normalized.typography:
|
| 1526 |
+
for name, t in state.mobile_normalized.typography.items():
|
| 1527 |
+
base_name = t.suggested_name or name
|
| 1528 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1529 |
+
if not clean_name.startswith("font."):
|
| 1530 |
+
clean_name = f"font.{clean_name}"
|
| 1531 |
+
|
| 1532 |
+
mobile_key = f"{clean_name}.mobile"
|
| 1533 |
+
result["typography"][mobile_key] = {
|
| 1534 |
+
"value": t.font_size,
|
| 1535 |
+
"type": "dimension",
|
| 1536 |
+
"fontFamily": t.font_family,
|
| 1537 |
+
"fontWeight": str(t.font_weight),
|
| 1538 |
+
"lineHeight": t.line_height or "1.5",
|
| 1539 |
+
"source": "detected",
|
| 1540 |
+
}
|
| 1541 |
+
|
| 1542 |
+
# =========================================================================
|
| 1543 |
+
# SPACING - FLAT structure with viewport suffix
|
| 1544 |
+
# =========================================================================
|
| 1545 |
+
spacing_token_names = [
|
| 1546 |
+
"space.1", "space.2", "space.3", "space.4", "space.5",
|
| 1547 |
+
"space.6", "space.8", "space.10", "space.12", "space.16"
|
| 1548 |
+
]
|
| 1549 |
+
|
| 1550 |
+
if spacing_base:
|
| 1551 |
+
# Generate grid-aligned spacing for both viewports
|
| 1552 |
+
for i, token_name in enumerate(spacing_token_names):
|
| 1553 |
+
value = spacing_base * (i + 1)
|
| 1554 |
+
|
| 1555 |
+
# Desktop
|
| 1556 |
+
desktop_key = f"{token_name}.desktop"
|
| 1557 |
+
result["spacing"][desktop_key] = {
|
| 1558 |
+
"value": f"{value}px",
|
| 1559 |
+
"type": "dimension",
|
| 1560 |
+
"source": "upgraded",
|
| 1561 |
+
}
|
| 1562 |
+
|
| 1563 |
+
# Mobile (same values)
|
| 1564 |
+
mobile_key = f"{token_name}.mobile"
|
| 1565 |
+
result["spacing"][mobile_key] = {
|
| 1566 |
+
"value": f"{value}px",
|
| 1567 |
+
"type": "dimension",
|
| 1568 |
+
"source": "upgraded",
|
| 1569 |
+
}
|
| 1570 |
+
else:
|
| 1571 |
+
# Keep original with flat structure
|
| 1572 |
+
if state.desktop_normalized and state.desktop_normalized.spacing:
|
| 1573 |
+
for name, s in state.desktop_normalized.spacing.items():
|
| 1574 |
+
base_name = s.suggested_name or name
|
| 1575 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1576 |
+
if not clean_name.startswith("space."):
|
| 1577 |
+
clean_name = f"space.{clean_name}"
|
| 1578 |
+
|
| 1579 |
+
desktop_key = f"{clean_name}.desktop"
|
| 1580 |
+
result["spacing"][desktop_key] = {
|
| 1581 |
+
"value": s.value,
|
| 1582 |
+
"type": "dimension",
|
| 1583 |
+
"source": "detected",
|
| 1584 |
+
}
|
| 1585 |
+
|
| 1586 |
+
if state.mobile_normalized and state.mobile_normalized.spacing:
|
| 1587 |
+
for name, s in state.mobile_normalized.spacing.items():
|
| 1588 |
+
base_name = s.suggested_name or name
|
| 1589 |
+
clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1590 |
+
if not clean_name.startswith("space."):
|
| 1591 |
+
clean_name = f"space.{clean_name}"
|
| 1592 |
+
|
| 1593 |
+
mobile_key = f"{clean_name}.mobile"
|
| 1594 |
+
result["spacing"][mobile_key] = {
|
| 1595 |
+
"value": s.value,
|
| 1596 |
+
"type": "dimension",
|
| 1597 |
+
"source": "detected",
|
| 1598 |
+
}
|
| 1599 |
+
|
| 1600 |
+
# =========================================================================
|
| 1601 |
+
# RADIUS (viewport-agnostic)
|
| 1602 |
+
# =========================================================================
|
| 1603 |
+
if state.desktop_normalized and state.desktop_normalized.radius:
|
| 1604 |
+
for name, r in state.desktop_normalized.radius.items():
|
| 1605 |
+
clean_name = name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1606 |
+
if not clean_name.startswith("radius."):
|
| 1607 |
+
clean_name = f"radius.{clean_name}"
|
| 1608 |
+
|
| 1609 |
+
result["radius"][clean_name] = {
|
| 1610 |
+
"value": r.value,
|
| 1611 |
+
"type": "dimension",
|
| 1612 |
+
"source": "detected",
|
| 1613 |
+
}
|
| 1614 |
+
|
| 1615 |
+
# =========================================================================
|
| 1616 |
+
# SHADOWS (viewport-agnostic)
|
| 1617 |
+
# =========================================================================
|
| 1618 |
+
if state.desktop_normalized and state.desktop_normalized.shadows:
|
| 1619 |
+
for name, s in state.desktop_normalized.shadows.items():
|
| 1620 |
+
clean_name = name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
|
| 1621 |
+
if not clean_name.startswith("shadow."):
|
| 1622 |
+
clean_name = f"shadow.{clean_name}"
|
| 1623 |
+
|
| 1624 |
+
result["shadows"][clean_name] = {
|
| 1625 |
+
"value": s.value,
|
| 1626 |
+
"type": "boxShadow",
|
| 1627 |
+
"source": "detected",
|
| 1628 |
+
}
|
| 1629 |
+
|
| 1630 |
+
return json.dumps(result, indent=2, default=str)
|
| 1631 |
+
|
| 1632 |
+
|
| 1633 |
+
# =============================================================================
|
| 1634 |
+
# UI BUILDING
|
| 1635 |
+
# =============================================================================
|
| 1636 |
+
|
| 1637 |
+
def create_ui():
|
| 1638 |
+
"""Create the Gradio interface."""
|
| 1639 |
+
|
| 1640 |
+
with gr.Blocks(
|
| 1641 |
+
title="Design System Extractor v2",
|
| 1642 |
+
theme=gr.themes.Soft(),
|
| 1643 |
+
css="""
|
| 1644 |
+
.color-swatch { display: inline-block; width: 24px; height: 24px; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
|
| 1645 |
+
"""
|
| 1646 |
+
) as app:
|
| 1647 |
+
|
| 1648 |
+
gr.Markdown("""
|
| 1649 |
+
# π¨ Design System Extractor v2
|
| 1650 |
+
|
| 1651 |
+
**Reverse-engineer design systems from live websites.**
|
| 1652 |
+
|
| 1653 |
+
A semi-automated, human-in-the-loop system that extracts, normalizes, and upgrades design tokens.
|
| 1654 |
+
|
| 1655 |
+
---
|
| 1656 |
+
""")
|
| 1657 |
+
|
| 1658 |
+
# =================================================================
|
| 1659 |
+
# CONFIGURATION
|
| 1660 |
+
# =================================================================
|
| 1661 |
+
|
| 1662 |
+
with gr.Accordion("βοΈ Configuration", open=not bool(HF_TOKEN_FROM_ENV)):
|
| 1663 |
+
gr.Markdown("**HuggingFace Token** β Required for Stage 2 (AI upgrades)")
|
| 1664 |
+
with gr.Row():
|
| 1665 |
+
hf_token_input = gr.Textbox(
|
| 1666 |
+
label="HF Token", placeholder="hf_xxxx", type="password",
|
| 1667 |
+
scale=4, value=HF_TOKEN_FROM_ENV,
|
| 1668 |
+
)
|
| 1669 |
+
save_token_btn = gr.Button("πΎ Save", scale=1)
|
| 1670 |
+
token_status = gr.Markdown("β
Token loaded" if HF_TOKEN_FROM_ENV else "β³ Enter token")
|
| 1671 |
+
|
| 1672 |
+
def save_token(token):
|
| 1673 |
+
if token and len(token) > 10:
|
| 1674 |
+
os.environ["HF_TOKEN"] = token.strip()
|
| 1675 |
+
return "β
Token saved!"
|
| 1676 |
+
return "β Invalid token"
|
| 1677 |
+
|
| 1678 |
+
save_token_btn.click(save_token, [hf_token_input], [token_status])
|
| 1679 |
+
|
| 1680 |
+
# =================================================================
|
| 1681 |
+
# URL INPUT & PAGE DISCOVERY
|
| 1682 |
+
# =================================================================
|
| 1683 |
+
|
| 1684 |
+
with gr.Accordion("π Step 1: Discover Pages", open=True):
|
| 1685 |
+
gr.Markdown("Enter your website URL to discover pages for extraction.")
|
| 1686 |
+
|
| 1687 |
+
with gr.Row():
|
| 1688 |
+
url_input = gr.Textbox(label="Website URL", placeholder="https://example.com", scale=4)
|
| 1689 |
+
discover_btn = gr.Button("π Discover Pages", variant="primary", scale=1)
|
| 1690 |
+
|
| 1691 |
+
discover_status = gr.Markdown("")
|
| 1692 |
+
|
| 1693 |
+
with gr.Row():
|
| 1694 |
+
log_output = gr.Textbox(label="π Log", lines=8, interactive=False)
|
| 1695 |
+
|
| 1696 |
+
pages_table = gr.Dataframe(
|
| 1697 |
+
headers=["Select", "URL", "Title", "Type", "Status"],
|
| 1698 |
+
datatype=["bool", "str", "str", "str", "str"],
|
| 1699 |
+
label="Discovered Pages",
|
| 1700 |
+
interactive=True,
|
| 1701 |
+
visible=False,
|
| 1702 |
+
)
|
| 1703 |
+
|
| 1704 |
+
extract_btn = gr.Button("π Extract Tokens (Desktop + Mobile)", variant="primary", visible=False)
|
| 1705 |
+
|
| 1706 |
+
# =================================================================
|
| 1707 |
+
# STAGE 1: EXTRACTION REVIEW
|
| 1708 |
+
# =================================================================
|
| 1709 |
+
|
| 1710 |
+
with gr.Accordion("π Stage 1: Review Extracted Tokens", open=False) as stage1_accordion:
|
| 1711 |
+
|
| 1712 |
+
extraction_status = gr.Markdown("")
|
| 1713 |
+
|
| 1714 |
+
gr.Markdown("""
|
| 1715 |
+
**Review the extracted tokens.** Toggle between Desktop and Mobile viewports.
|
| 1716 |
+
Accept or reject tokens, then proceed to Stage 2 for AI-powered upgrades.
|
| 1717 |
+
""")
|
| 1718 |
+
|
| 1719 |
+
viewport_toggle = gr.Radio(
|
| 1720 |
+
choices=["Desktop (1440px)", "Mobile (375px)"],
|
| 1721 |
+
value="Desktop (1440px)",
|
| 1722 |
+
label="Viewport",
|
| 1723 |
+
)
|
| 1724 |
+
|
| 1725 |
+
with gr.Tabs():
|
| 1726 |
+
with gr.Tab("π¨ Colors"):
|
| 1727 |
+
colors_table = gr.Dataframe(
|
| 1728 |
+
headers=["Accept", "Color", "Suggested Name", "Frequency", "Confidence", "Contrast", "AA", "Context"],
|
| 1729 |
+
datatype=["bool", "str", "str", "number", "str", "str", "str", "str"],
|
| 1730 |
+
label="Colors",
|
| 1731 |
+
interactive=True,
|
| 1732 |
+
)
|
| 1733 |
+
|
| 1734 |
+
with gr.Tab("π Typography"):
|
| 1735 |
+
typography_table = gr.Dataframe(
|
| 1736 |
+
headers=["Accept", "Font", "Size", "Weight", "Line Height", "Suggested Name", "Frequency", "Confidence"],
|
| 1737 |
+
datatype=["bool", "str", "str", "str", "str", "str", "number", "str"],
|
| 1738 |
+
label="Typography",
|
| 1739 |
+
interactive=True,
|
| 1740 |
+
)
|
| 1741 |
+
|
| 1742 |
+
with gr.Tab("π Spacing"):
|
| 1743 |
+
spacing_table = gr.Dataframe(
|
| 1744 |
+
headers=["Accept", "Value", "Pixels", "Suggested Name", "Frequency", "Base 8", "Confidence"],
|
| 1745 |
+
datatype=["bool", "str", "str", "str", "number", "str", "str"],
|
| 1746 |
+
label="Spacing",
|
| 1747 |
+
interactive=True,
|
| 1748 |
+
)
|
| 1749 |
+
|
| 1750 |
+
with gr.Tab("π Radius"):
|
| 1751 |
+
radius_table = gr.Dataframe(
|
| 1752 |
+
headers=["Accept", "Value", "Frequency", "Context"],
|
| 1753 |
+
datatype=["bool", "str", "number", "str"],
|
| 1754 |
+
label="Border Radius",
|
| 1755 |
+
interactive=True,
|
| 1756 |
+
)
|
| 1757 |
+
|
| 1758 |
+
# =============================================================
|
| 1759 |
+
# VISUAL PREVIEWS (Stage 1) - AS-IS only, no enhancements
|
| 1760 |
+
# =============================================================
|
| 1761 |
+
gr.Markdown("---")
|
| 1762 |
+
gr.Markdown("## ποΈ Visual Previews (AS-IS)")
|
| 1763 |
+
gr.Markdown("*Raw extracted values from the website β no enhancements applied*")
|
| 1764 |
+
|
| 1765 |
+
with gr.Tabs():
|
| 1766 |
+
with gr.Tab("π€ Typography"):
|
| 1767 |
+
gr.Markdown("*Actual typography rendered with the detected font*")
|
| 1768 |
+
stage1_typography_preview = gr.HTML(
|
| 1769 |
+
value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Typography preview will appear after extraction...</div>",
|
| 1770 |
+
label="Typography Preview"
|
| 1771 |
+
)
|
| 1772 |
+
|
| 1773 |
+
with gr.Tab("π¨ Colors"):
|
| 1774 |
+
gr.Markdown("*All detected colors (AS-IS β no generated ramps)*")
|
| 1775 |
+
stage1_colors_preview = gr.HTML(
|
| 1776 |
+
value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Colors preview will appear after extraction...</div>",
|
| 1777 |
+
label="Colors Preview"
|
| 1778 |
+
)
|
| 1779 |
+
|
| 1780 |
+
with gr.Tab("π Spacing"):
|
| 1781 |
+
gr.Markdown("*All detected spacing values*")
|
| 1782 |
+
stage1_spacing_preview = gr.HTML(
|
| 1783 |
+
value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Spacing preview will appear after extraction...</div>",
|
| 1784 |
+
label="Spacing Preview"
|
| 1785 |
+
)
|
| 1786 |
+
|
| 1787 |
+
with gr.Tab("π Radius"):
|
| 1788 |
+
gr.Markdown("*All detected border radius values*")
|
| 1789 |
+
stage1_radius_preview = gr.HTML(
|
| 1790 |
+
value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Radius preview will appear after extraction...</div>",
|
| 1791 |
+
label="Radius Preview"
|
| 1792 |
+
)
|
| 1793 |
+
|
| 1794 |
+
with gr.Tab("π Shadows"):
|
| 1795 |
+
gr.Markdown("*All detected box shadow values*")
|
| 1796 |
+
stage1_shadows_preview = gr.HTML(
|
| 1797 |
+
value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Shadows preview will appear after extraction...</div>",
|
| 1798 |
+
label="Shadows Preview"
|
| 1799 |
+
)
|
| 1800 |
+
|
| 1801 |
+
with gr.Row():
|
| 1802 |
+
proceed_stage2_btn = gr.Button("β‘οΈ Proceed to Stage 2: AI Upgrades", variant="primary")
|
| 1803 |
+
download_stage1_btn = gr.Button("π₯ Download Stage 1 JSON", variant="secondary")
|
| 1804 |
+
|
| 1805 |
+
# =================================================================
|
| 1806 |
+
# STAGE 2: AI UPGRADES
|
| 1807 |
+
# =================================================================
|
| 1808 |
+
|
| 1809 |
+
with gr.Accordion("π§ Stage 2: AI-Powered Upgrades", open=False) as stage2_accordion:
|
| 1810 |
+
|
| 1811 |
+
stage2_status = gr.Markdown("Click 'Analyze' to start AI-powered design system analysis.")
|
| 1812 |
+
|
| 1813 |
+
# =============================================================
|
| 1814 |
+
# LLM CONFIGURATION & COMPETITORS
|
| 1815 |
+
# =============================================================
|
| 1816 |
+
with gr.Accordion("βοΈ Analysis Configuration", open=False):
|
| 1817 |
+
gr.Markdown("""
|
| 1818 |
+
### π€ LLM Models Used
|
| 1819 |
+
|
| 1820 |
+
| Role | Model | Expertise |
|
| 1821 |
+
|------|-------|-----------|
|
| 1822 |
+
| **Typography Analyst** | meta-llama/Llama-3.1-70B | Type scale patterns, readability |
|
| 1823 |
+
| **Color Analyst** | meta-llama/Llama-3.1-70B | Color theory, accessibility |
|
| 1824 |
+
| **Spacing Analyst** | Rule-based | Grid alignment, consistency |
|
| 1825 |
+
|
| 1826 |
+
*Analysis compares your design against industry leaders.*
|
| 1827 |
+
""")
|
| 1828 |
+
|
| 1829 |
+
gr.Markdown("### π― Competitor Design Systems")
|
| 1830 |
+
gr.Markdown("Enter design systems to compare against (comma-separated):")
|
| 1831 |
+
competitors_input = gr.Textbox(
|
| 1832 |
+
value="Material Design 3, Apple HIG, Shopify Polaris, IBM Carbon, Atlassian",
|
| 1833 |
+
label="Competitors",
|
| 1834 |
+
placeholder="Material Design 3, Apple HIG, Shopify Polaris...",
|
| 1835 |
+
)
|
| 1836 |
+
gr.Markdown("*Suggestions: Ant Design, Chakra UI, Tailwind, Bootstrap, Salesforce Lightning*")
|
| 1837 |
+
|
| 1838 |
+
analyze_btn = gr.Button("π€ Analyze Design System", variant="primary", size="lg")
|
| 1839 |
+
|
| 1840 |
+
with gr.Accordion("π AI Analysis Log", open=True):
|
| 1841 |
+
stage2_log = gr.Textbox(label="Log", lines=18, interactive=False)
|
| 1842 |
+
|
| 1843 |
+
# =============================================================
|
| 1844 |
+
# BRAND COMPARISON (LLM Research)
|
| 1845 |
+
# =============================================================
|
| 1846 |
+
gr.Markdown("---")
|
| 1847 |
+
brand_comparison = gr.Markdown("*Brand comparison will appear after analysis*")
|
| 1848 |
+
|
| 1849 |
+
# =============================================================
|
| 1850 |
+
# FONT FAMILIES DETECTED
|
| 1851 |
+
# =============================================================
|
| 1852 |
+
gr.Markdown("---")
|
| 1853 |
+
gr.Markdown("## π€ Font Families Detected")
|
| 1854 |
+
font_families_display = gr.Markdown("*Font information will appear after analysis*")
|
| 1855 |
+
|
| 1856 |
+
# =============================================================
|
| 1857 |
+
# TYPOGRAPHY SECTION - Desktop & Mobile
|
| 1858 |
+
# =============================================================
|
| 1859 |
+
gr.Markdown("---")
|
| 1860 |
+
gr.Markdown("## π Typography")
|
| 1861 |
+
|
| 1862 |
+
# Visual Preview
|
| 1863 |
+
with gr.Accordion("ποΈ Typography Visual Preview", open=True):
|
| 1864 |
+
stage2_typography_preview = gr.HTML(
|
| 1865 |
+
value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Typography preview will appear after analysis...</div>",
|
| 1866 |
+
label="Typography Preview"
|
| 1867 |
+
)
|
| 1868 |
+
|
| 1869 |
+
with gr.Row():
|
| 1870 |
+
with gr.Column(scale=2):
|
| 1871 |
+
gr.Markdown("### π₯οΈ Desktop (1440px)")
|
| 1872 |
+
typography_desktop = gr.Dataframe(
|
| 1873 |
+
headers=["Token", "Current", "Scale 1.2", "Scale 1.25 β", "Scale 1.333", "Keep"],
|
| 1874 |
+
datatype=["str", "str", "str", "str", "str", "str"],
|
| 1875 |
+
label="Desktop Typography",
|
| 1876 |
+
interactive=False,
|
| 1877 |
+
)
|
| 1878 |
+
|
| 1879 |
+
with gr.Column(scale=2):
|
| 1880 |
+
gr.Markdown("### π± Mobile (375px)")
|
| 1881 |
+
typography_mobile = gr.Dataframe(
|
| 1882 |
+
headers=["Token", "Current", "Scale 1.2", "Scale 1.25 β", "Scale 1.333", "Keep"],
|
| 1883 |
+
datatype=["str", "str", "str", "str", "str", "str"],
|
| 1884 |
+
label="Mobile Typography",
|
| 1885 |
+
interactive=False,
|
| 1886 |
+
)
|
| 1887 |
+
|
| 1888 |
+
with gr.Row():
|
| 1889 |
+
with gr.Column():
|
| 1890 |
+
gr.Markdown("### Select Type Scale Option")
|
| 1891 |
+
type_scale_radio = gr.Radio(
|
| 1892 |
+
choices=["Keep Current", "Scale 1.2 (Minor Third)", "Scale 1.25 (Major Third) β", "Scale 1.333 (Perfect Fourth)"],
|
| 1893 |
+
value="Scale 1.25 (Major Third) β",
|
| 1894 |
+
label="Type Scale",
|
| 1895 |
+
interactive=True,
|
| 1896 |
+
)
|
| 1897 |
+
gr.Markdown("*Font family will be preserved. Sizes rounded to even numbers.*")
|
| 1898 |
+
|
| 1899 |
+
# =============================================================
|
| 1900 |
+
# COLORS SECTION - Base Colors + Ramps
|
| 1901 |
+
# =============================================================
|
| 1902 |
+
gr.Markdown("---")
|
| 1903 |
+
gr.Markdown("## π¨ Colors")
|
| 1904 |
+
|
| 1905 |
+
# Visual Preview
|
| 1906 |
+
with gr.Accordion("ποΈ Color Ramps Visual Preview", open=True):
|
| 1907 |
+
stage2_color_ramps_preview = gr.HTML(
|
| 1908 |
+
value="<div style='padding: 20px; background: #f5f5f5; border-radius: 8px; color: #666;'>Color ramps preview will appear after analysis...</div>",
|
| 1909 |
+
label="Color Ramps Preview"
|
| 1910 |
+
)
|
| 1911 |
+
|
| 1912 |
+
base_colors_display = gr.Markdown("*Base colors will appear after analysis*")
|
| 1913 |
+
|
| 1914 |
+
gr.Markdown("---")
|
| 1915 |
+
|
| 1916 |
+
color_ramps_display = gr.Markdown("*Color ramps will appear after analysis*")
|
| 1917 |
+
|
| 1918 |
+
color_ramps_checkbox = gr.Checkbox(
|
| 1919 |
+
label="β Generate color ramps (keeps base colors, adds 50-950 shades)",
|
| 1920 |
+
value=True,
|
| 1921 |
+
)
|
| 1922 |
+
|
| 1923 |
+
# =============================================================
|
| 1924 |
+
# SPACING SECTION
|
| 1925 |
+
# =============================================================
|
| 1926 |
+
gr.Markdown("---")
|
| 1927 |
+
gr.Markdown("## π Spacing (Rule-Based)")
|
| 1928 |
+
|
| 1929 |
+
with gr.Row():
|
| 1930 |
+
with gr.Column(scale=2):
|
| 1931 |
+
spacing_comparison = gr.Dataframe(
|
| 1932 |
+
headers=["Current", "8px Grid", "4px Grid"],
|
| 1933 |
+
datatype=["str", "str", "str"],
|
| 1934 |
+
label="Spacing Comparison",
|
| 1935 |
+
interactive=False,
|
| 1936 |
+
)
|
| 1937 |
+
|
| 1938 |
+
with gr.Column(scale=1):
|
| 1939 |
+
spacing_radio = gr.Radio(
|
| 1940 |
+
choices=["Keep Current", "8px Base Grid β", "4px Base Grid"],
|
| 1941 |
+
value="8px Base Grid β",
|
| 1942 |
+
label="Spacing System",
|
| 1943 |
+
interactive=True,
|
| 1944 |
+
)
|
| 1945 |
+
|
| 1946 |
+
# =============================================================
|
| 1947 |
+
# RADIUS SECTION
|
| 1948 |
+
# =============================================================
|
| 1949 |
+
gr.Markdown("---")
|
| 1950 |
+
gr.Markdown("## π Border Radius (Rule-Based)")
|
| 1951 |
+
|
| 1952 |
+
radius_display = gr.Markdown("*Radius tokens will appear after analysis*")
|
| 1953 |
+
|
| 1954 |
+
# =============================================================
|
| 1955 |
+
# SHADOWS SECTION
|
| 1956 |
+
# =============================================================
|
| 1957 |
+
gr.Markdown("---")
|
| 1958 |
+
gr.Markdown("## π«οΈ Shadows (Rule-Based)")
|
| 1959 |
+
|
| 1960 |
+
shadows_display = gr.Markdown("*Shadow tokens will appear after analysis*")
|
| 1961 |
+
|
| 1962 |
+
# =============================================================
|
| 1963 |
+
# APPLY SECTION
|
| 1964 |
+
# =============================================================
|
| 1965 |
+
gr.Markdown("---")
|
| 1966 |
+
|
| 1967 |
+
with gr.Row():
|
| 1968 |
+
apply_upgrades_btn = gr.Button("β¨ Apply Selected Upgrades", variant="primary", scale=2)
|
| 1969 |
+
reset_btn = gr.Button("β©οΈ Reset to Original", variant="secondary", scale=1)
|
| 1970 |
+
|
| 1971 |
+
apply_status = gr.Markdown("")
|
| 1972 |
+
|
| 1973 |
+
# =================================================================
|
| 1974 |
+
# STAGE 3: EXPORT
|
| 1975 |
+
# =================================================================
|
| 1976 |
+
|
| 1977 |
+
with gr.Accordion("π¦ Stage 3: Export", open=False):
|
| 1978 |
+
gr.Markdown("""
|
| 1979 |
+
Export your design tokens to JSON (compatible with Figma Tokens Studio).
|
| 1980 |
+
|
| 1981 |
+
- **Stage 1 JSON**: Raw extracted tokens (as-is)
|
| 1982 |
+
- **Final JSON**: Upgraded tokens with selected improvements
|
| 1983 |
+
""")
|
| 1984 |
+
|
| 1985 |
+
with gr.Row():
|
| 1986 |
+
export_stage1_btn = gr.Button("π₯ Export Stage 1 (As-Is)", variant="secondary")
|
| 1987 |
+
export_final_btn = gr.Button("π₯ Export Final (Upgraded)", variant="primary")
|
| 1988 |
+
|
| 1989 |
+
export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
|
| 1990 |
+
|
| 1991 |
+
export_stage1_btn.click(export_stage1_json, outputs=[export_output])
|
| 1992 |
+
export_final_btn.click(export_tokens_json, outputs=[export_output])
|
| 1993 |
+
|
| 1994 |
+
# =================================================================
|
| 1995 |
+
# EVENT HANDLERS
|
| 1996 |
+
# =================================================================
|
| 1997 |
+
|
| 1998 |
+
# Store data for viewport toggle
|
| 1999 |
+
desktop_data = gr.State({})
|
| 2000 |
+
mobile_data = gr.State({})
|
| 2001 |
+
|
| 2002 |
+
# Discover pages
|
| 2003 |
+
discover_btn.click(
|
| 2004 |
+
fn=discover_pages,
|
| 2005 |
+
inputs=[url_input],
|
| 2006 |
+
outputs=[discover_status, log_output, pages_table],
|
| 2007 |
+
).then(
|
| 2008 |
+
fn=lambda: (gr.update(visible=True), gr.update(visible=True)),
|
| 2009 |
+
outputs=[pages_table, extract_btn],
|
| 2010 |
+
)
|
| 2011 |
+
|
| 2012 |
+
# Extract tokens
|
| 2013 |
+
extract_btn.click(
|
| 2014 |
+
fn=extract_tokens,
|
| 2015 |
+
inputs=[pages_table],
|
| 2016 |
+
outputs=[extraction_status, log_output, desktop_data, mobile_data,
|
| 2017 |
+
stage1_typography_preview, stage1_colors_preview,
|
| 2018 |
+
stage1_spacing_preview, stage1_radius_preview, stage1_shadows_preview],
|
| 2019 |
+
).then(
|
| 2020 |
+
fn=lambda d: (d.get("colors", []), d.get("typography", []), d.get("spacing", [])),
|
| 2021 |
+
inputs=[desktop_data],
|
| 2022 |
+
outputs=[colors_table, typography_table, spacing_table],
|
| 2023 |
+
).then(
|
| 2024 |
+
fn=lambda: gr.update(open=True),
|
| 2025 |
+
outputs=[stage1_accordion],
|
| 2026 |
+
)
|
| 2027 |
+
|
| 2028 |
+
# Viewport toggle
|
| 2029 |
+
viewport_toggle.change(
|
| 2030 |
+
fn=switch_viewport,
|
| 2031 |
+
inputs=[viewport_toggle],
|
| 2032 |
+
outputs=[colors_table, typography_table, spacing_table],
|
| 2033 |
+
)
|
| 2034 |
+
|
| 2035 |
+
# Stage 2: Analyze
|
| 2036 |
+
analyze_btn.click(
|
| 2037 |
+
fn=run_stage2_analysis,
|
| 2038 |
+
inputs=[competitors_input],
|
| 2039 |
+
outputs=[stage2_status, stage2_log, brand_comparison, font_families_display,
|
| 2040 |
+
typography_desktop, typography_mobile, spacing_comparison,
|
| 2041 |
+
base_colors_display, color_ramps_display, radius_display, shadows_display,
|
| 2042 |
+
stage2_typography_preview, stage2_color_ramps_preview],
|
| 2043 |
+
)
|
| 2044 |
+
|
| 2045 |
+
# Stage 2: Apply upgrades
|
| 2046 |
+
apply_upgrades_btn.click(
|
| 2047 |
+
fn=apply_selected_upgrades,
|
| 2048 |
+
inputs=[type_scale_radio, spacing_radio, color_ramps_checkbox],
|
| 2049 |
+
outputs=[apply_status, stage2_log],
|
| 2050 |
+
)
|
| 2051 |
+
|
| 2052 |
+
# Stage 1: Download JSON
|
| 2053 |
+
download_stage1_btn.click(
|
| 2054 |
+
fn=export_stage1_json,
|
| 2055 |
+
outputs=[export_output],
|
| 2056 |
+
)
|
| 2057 |
+
|
| 2058 |
+
# Proceed to Stage 2 button
|
| 2059 |
+
proceed_stage2_btn.click(
|
| 2060 |
+
fn=lambda: gr.update(open=True),
|
| 2061 |
+
outputs=[stage2_accordion],
|
| 2062 |
+
)
|
| 2063 |
+
|
| 2064 |
+
# =================================================================
|
| 2065 |
+
# FOOTER
|
| 2066 |
+
# =================================================================
|
| 2067 |
+
|
| 2068 |
+
gr.Markdown("""
|
| 2069 |
+
---
|
| 2070 |
+
**Design System Extractor v2** | Built with Playwright + Gradio + LangGraph + HuggingFace
|
| 2071 |
+
|
| 2072 |
+
*A semi-automated co-pilot for design system recovery and modernization.*
|
| 2073 |
+
""")
|
| 2074 |
+
|
| 2075 |
+
return app
|
| 2076 |
+
|
| 2077 |
+
|
| 2078 |
+
# =============================================================================
|
| 2079 |
+
# MAIN
|
| 2080 |
+
# =============================================================================
|
| 2081 |
+
|
| 2082 |
+
if __name__ == "__main__":
|
| 2083 |
+
app = create_ui()
|
| 2084 |
+
app.launch(server_name="0.0.0.0", server_port=7860)
|