From b0393b8e851f3f4e8355f0e513e9129bfc115611 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 7 Jun 2026 16:45:31 -0500 Subject: feat(theme-selector): add browser-based theme design tool A self-contained tool for building Emacs color themes by eye. generate.py emits one HTML page with six languages of tree-sitter-tokenized code, a category-to-color assignment table, a UI-faces table, and an editable palette. Reassign colors from the palette, toggle weight and slant per category, set foreground and background per UI face, then export a theme.json a later build step turns into theme files. The export carries the name, palette, syntax assignments, bold and italic sets, and a ui object of per-face foreground and background. The theme name is both the json name field and the download filename. samples.py holds the language samples and the default color map. theme-selector.html is the generated output. The json-to-theme converter is the next piece, and the part worth TDD. --- scripts/theme-selector/README.md | 67 +++++++++ scripts/theme-selector/generate.py | 209 +++++++++++++++++++++++++++++ scripts/theme-selector/samples.py | 168 +++++++++++++++++++++++ scripts/theme-selector/theme-selector.html | 160 ++++++++++++++++++++++ 4 files changed, 604 insertions(+) create mode 100644 scripts/theme-selector/README.md create mode 100644 scripts/theme-selector/generate.py create mode 100644 scripts/theme-selector/samples.py create mode 100644 scripts/theme-selector/theme-selector.html diff --git a/scripts/theme-selector/README.md b/scripts/theme-selector/README.md new file mode 100644 index 00000000..a8be0a10 --- /dev/null +++ b/scripts/theme-selector/README.md @@ -0,0 +1,67 @@ +# theme-selector + +A self-contained tool for designing Emacs color themes by eye. It renders six +languages of tree-sitter-tokenized code, a category→color assignment table, a +UI-faces table, and an editable palette into one HTML page you drive in the +browser. Reassign colors, toggle weight/slant, edit the palette, then export a +`theme.json` that a build step turns into `themes/-*.el`. + +## Run + +```bash +python3 generate.py # writes theme-selector.html beside this script +``` + +Then open it in Chrome (Firefox had color-rendering flakiness during design): + +```bash +WAYLAND_DISPLAY=wayland-1 google-chrome-stable theme-selector.html +``` + +During color work, disable Hyprland inactive-window dimming so colors read true: + +```bash +hyprctl keyword decoration:dim_inactive false +``` + +## Files + +- `generate.py` — emits the HTML+JS. Edit here to change layout or behavior. +- `samples.py` — the language code samples and the default category→color map + (`COLS`). `generate.py` reads the part before the `cols=` marker. +- `theme-selector.html` — generated output. Regenerate; don't hand-edit. + +## What it captures + +- Background and foreground (the `default` face's `:background` / `:foreground`). +- The syntax layer: every font-lock / tree-sitter category (keyword, string, + function, type, comment, and the rest), each with normal/bold/italic. +- UI faces: cursor, region, mode-line, fringe, line numbers, isearch, paren + match, link, error/warning/success, and more — foreground and background per + face. +- The palette itself: add by hex or swatch, remove, rename, drag to reorder. + +## theme.json contract + +The export (and what a build step consumes): + +```json +{ + "name": "dupre-revision", + "palette": [["#67809c", "blue"], ["#e8bd30", "gold"]], + "assignments": {"kw": "#67809c", "str": "#5d9b86", "bg": "#0d0b0a", "p": "#cdced1"}, + "bold": ["kw", "fnd"], + "italic": [], + "ui": {"region": {"fg": null, "bg": "#264364"}, "cursor": {"fg": null, "bg": "#a9b2bb"}} +} +``` + +The theme name is both the `name` field and the download filename +(`.json`, sanitized). Upload a `theme.json` to start from a prior theme. + +## Next step (not yet built) + +A `theme.json` → `themes/-palette.el` + `-faces.el` + `-theme.el` +converter. That step is the correctness-sensitive part and is the one worth +TDD: JSON in, valid Emacs palette + faces out, every face mapped, WCAG-contrast +asserted on the result. diff --git a/scripts/theme-selector/generate.py b/scripts/theme-selector/generate.py new file mode 100644 index 00000000..94910d1c --- /dev/null +++ b/scripts/theme-selector/generate.py @@ -0,0 +1,209 @@ +import json, re, os +HERE=os.path.dirname(os.path.abspath(__file__)) +ns={} +src=open(os.path.join(HERE,'samples.py')).read() +exec(src[:src.index('cols=')], ns) +SAMPLES={"Elisp":ns['ELS'],"Go":ns['GOS'],"Python":ns['PYS'],"TypeScript":ns['TSS'],"Shell":ns['SHS'],"C/C++":ns['CS']} +COLS=ns['COLS'] +MAP={k:v[0] for k,v in COLS.items()}; BOLD={k:v[1] for k,v in COLS.items()}; MAP['str']='#5d9b86'; MAP['bg']='#0d0b0a' +PALETTE=[["#67809c","blue"],["#e8bd30","gold"],["#9b5fd0","regal"],["#2ba178","emerald"],["#5d9b86","sage"], + ["#cb6b4d","terracotta"],["#be9e74","tan"],["#cdced1","white"],["#a9b2bb","silver"],["#838d97","steel"], + ["#5e6770","pewter"],["#2f343a","gunmetal"],["#264364","navy"],["#0d0b0a","ground"],["#1a1714","bg-dim"]] +CATS=[["bg","background (ground)","Aa Bb 123"],["p","fg · default text","other / whitespace"],["kw","keyword","class def if return"],["bi","builtin","len echo printf"], + ["pp","preprocessor","#include #define"],["fnd","function · def","resolve push"], + ["fnc","function · call","printf rsync get"],["dec","decorator","@dataclass"], + ["ty","type / class","int str Order Queue"],["prop","property / field","id name items"], + ["con","constant","None nil NULL true"],["num","number","8080 100 -1"], + ["str","string",'"dupre" "fmt"'],["esc","escape","\\n \\t"],["re","regexp","/^#[0-9a-f]+/"], + ["doc","docstring",'"""..."""'],["cm","comment","# reject nil"],["cmd","comment delim","# // ;;"], + ["var","variable / use","value key self"],["op","operator",": = -> =="], + ["punc","punctuation","{ } ( ) ;"]] +UI_FACES=[["cursor","cursor","Aa|"],["region","region (selection)","selected text"], + ["hl-line","hl-line (current line)","current line"],["highlight","highlight","hover"], + ["mode-line","mode-line","status active"],["mode-line-inactive","mode-line-inactive","status idle"], + ["fringe","fringe","| |"],["line-number","line-number"," 42"], + ["line-number-current-line","line-number-current-line","> 42"],["minibuffer-prompt","minibuffer-prompt","M-x "], + ["isearch","isearch (match)","match"],["lazy-highlight","lazy-highlight","other match"], + ["isearch-fail","isearch-fail","no match"],["show-paren-match","show-paren-match","( )"], + ["show-paren-mismatch","show-paren-mismatch",") ("],["link","link","https://"], + ["error","error","error!"],["warning","warning","warning"], + ["success","success","ok"],["vertical-border","vertical-border","|"]] +UIMAP={"cursor":{"fg":None,"bg":"#a9b2bb"},"region":{"fg":None,"bg":"#264364"}, + "hl-line":{"fg":None,"bg":"#1a1714"},"highlight":{"fg":None,"bg":"#2f343a"}, + "mode-line":{"fg":"#cdced1","bg":"#2f343a"},"mode-line-inactive":{"fg":"#838d97","bg":"#1a1714"}, + "fringe":{"fg":None,"bg":"#0d0b0a"},"line-number":{"fg":"#5e6770","bg":None}, + "line-number-current-line":{"fg":"#e8bd30","bg":"#1a1714"},"minibuffer-prompt":{"fg":"#67809c","bg":None}, + "isearch":{"fg":"#0d0b0a","bg":"#e8bd30"},"lazy-highlight":{"fg":"#0d0b0a","bg":"#838d97"}, + "isearch-fail":{"fg":"#cb6b4d","bg":None},"show-paren-match":{"fg":None,"bg":"#264364"}, + "show-paren-mismatch":{"fg":"#0d0b0a","bg":"#cb6b4d"},"link":{"fg":"#67809c","bg":None}, + "error":{"fg":"#cb6b4d","bg":None},"warning":{"fg":"#e8bd30","bg":None}, + "success":{"fg":"#5d9b86","bg":None},"vertical-border":{"fg":"#2f343a","bg":None}} +def cid(l): return re.sub(r'\W','',l) +code_cont="".join(f'

