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