Files
offlineu/tools/generate_website_scheme_themes.py
T
rmsitzandClaude Sonnet 5 722b381913 Add 42 curated themes from Figma's website color schemes
Sourced from Figma's "53 Unique Website Color Schemes" resource page,
which only shows rendered mockup screenshots (no raw hex data) - extracted
dominant colors via canvas pixel sampling, then a small pipeline
(tools/generate_website_scheme_themes.py) maps each scheme's real colors
into this app's palette shape, with contrast enforced against both the
background and the white text every .btn uses.

Cut 11 of the original 53 after review: 2 for a neon-bright accent (the
saturation cap only bounded lightness at first, so ~28 of 53 accents
landed at pure 100% saturation - fixed at the pipeline level, calibrated
against what the existing hand-picked themes actually use), and 9 for
being near-duplicates of another kept scheme once mapped into actual UI
colors (measured by real color distance, not eyeballed) - including one
pixel-identical pair and a 4-way cluster trimmed to its two most distinct
members.

Settings dropdown is grouped into <optgroup>s (Base, Editor Themes, and
5 categories matching the source page's own curation) rather than one
flat 54-option list.

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

236 lines
9.3 KiB
Python

"""
One-off tool that generated the 53 "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}, ...]}. This script turns that raw
material into an actual UI palette per scheme (bg/text/accent, matching
THEME_PALETTES' shape), then nudges lightness 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).
Not a live pipeline - re-running it regenerates the exact same 53 themes
from the same frozen swatch data. To add more schemes, extract their
dominant colors the same way (see the canvas-sampling approach used
in-session; not scripted here) and append to the JSON, then rerun and
splice the output into offlineu_core.py by hand.
Usage: python3 generate_website_scheme_themes.py
Writes generated_themes.json with the full computed palette per scheme.
"""
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):
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
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']}")