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>
255 lines
10 KiB
Python
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']}")
|