{l}

' for l in SAMPLES) +HTML = """theme-selector + +

Untitled: color palette

+

code samples

+
CODE_CONT
+

color → category — chip reassigns · N/B/I sets weight & slant · click a header to sort

+
color △stylecategory △example
+

UI / interface faces — foreground & background per face

+
faceforegroundbackgroundpreview
+

palette — add / remove / rename / drag to reorder

+
+
+ + + + +
+

save / load theme

+
+ +
+
+ + + +
+ +""" +HTML=(HTML.replace("CODE_CONT",code_cont).replace("SAMPLES_J",json.dumps(SAMPLES)) + .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS)) + .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)) + .replace("BOLD_J",json.dumps(BOLD)).replace("MAP_J",json.dumps(MAP))) +OUT=os.path.join(HERE,'theme-selector.html') +open(OUT,"w").write(HTML) +print("wrote",OUT) diff --git a/scripts/theme-selector/samples.py b/scripts/theme-selector/samples.py new file mode 100644 index 00000000..1637a2a3 --- /dev/null +++ b/scripts/theme-selector/samples.py @@ -0,0 +1,168 @@ +GROUND="#0d0b0a" +COLS={ + 'kw':("#67809c",True),'bi':("#67809c",False),'pp':("#67809c",False), + 'fnd':("#a9b2bb",True),'fnc':("#a9b2bb",False),'dec':("#e8bd30",False), + 'ty':("#9b5fd0",False),'prop':("#838d97",False), + 'con':("#cb6b4d",False),'num':("#cb6b4d",False),'esc':("#cb6b4d",False), + 'str':("#2ba178",False),'re':("#5d9b86",False),'doc':("#5d9b86",False), + 'cm':("#be9e74",False),'cmd':("#a9b2bb",False), + 'var':("#e8bd30",False),'op':("#a9b2bb",False),'punc':("#a9b2bb",False),'p':("#cdced1",False), +} +NAMES={"#67809c":"blue","#e8bd30":"gold","#9b5fd0":"regal","#2ba178":"emerald","#cb6b4d":"terracotta","#be9e74":"tan","#5d9b86":"sage","#cdced1":"white","#a9b2bb":"silver","#838d97":"steel","#5e6770":"pewter","#2f343a":"gunmetal","#264364":"navy"} +def esc(t): return t.replace("&","&").replace("<","<").replace(">",">") +def span(k,t): + c,b=COLS[k]; w=";font-weight:bold" if b else "" + return f'{esc(t)}' +def render(lines): return "\n".join("".join(span(k,t) for k,t in ln) or " " for ln in lines) + +PYS=[ + [('cmd','#'),('cm',' theme.py')], + [('kw','from'),('p',' '),('var','dataclasses'),('p',' '),('kw','import'),('p',' '),('var','dataclass'),('punc',','),('p',' '),('var','field')], + [], + [('con','DEFAULT_PORT'),('op',':'),('p',' '),('ty','int'),('p',' '),('op','='),('p',' '),('num','8080')], + [], + [('dec','@dataclass')], + [('kw','class'),('p',' '),('ty','Theme'),('op',':')], + [('p',' '),('doc','"""A color theme."""')], + [('p',' '),('prop','name'),('op',':'),('p',' '),('ty','str'),('p',' '),('op','='),('p',' '),('str','"dupre"')], + [('p',' '),('prop','colors'),('op',':'),('p',' '),('ty','dict'),('p',' '),('op','='),('p',' '),('fnc','field'),('punc','('),('prop','default_factory'),('op','='),('ty','dict'),('punc',')')], + [], + [('p',' '),('kw','def'),('p',' '),('fnd','resolve'),('punc','('),('var','self'),('punc',','),('p',' '),('var','key'),('op',':'),('p',' '),('ty','str'),('punc',')'),('p',' '),('op','->'),('p',' '),('ty','str'),('p',' '),('op','|'),('p',' '),('con','None'),('op',':')], + [('p',' '),('cmd','#'),('cm',' fallback to none')], + [('p',' '),('var','v'),('p',' '),('op','='),('p',' '),('var','self'),('op','.'),('prop','colors'),('op','.'),('fnc','get'),('punc','('),('var','key'),('punc',','),('p',' '),('str','"'),('esc','\\t'),('str','none"'),('punc',')')], + [('p',' '),('kw','if'),('p',' '),('bi','len'),('punc','('),('var','v'),('punc',')'),('p',' '),('op','=='),('p',' '),('num','0'),('op',':'),('p',' '),('kw','return'),('p',' '),('con','None')], + [('p',' '),('kw','return'),('p',' '),('var','v')], +] +ELS=[ + [('cmd',';;'),('cm',' cache.el')], + [('punc','('),('kw','require'),('p',' '),('con',"'cl-lib"),('punc',')')], + [], + [('punc','('),('kw','defvar'),('p',' '),('var','cache--tbl'),('p',' '),('punc','('),('fnc','make-hash-table'),('p',' '),('con',':test'),('p',' '),('con',"'equal"),('punc','))')], + [('p',' '),('doc','"Memo table.")')], + [], + [('punc','('),('kw','defun'),('p',' '),('fnd','cache-get'),('p',' '),('punc','('),('var','key'),('punc',')')], + [('p',' '),('doc','"Return cached value for KEY."')], + [('p',' '),('punc','('),('kw','or'),('p',' '),('punc','('),('fnc','gethash'),('p',' '),('var','key'),('p',' '),('var','cache--tbl'),('punc',')')], + [('p',' '),('punc','('),('kw','let'),('p',' '),('punc','(('),('var','v'),('p',' '),('punc','('),('fnc','compute'),('p',' '),('var','key'),('p',' '),('num','42'),('punc','))) ')], + [('p',' '),('punc','('),('fnc','puthash'),('p',' '),('var','key'),('p',' '),('var','v'),('p',' '),('var','cache--tbl'),('punc',') '),('var','v'),('punc','))))')], +] +GOS=[ + [('cmd','//'),('cm',' queue.go')], + [('kw','package'),('p',' '),('var','main')], + [], + [('kw','import'),('p',' '),('str','"fmt"')], + [], + [('kw','const'),('p',' '),('con','MaxItems'),('p',' '),('op','='),('p',' '),('num','100')], + [], + [('kw','type'),('p',' '),('ty','Order'),('p',' '),('kw','struct'),('p',' '),('punc','{')], + [('p',' '),('prop','ID'),('p',' '),('ty','int')], + [('p',' '),('prop','Name'),('p',' '),('ty','string')], + [('punc','}')], + [], + [('kw','func'),('p',' '),('punc','('),('var','q'),('p',' '),('op','*'),('ty','Queue'),('punc',')'),('p',' '),('fnd','Push'),('punc','('),('var','o'),('p',' '),('op','*'),('ty','Order'),('punc',')'),('p',' '),('ty','error'),('p',' '),('punc','{')], + [('p',' '),('cmd','//'),('cm',' reject nil')], + [('p',' '),('kw','if'),('p',' '),('var','o'),('p',' '),('op','=='),('p',' '),('con','nil'),('p',' '),('punc','{')], + [('p',' '),('kw','return'),('p',' '),('fnc','fmt.Errorf'),('punc','('),('str','"nil"'),('punc',')')], + [('p',' '),('punc','}')], + [('p',' '),('var','q'),('op','.'),('prop','items'),('p',' '),('op','='),('p',' '),('fnc','append'),('punc','('),('var','q'),('op','.'),('prop','items'),('punc',','),('p',' '),('var','o'),('punc',')')], + [('p',' '),('kw','return'),('p',' '),('con','nil')], + [('punc','}')], +] +TSS=[ + [('cmd','//'),('cm',' orders.ts')], + [('kw','import'),('p',' '),('punc','{'),('p',' '),('ty','Order'),('p',' '),('punc','}'),('p',' '),('kw','from'),('p',' '),('str','"./types"')], + [], + [('kw','export'),('p',' '),('kw','interface'),('p',' '),('ty','Queue'),('p',' '),('punc','{')], + [('p',' '),('prop','max'),('op',':'),('p',' '),('ty','number'),('punc',';')], + [('p',' '),('prop','items'),('op',':'),('p',' '),('ty','Order'),('punc','[];')], + [('punc','}')], + [], + [('dec','@Injectable'),('punc','()')], + [('kw','export'),('p',' '),('kw','class'),('p',' '),('ty','OrderQueue'),('p',' '),('kw','implements'),('p',' '),('ty','Queue'),('p',' '),('punc','{')], + [('p',' '),('kw','private'),('p',' '),('prop','re'),('p',' '),('op','='),('p',' '),('re','/^#[0-9a-f]{6}$/i'),('punc',';')], + [], + [('p',' '),('fnd','push'),('punc','('),('var','o'),('op',':'),('p',' '),('ty','Order'),('punc',')'),('op',':'),('p',' '),('ty','boolean'),('p',' '),('punc','{')], + [('p',' '),('kw','if'),('p',' '),('punc','('),('var','o'),('p',' '),('op','==='),('p',' '),('con','null'),('punc',')'),('p',' '),('kw','return'),('p',' '),('con','false'),('punc',';')], + [('p',' '),('var','console'),('op','.'),('fnc','log'),('punc','('),('str','`id '),('punc','${'),('var','o'),('op','.'),('prop','id'),('punc','}'),('esc','\\n'),('str','`'),('punc',');')], + [('p',' '),('kw','return'),('p',' '),('con','true'),('punc',';')], + [('p',' '),('punc','}')], + [('punc','}')], +] + +CS=[ + [('cmd','//'),('cm',' theme.c')], + [('pp','#include'),('p',' '),('str','')], + [('pp','#define'),('p',' '),('con','MAX_PORT'),('p',' '),('num','8080')], + [], + [('kw','typedef'),('p',' '),('kw','struct'),('p',' '),('punc','{')], + [('p',' '),('ty','int'),('p',' '),('prop','id'),('punc',';')], + [('p',' '),('ty','char'),('p',' '),('op','*'),('prop','name'),('punc',';')], + [('punc','}'),('p',' '),('ty','Order'),('punc',';')], + [], + [('ty','int'),('p',' '),('fnd','push'),('punc','('),('ty','Order'),('p',' '),('op','*'),('var','o'),('punc',')'),('p',' '),('punc','{')], + [('p',' '),('kw','if'),('p',' '),('punc','('),('var','o'),('p',' '),('op','=='),('p',' '),('con','NULL'),('punc',')'),('p',' '),('punc','{')], + [('p',' '),('kw','return'),('p',' '),('num','-1'),('punc',';')], + [('p',' '),('punc','}')], + [('p',' '),('fnc','printf'),('punc','('),('str','"id=%d'),('esc',chr(92)+'n'),('str','"'),('punc',','),('p',' '),('var','o'),('op','->'),('prop','id'),('punc',');')], + [('p',' '),('kw','return'),('p',' '),('num','0'),('punc',';')], + [('punc','}')], +] +SHS=[ + [('cmd','#!'),('cm','/bin/bash')], + [('cmd','#'),('cm',' deploy.sh')], + [('bi','set'),('p',' '),('op','-'),('var','euo'),('p',' '),('var','pipefail')], + [], + [('var','PORT'),('op','='),('num','8080')], + [('var','NAME'),('op','='),('str','"dupre"')], + [], + [('fnd','deploy'),('punc','()'),('p',' '),('punc','{')], + [('p',' '),('kw','local'),('p',' '),('var','target'),('op','='),('str','"$1"')], + [('p',' '),('kw','if'),('p',' '),('punc','[['),('p',' '),('op','-z'),('p',' '),('str','"$target"'),('p',' '),('punc',']]'),('punc',';'),('p',' '),('kw','then')], + [('p',' '),('bi','echo'),('p',' '),('str','"no target"')], + [('p',' '),('kw','return'),('p',' '),('num','1')], + [('p',' '),('kw','fi')], + [('p',' '),('fnc','rsync'),('p',' '),('op','-az'),('p',' '),('str','"$NAME"'),('p',' '),('str','"$target"')], + [('punc','}')], +] + +cols="".join(f'

{n}

{render(s)}
' for n,s in [("Elisp",ELS),("Go",GOS),("Python",PYS),("TypeScript",TSS),("Shell",SHS),("C/C++",CS)]) +legend_rows=[ + ("keyword (bold)","kw","class def if return import"),("builtin","bi","len range print"), + ("function — definition (bold)","fnd","resolve cache-get push"),("function — call","fnc","get append fmt.Errorf"), + ("decorator / attribute","dec","@dataclass @Injectable"),("type / class","ty","str dict Order Queue boolean"), + ("property / field / key","prop","name colors items id re"),("constant","con","None nil true MaxItems :test"), + ("number","num","8080 100 42 0"),("string","str",'"dupre" "fmt" `id`'),("escape","esc",r'\t \n'), + ("regexp","re",'/^#[0-9a-f]{6}$/i'),("docstring","doc",'"""..." "Memo table."'), + ("comment","cm","# reject nil // fallback"),("comment delimiter","cmd","# // ;; /*"), + ("variable / use","var","v key self q console"),("operator","op",": = -> | == === . *"), + ("punctuation / bracket","punc","{ } ( ) [ ] , ;"), +] +def lrow(label,k,ex): + c,b=COLS[k] + return f'{NAMES.get(c,"")}{c}{label}{esc(ex)}' +legend="".join(lrow(l,k,e) for l,k,e in legend_rows) +def grp(title,items): + sw="".join(f'
{n}
{h}
' for n,h in items) + return f'
{title}
{sw}
' +palette=(grp("ground / foreground",[("ground","#0d0b0a"),("bg-dim","#1a1714"),("fg","#cdced1")]) + + grp("syntax hues",[("blue · keyword","#67809c"),("gold · variable","#e8bd30"),("regal · type","#9b5fd0"),("emerald · string","#2ba178"),("terracotta · const/num","#cb6b4d"),("tan · comment","#be9e74")]) + + grp("metallic greyscale (structural)",[("gunmetal","#2f343a"),("metal","#474e56"),("pewter","#5e6770"),("steel · property","#838d97"),("silver · fn/op/punct","#a9b2bb"),("bright · fg","#cdced1")]) + + grp("special green + fills",[("muted emerald · doc/regexp","#5d9b86"),("navy fill","#264364"),("gunmetal fill","#2f343a")])) +html=f'''dupre revision — canonical + +

code samples

+
{cols}
+

color → tree-sitter category assignment — click a header to sort

+{legend}
color △hex △category △example
+

palette

+{palette}\n''' +open("/tmp/dupre-canon.html","w").write(html) +print("wrote /tmp/dupre-canon.html") diff --git a/scripts/theme-selector/theme-selector.html b/scripts/theme-selector/theme-selector.html new file mode 100644 index 00000000..827529c5 --- /dev/null +++ b/scripts/theme-selector/theme-selector.html @@ -0,0 +1,160 @@ +theme-selector + +

Untitled: color palette

+

code samples

+

Elisp

Go

Python

TypeScript

Shell

C/C++

+

color → category — chip reassigns · N/B/I sets weight & slant · click a header to sort

+
color △stylecategory △example
+

UI / interface faces — foreground & background per face

+
faceforegroundbackgroundpreview
+

palette — add / remove / rename / drag to reorder

+
+
+ + + + +
+

save / load theme

+
+ +
+
+ + + +
+ + \ No newline at end of file -- cgit v1.2.3