Files
offlineu/tools/generate_website_scheme_themes.py
rmsitzandClaude Sonnet 5 52371dd02f Convert all Figma theme backgrounds to dark/colored, dedupe again
The 34 kept themes were split 24 light-mode / 18 dark-mode - the light
ones all shared the same washed-out near-white background (L=0.95),
too bright per feedback. Added a force_mood override to build_theme()
and regenerated all 24 as dark instead, same accent character, colored-
dark background rather than near-white.

That conversion collapsed a lot of previously-distinct light backgrounds
into similar dark neutrals, so re-ran the same color-distance dedup pass
across the full set: 8 more turned out to be near-twins once everything
converged to dark (including a 3-way orange-on-dark cluster trimmed to
one). Net: 53 candidates -> 34 kept, all dark/colored, zero white
backgrounds - down from the 42 (24 light/18 dark) shipped last commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:33:34 -04:00

255 lines
10 KiB
Python

"""
One-off tool behind the 34 "website scheme" theme entries in
THEME_PALETTES/THEME_DISPLAY_NAMES (offlineu_core.py), sourced from Figma's
"53 Unique Website Color Schemes" resource page:
https://www.figma.com/resource-library/website-color-schemes/
That page has no raw hex data - each scheme is a rendered mockup
screenshot, not a swatch grid - so website_scheme_swatches.json (checked in
alongside this script) holds dominant colors already extracted from those
53 images via canvas pixel-histogram sampling in a browser, one entry per
scheme: {n, name, colors: [{hex, pct}, ...]}. build_theme() turns that raw
material into an actual UI palette per scheme (bg/text/accent, matching
THEME_PALETTES' shape), then nudges lightness/saturation as needed so every
result clears the same contrast bars this file's checks apply:
text-vs-background, accent-vs-background, and accent-vs-white (buttons
always use white text - see .btn in course_dashboard.html - so a too-bright
accent needs catching even when it reads fine against the background
alone), and so accents don't land at the full saturation that reads as
neon in the mid-lightness band.
Running this file directly (see __main__ below) auto-detects each scheme's
own mood from its swatches and writes all 53 as candidates - that's NOT
what's actually shipped. The source material is mostly light-mode
marketing mockups, so the first pass over-represented near-white
backgrounds; the shipped 34 all use build_theme(entry, force_mood='dark')
instead (every one of the 53 converted to a dark/colored background), then
had duplicates pruned by measuring real color distance (hue + lightness +
saturation, background and accent both) between every pair, tight enough
to only catch genuine near-twins - not something this script's __main__
does for you. That curation was one-off analysis, not captured as a single
rerunnable command; to redo it, call build_theme(entry, force_mood='dark')
per scheme, then de-duplicate the results the same way before splicing
into offlineu_core.py by hand.
To add more schemes: extract their dominant colors the same way (see the
canvas-sampling approach used originally; not scripted here) and append to
the JSON, then rerun.
Usage: python3 generate_website_scheme_themes.py
Writes generated_themes.json with all 53 candidates, auto-detected mood.
"""
import colorsys
import json
def hex_to_rgb(h):
h = h.lstrip('#')
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def rgb_to_hex(rgb):
return '#' + ''.join(f'{max(0, min(255, round(c))):02x}' for c in rgb)
def rgb_to_hsl(rgb):
r, g, b = [c / 255 for c in rgb]
h, l, s = colorsys.rgb_to_hls(r, g, b)
return h, s, l
def hsl_to_rgb(h, s, l):
r, g, b = colorsys.hls_to_rgb(h, l, s)
return tuple(round(c * 255) for c in (r, g, b))
def set_l(h, s, l):
return hsl_to_rgb(h, s, l)
def relative_luminance(rgb):
def chan(c):
c = c / 255
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = rgb
return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
def contrast_ratio(rgb1, rgb2):
l1, l2 = relative_luminance(rgb1), relative_luminance(rgb2)
l1, l2 = max(l1, l2), min(l1, l2)
return (l1 + 0.05) / (l2 + 0.05)
def clamp(x, lo, hi):
return max(lo, min(hi, x))
def slugify(name):
return name.lower().replace(' ', '_').replace("'", '')
DEFAULT_ACCENT_HSL = rgb_to_hsl(hex_to_rgb('#007acc'))
def tame_accent_saturation(s, l):
"""
Cap accent saturation to something a UI element can wear all day rather
than the fully-saturated brand colors these screenshots were sampled
from. Calibrated against the existing hand-picked themes: they only
reach full saturation out at pastel lightness (houston/night_owl,
l>=0.75) - in the mid-lightness band accents actually render in here
(~0.32-0.62, both branches below), full saturation is exactly what
reads as neon, so it gets capped harder there.
"""
if 0.32 <= l <= 0.62:
return min(s, 0.68)
return min(s, 0.85)
def build_theme(entry, force_mood=None):
name = entry['name']
swatches = entry['colors']
parsed = []
for sw in swatches:
rgb = hex_to_rgb(sw['hex'])
h, s, l = rgb_to_hsl(rgb)
parsed.append({'hex': sw['hex'], 'rgb': rgb, 'h': h, 's': s, 'l': l, 'pct': sw['pct']})
# exclude near-white "page chrome" padding, keep the scheme's real content colors
content = [p for p in parsed if p['l'] < 0.90]
if not content:
content = parsed
if force_mood == 'dark':
is_dark = True
elif force_mood == 'light':
is_dark = False
else:
darkest_l = min(p['l'] for p in content)
is_dark = darkest_l < 0.28
accent_candidates = [p for p in content if p['s'] > 0.25]
if accent_candidates:
accent_src = max(accent_candidates, key=lambda p: (round(p['s'], 2), p['pct']))
ah, asat, al = accent_src['h'], accent_src['s'], accent_src['l']
else:
ah, asat, al = DEFAULT_ACCENT_HSL
if is_dark:
base = min(content, key=lambda p: p['l'])
bh, bs = base['h'], min(base['s'], 0.22)
bg_l = clamp(base['l'], 0.09, 0.16)
bg_primary = set_l(bh, bs, bg_l)
bg_secondary = set_l(bh, bs, clamp(bg_l + 0.07, 0.14, 0.24))
bg_tertiary = set_l(bh, bs, clamp(bg_l + 0.14, 0.20, 0.32))
bg_tertiary_hover = set_l(bh, bs, clamp(bg_l + 0.17, 0.22, 0.35))
text_primary = set_l(bh, min(bs * 0.3, 0.08), 0.90)
text_muted = set_l(bh, min(bs * 0.3, 0.10), 0.62)
border_color = set_l(bh, bs, clamp(bg_l + 0.16, 0.24, 0.34))
accent_l = clamp(al, 0.50, 0.68)
accent_s = tame_accent_saturation(max(asat, 0.45), accent_l)
accent = set_l(ah, accent_s, accent_l)
else:
light_candidates = [p for p in content if p['l'] >= 0.55]
base_h = light_candidates[0]['h'] if light_candidates else ah
bg_primary = set_l(base_h, 0.12, 0.95)
bg_secondary = (255, 255, 255)
bg_tertiary = set_l(base_h, 0.12, 0.93)
bg_tertiary_hover = set_l(base_h, 0.14, 0.88)
text_primary = set_l(base_h, 0.05, 0.14)
text_muted = set_l(base_h, 0.05, 0.42)
border_color = set_l(base_h, 0.10, 0.82)
accent_l = clamp(al, 0.38, 0.55)
accent_s = tame_accent_saturation(max(asat, 0.45), accent_l)
accent = set_l(ah, accent_s, accent_l)
palette = {
'bg-primary': rgb_to_hex(bg_primary),
'bg-secondary': rgb_to_hex(bg_secondary),
'bg-tertiary': rgb_to_hex(bg_tertiary),
'bg-tertiary-hover': rgb_to_hex(bg_tertiary_hover),
'text-primary': rgb_to_hex(text_primary),
'text-muted': rgb_to_hex(text_muted),
'border-color': rgb_to_hex(border_color),
'accent': rgb_to_hex(accent),
}
bgp_rgb = hex_to_rgb(palette['bg-primary'])
tp_rgb = hex_to_rgb(palette['text-primary'])
tries = 0
while contrast_ratio(bgp_rgb, tp_rgb) < 4.5 and tries < 30:
h, s, l = rgb_to_hsl(tp_rgb)
l = clamp(l + (0.03 if is_dark else -0.03), 0, 1)
tp_rgb = hsl_to_rgb(h, s, l)
tries += 1
palette['text-primary'] = rgb_to_hex(tp_rgb)
acc_rgb = hex_to_rgb(palette['accent'])
tries = 0
while contrast_ratio(bgp_rgb, acc_rgb) < 3.0 and tries < 30:
h, s, l = rgb_to_hsl(acc_rgb)
l = clamp(l + (0.03 if is_dark else -0.03), 0, 1)
acc_rgb = hsl_to_rgb(h, s, l)
tries += 1
# .btn always uses white text on the accent background (see
# course_dashboard.html .btn), regardless of theme - calibrated to
# 1.8 rather than a stricter WCAG bar because the existing hand-picked
# themes (ayu_dark 1.91, nord 2.00, dracula 2.41, ...) already run
# fairly loose here; this only catches genuine outliers like a
# near-white/neon accent that would be nearly illegible.
white_rgb = (255, 255, 255)
tries = 0
while contrast_ratio(acc_rgb, white_rgb) < 1.8 and tries < 30:
h, s, l = rgb_to_hsl(acc_rgb)
l = clamp(l - 0.03, 0, 1)
acc_rgb = hsl_to_rgb(h, s, l)
tries += 1
palette['accent'] = rgb_to_hex(acc_rgb)
# Derived from the *final* accent (after both safety loops above), not
# the pre-adjustment value - matching every existing hand-picked theme's
# own convention (accent-hover always a bit darker than accent, e.g.
# dracula #bd93f9 -> #a672f0, light theme #007acc -> #005a9e). Building
# this from the original unadjusted accent_l would let a since-darkened
# accent end up *lighter* than its own hover state.
h, s, l = rgb_to_hsl(acc_rgb)
hover_l = clamp(l - 0.10, 0.10, 0.90)
hover_rgb = hsl_to_rgb(h, s, hover_l)
palette['accent-hover'] = rgb_to_hex(hover_rgb)
bgs_rgb = hex_to_rgb(palette['bg-secondary'])
tm_rgb = hex_to_rgb(palette['text-muted'])
tries = 0
while contrast_ratio(bgs_rgb, tm_rgb) < 3.0 and tries < 30:
h, s, l = rgb_to_hsl(tm_rgb)
l = clamp(l + (0.03 if is_dark else -0.03), 0, 1)
tm_rgb = hsl_to_rgb(h, s, l)
tries += 1
palette['text-muted'] = rgb_to_hex(tm_rgb)
return {
'key': slugify(name),
'display_name': name,
'mood': 'dark' if is_dark else 'light',
'palette': palette,
'contrast_text_bg': round(contrast_ratio(bgp_rgb, hex_to_rgb(palette['text-primary'])), 2),
'contrast_accent_bg': round(contrast_ratio(bgp_rgb, hex_to_rgb(palette['accent'])), 2),
}
if __name__ == '__main__':
data = json.load(open('website_scheme_swatches.json'))
results = [build_theme(e) for e in data]
json.dump(results, open('generated_themes.json', 'w'), indent=2)
dark_count = sum(1 for r in results if r['mood'] == 'dark')
light_count = sum(1 for r in results if r['mood'] == 'light')
print(f'Generated {len(results)} themes: {dark_count} dark, {light_count} light')
low_contrast = [r for r in results if r['contrast_text_bg'] < 4.5 or r['contrast_accent_bg'] < 3.0]
print(f'Themes still under contrast targets after adjustment: {len(low_contrast)}')
for r in low_contrast:
print(' ', r['key'], r['contrast_text_bg'], r['contrast_accent_bg'])
for r in results:
print(r['key'].ljust(32), r['mood'].ljust(6), r['palette']['bg-primary'], r['palette']['accent'], f"text/bg={r['contrast_text_bg']}", f"accent/bg={r['contrast_accent_bg']}")