""" 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']}")