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>
This commit is contained in:
2026-08-24 08:08:56 -04:00
co-authored by Claude Sonnet 5
parent acabb89981
commit 722b381913
5 changed files with 563 additions and 6 deletions
+19 -1
View File
@@ -120,7 +120,12 @@ silently stay blank instead of erroring.
- Per-course study guide export (markdown, organized by section)
**Personalization** (Settings)
- Built-in themes or a custom accent color, corner radius, and card style
- 54 built-in themes (grouped in the dropdown: Base, Editor Themes, and 5
categories of website-inspired schemes sourced from Figma's "53 Unique
Website Color Schemes" - 42 of the 53 kept, after cutting a couple of
neon-bright ones and several that ended up near-duplicates of another
scheme once mapped into actual UI colors), or a fully custom accent
color, corner radius, and card style
- Font, text size, page width, and spacing density
- Default library path override + manual "Refresh Library" (bypasses the
5-minute filesystem-scan cache)
@@ -188,3 +193,16 @@ folder and file names.
| Documents | `.txt` `.md` `.html` `.htm` `.pdf` `.docx` `.doc` `.rtf` |
| Subtitles | `.srt` `.vtt` `.ass` `.sub` `.sbv` |
| Quizzes | Any doc file whose name contains `quiz`, `exam`, `test`, `assessment`, `exercise`, `assignment`, or `homework` |
---
## Tools
`tools/generate_website_scheme_themes.py` is the one-off script that
produced the 42 "website scheme" theme entries in `THEME_PALETTES` (of
53 candidates - 11 were cut after visual review, 2 for a neon-bright
accent and 9 for landing too close to another kept scheme once mapped
into actual UI colors) - not part of the running app, kept for reference
if those palettes ever need regenerating or extending. See its module
docstring for how it turned Figma's color-scheme screenshots into actual
UI palettes.
+302 -3
View File
@@ -100,6 +100,28 @@ SETTINGS_CHOICES = {
'theme': {
'dark', 'light', 'dracula', 'tokyo_night', 'catppuccin_mocha', 'ayu_dark',
'github_dark', 'atom_one_dark', 'houston', 'night_owl', 'nord', 'matcha',
# 42 palettes derived from Figma's "53 Unique Website Color Schemes"
# resource page (11 of the 53 dropped: 2 for a neon-bright accent
# that read badly as a button color, the rest for being too close
# to another scheme already kept - same accent hue/lightness and
# background, just a different name) - see WEBSITE_SCHEME_CATEGORIES
# below for the dropdown grouping, and
# tools/generate_website_scheme_themes.py (a one-off content-
# generation script, not part of the running app) for how the kept
# ones were built from that page's screenshots: dominant colors
# extracted per scheme, then mapped into this app's bg/text/accent
# shape with automatic contrast adjustment against both the
# background and the white button text.
'ink_wash', 'jade_pebble_morning', 'woodland', 'graphite', 'urban_slate', 'pearl',
'vichy', 'sorbet', 'yacht_club', 'amber_walnut_morning', 'copper_aquamarine_dream',
'cocoa_topaz_noonday', 'sandstone_aquamarine_serenity', 'honey_opal_sunset',
'seashell_garnet_afternoon', 'rose_quartz_evening', 'calcite', 'fireside', 'terrazzo',
'sapphire_nightfall_whisper', 'marina', 'emerald_lavender_lake',
'sage_peridot_morning', 'amethyst_dawn_haze', 'turquoise_amber_autumn',
'sapphire_ash_morning', 'royal_glimmer', 'neptune', 'tropical_heat', 'celestial',
'festive_eve', 'freshly_squeezed', 'jelly_shoes', 'gossamer', 'clockwork',
'lemon_granite_morning', 'arctic_reflection', 'slate', 'autumn_luxe', 'inked',
'wraith', 'urban_nocturne',
},
'font_family': {'system', 'sans', 'serif', 'monospace', 'monaspace'},
'font_size': {'small', 'medium', 'large', 'xlarge'},
@@ -118,6 +140,48 @@ THEME_DISPLAY_NAMES = {
'ayu_dark': 'Ayu Dark', 'github_dark': 'GitHub Dark',
'atom_one_dark': 'Atom One Dark', 'houston': 'Houston',
'night_owl': 'Night Owl', 'nord': 'Nord', 'matcha': 'Matcha',
'ink_wash': 'Ink Wash',
'jade_pebble_morning': 'Jade Pebble Morning',
'woodland': 'Woodland',
'graphite': 'Graphite',
'urban_slate': 'Urban Slate',
'pearl': 'Pearl',
'vichy': 'Vichy',
'sorbet': 'Sorbet',
'yacht_club': 'Yacht Club',
'amber_walnut_morning': 'Amber Walnut Morning',
'copper_aquamarine_dream': 'Copper Aquamarine Dream',
'cocoa_topaz_noonday': 'Cocoa Topaz Noonday',
'sandstone_aquamarine_serenity': 'Sandstone Aquamarine Serenity',
'honey_opal_sunset': 'Honey Opal Sunset',
'seashell_garnet_afternoon': 'Seashell Garnet Afternoon',
'rose_quartz_evening': 'Rose Quartz Evening',
'calcite': 'Calcite',
'fireside': 'Fireside',
'terrazzo': 'Terrazzo',
'sapphire_nightfall_whisper': 'Sapphire Nightfall Whisper',
'marina': 'Marina',
'emerald_lavender_lake': 'Emerald Lavender Lake',
'sage_peridot_morning': 'Sage Peridot Morning',
'amethyst_dawn_haze': 'Amethyst Dawn Haze',
'turquoise_amber_autumn': 'Turquoise Amber Autumn',
'sapphire_ash_morning': 'Sapphire Ash Morning',
'royal_glimmer': 'Royal Glimmer',
'neptune': 'Neptune',
'tropical_heat': 'Tropical Heat',
'celestial': 'Celestial',
'festive_eve': 'Festive Eve',
'freshly_squeezed': 'Freshly Squeezed',
'jelly_shoes': 'Jelly Shoes',
'gossamer': 'Gossamer',
'clockwork': 'Clockwork',
'lemon_granite_morning': 'Lemon Granite Morning',
'arctic_reflection': 'Arctic Reflection',
'slate': 'Slate',
'autumn_luxe': 'Autumn Luxe',
'inked': 'Inked',
'wraith': 'Wraith',
'urban_nocturne': 'Urban Nocturne',
}
# Full color palette per theme. 'accent' here is only the *default* accent
@@ -196,6 +260,233 @@ THEME_PALETTES = {
'bg-tertiary-hover': '#3c4850', 'text-primary': '#d1ded3', 'text-muted': '#7c8885',
'border-color': '#707c4f', 'accent': '#a4b07e', 'accent-hover': '#8b966b',
},
# --- Minimal & Neutral (from Figma's "53 Unique Website Color Schemes") ---
'ink_wash': {
'bg-primary': '#202020', 'bg-secondary': '#323232', 'bg-tertiary': '#444444',
'bg-tertiary-hover': '#4b4b4b', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#494949', 'accent': '#2990d6', 'accent-hover': '#2173ab',
},
'jade_pebble_morning': {
'bg-primary': '#f2f4f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#edefeb',
'bg-tertiary-hover': '#e0e5dc', 'text-primary': '#242522', 'text-muted': '#6b7066',
'border-color': '#d1d6cd', 'accent': '#659539', 'accent-hover': '#4c702b',
},
'woodland': {
'bg-primary': '#323220', 'bg-secondary': '#48482e', 'bg-tertiary': '#5d5d3c',
'bg-tertiary-hover': '#676742', 'text-primary': '#e7e7e4', 'text-muted': '#a4a498',
'border-color': '#646440', 'accent': '#acc927', 'accent-hover': '#879e1f',
},
'graphite': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#d34646', 'accent-hover': '#ba2c2c',
},
'urban_slate': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#2174ab', 'accent-hover': '#195780',
},
'pearl': {
'bg-primary': '#f2f1f4', 'bg-secondary': '#ffffff', 'bg-tertiary': '#ecebef',
'bg-tertiary-hover': '#dfdce5', 'text-primary': '#232225', 'text-muted': '#696670',
'border-color': '#d0cdd6', 'accent': '#bd7c3d', 'accent-hover': '#966331',
},
'vichy': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#1d968b', 'accent-hover': '#156b63',
},
'sorbet': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#c07b59', 'accent-hover': '#a7613f',
},
'yacht_club': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#97562b', 'accent-hover': '#6f3f20',
},
# --- Warm (from Figma's "53 Unique Website Color Schemes") ---
'amber_walnut_morning': {
'bg-primary': '#292929', 'bg-secondary': '#3b3b3b', 'bg-tertiary': '#4d4d4d',
'bg-tertiary-hover': '#545454', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#525252', 'accent': '#d09070', 'accent-hover': '#c3724a',
},
'copper_aquamarine_dream': {
'bg-primary': '#f4f4f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efefeb',
'bg-tertiary-hover': '#e5e5dc', 'text-primary': '#252522', 'text-muted': '#707066',
'border-color': '#d6d6cd', 'accent': '#bc5224', 'accent-hover': '#913f1c',
},
'cocoa_topaz_noonday': {
'bg-primary': '#322620', 'bg-secondary': '#48362e', 'bg-tertiary': '#5d473c',
'bg-tertiary-hover': '#674e42', 'text-primary': '#e7e5e4', 'text-muted': '#a49c98',
'border-color': '#644c40', 'accent': '#f1a04e', 'accent-hover': '#ed871f',
},
'sandstone_aquamarine_serenity': {
'bg-primary': '#322420', 'bg-secondary': '#48342e', 'bg-tertiary': '#5d443c',
'bg-tertiary-hover': '#674b42', 'text-primary': '#e7e5e4', 'text-muted': '#a49b98',
'border-color': '#644940', 'accent': '#76c9e5', 'accent-hover': '#4bb8dd',
},
'honey_opal_sunset': {
'bg-primary': '#322920', 'bg-secondary': '#483b2e', 'bg-tertiary': '#5d4d3c',
'bg-tertiary-hover': '#675442', 'text-primary': '#e7e6e4', 'text-muted': '#a49e98',
'border-color': '#645240', 'accent': '#d6b12a', 'accent-hover': '#ac8e21',
},
'seashell_garnet_afternoon': {
'bg-primary': '#f1f2f4', 'bg-secondary': '#ffffff', 'bg-tertiary': '#ebedef',
'bg-tertiary-hover': '#dce0e5', 'text-primary': '#222425', 'text-muted': '#666b70',
'border-color': '#cdd1d6', 'accent': '#da3e50', 'accent-hover': '#c02537',
},
'rose_quartz_evening': {
'bg-primary': '#322024', 'bg-secondary': '#482e34', 'bg-tertiary': '#5d3c44',
'bg-tertiary-hover': '#67424b', 'text-primary': '#e7e4e5', 'text-muted': '#a4989b',
'border-color': '#644049', 'accent': '#f36868', 'accent-hover': '#ef3939',
},
'calcite': {
'bg-primary': '#292929', 'bg-secondary': '#3b3b3b', 'bg-tertiary': '#4d4d4d',
'bg-tertiary-hover': '#545454', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#525252', 'accent': '#f1854e', 'accent-hover': '#ed641f',
},
'fireside': {
'bg-primary': '#f4f2f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efedeb',
'bg-tertiary-hover': '#e5e0dc', 'text-primary': '#252422', 'text-muted': '#706b66',
'border-color': '#d6d1cd', 'accent': '#ca7226', 'accent-hover': '#9f5a1e',
},
'terrazzo': {
'bg-primary': '#203232', 'bg-secondary': '#2e4848', 'bg-tertiary': '#3c5d5d',
'bg-tertiary-hover': '#426767', 'text-primary': '#e4e7e7', 'text-muted': '#98a4a4',
'border-color': '#406464', 'accent': '#ecad6f', 'accent-hover': '#e69342',
},
# --- Cool (from Figma's "53 Unique Website Color Schemes") ---
'sapphire_nightfall_whisper': {
'bg-primary': '#202932', 'bg-secondary': '#2e3b48', 'bg-tertiary': '#3c4c5d',
'bg-tertiary-hover': '#425467', 'text-primary': '#e4e6e7', 'text-muted': '#989ea4',
'border-color': '#405264', 'accent': '#297fd6', 'accent-hover': '#2166ab',
},
'marina': {
'bg-primary': '#f1f3f4', 'bg-secondary': '#ffffff', 'bg-tertiary': '#ebeeef',
'bg-tertiary-hover': '#dce2e5', 'text-primary': '#222425', 'text-muted': '#666d70',
'border-color': '#cdd3d6', 'accent': '#3e8cda', 'accent-hover': '#2572c0',
},
'emerald_lavender_lake': {
'bg-primary': '#f1f4f4', 'bg-secondary': '#ffffff', 'bg-tertiary': '#ebefef',
'bg-tertiary-hover': '#dce5e5', 'text-primary': '#222525', 'text-muted': '#667070',
'border-color': '#cdd6d6', 'accent': '#239f58', 'accent-hover': '#1a7541',
},
'sage_peridot_morning': {
'bg-primary': '#f1f4f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#ebefeb',
'bg-tertiary-hover': '#dce5dc', 'text-primary': '#222522', 'text-muted': '#667066',
'border-color': '#cdd6cd', 'accent': '#1f9e83', 'accent-hover': '#177360',
},
'amethyst_dawn_haze': {
'bg-primary': '#242032', 'bg-secondary': '#342e48', 'bg-tertiary': '#443c5d',
'bg-tertiary-hover': '#4b4267', 'text-primary': '#e5e4e7', 'text-muted': '#9b98a4',
'border-color': '#494064', 'accent': '#ccba27', 'accent-hover': '#a1931f',
},
'turquoise_amber_autumn': {
'bg-primary': '#322620', 'bg-secondary': '#48362e', 'bg-tertiary': '#5d473c',
'bg-tertiary-hover': '#674e42', 'text-primary': '#e7e5e4', 'text-muted': '#a49c98',
'border-color': '#644c40', 'accent': '#d66329', 'accent-hover': '#ab4f21',
},
'sapphire_ash_morning': {
'bg-primary': '#f1f2f4', 'bg-secondary': '#ffffff', 'bg-tertiary': '#ebecef',
'bg-tertiary-hover': '#dcdfe5', 'text-primary': '#222325', 'text-muted': '#666970',
'border-color': '#cdd0d6', 'accent': '#356a8d', 'accent-hover': '#274e68',
},
'royal_glimmer': {
'bg-primary': '#1f312d', 'bg-secondary': '#2d4741', 'bg-tertiary': '#3b5c56',
'bg-tertiary-hover': '#41665e', 'text-primary': '#e4e7e7', 'text-muted': '#98a4a2',
'border-color': '#3f635b', 'accent': '#29d6b4', 'accent-hover': '#21ab90',
},
'neptune': {
'bg-primary': '#f1f3f4', 'bg-secondary': '#ffffff', 'bg-tertiary': '#ebeeef',
'bg-tertiary-hover': '#dce2e5', 'text-primary': '#222425', 'text-muted': '#666d70',
'border-color': '#cdd3d6', 'accent': '#248fb8', 'accent-hover': '#1c6e8d',
},
# --- Vibrant & Bold (from Figma's "53 Unique Website Color Schemes") ---
'tropical_heat': {
'bg-primary': '#f4f3f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efeeeb',
'bg-tertiary-hover': '#e5e3dc', 'text-primary': '#252522', 'text-muted': '#706e66',
'border-color': '#d6d4cd', 'accent': '#ca5226', 'accent-hover': '#9f411e',
},
'celestial': {
'bg-primary': '#f4f4f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efefeb',
'bg-tertiary-hover': '#e5e4dc', 'text-primary': '#252522', 'text-muted': '#707066',
'border-color': '#d6d5cd', 'accent': '#918b1c', 'accent-hover': '#666214',
},
'festive_eve': {
'bg-primary': '#f2f1f4', 'bg-secondary': '#ffffff', 'bg-tertiary': '#ecebef',
'bg-tertiary-hover': '#dfdce5', 'text-primary': '#232225', 'text-muted': '#696670',
'border-color': '#d0cdd6', 'accent': '#733eda', 'accent-hover': '#5a25c0',
},
'freshly_squeezed': {
'bg-primary': '#f4f3f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efeeeb',
'bg-tertiary-hover': '#e5e2dc', 'text-primary': '#252422', 'text-muted': '#706d66',
'border-color': '#d6d3cd', 'accent': '#a28320', 'accent-hover': '#776118',
},
'jelly_shoes': {
'bg-primary': '#f3f1f4', 'bg-secondary': '#ffffff', 'bg-tertiary': '#eeebef',
'bg-tertiary-hover': '#e1dce5', 'text-primary': '#242225', 'text-muted': '#6c6670',
'border-color': '#d2cdd6', 'accent': '#9d3eda', 'accent-hover': '#8325c0',
},
# --- Modern (from Figma's "53 Unique Website Color Schemes") ---
'gossamer': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#d95c37', 'accent-hover': '#ba4623',
},
'clockwork': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#d26c28', 'accent-hover': '#a75620',
},
'lemon_granite_morning': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#89831b', 'accent-hover': '#5e5a13',
},
'arctic_reflection': {
'bg-primary': '#202c32', 'bg-secondary': '#2e3f48', 'bg-tertiary': '#3c525d',
'bg-tertiary-hover': '#425a67', 'text-primary': '#e4e6e7', 'text-muted': '#98a0a4',
'border-color': '#405864', 'accent': '#4693b9', 'accent-hover': '#387694',
},
'slate': {
'bg-primary': '#f4f1f1', 'bg-secondary': '#ffffff', 'bg-tertiary': '#efebeb',
'bg-tertiary-hover': '#e5dcdc', 'text-primary': '#252222', 'text-muted': '#706666',
'border-color': '#d6cdcd', 'accent': '#1f9e31', 'accent-hover': '#177324',
},
'autumn_luxe': {
'bg-primary': '#303020', 'bg-secondary': '#45452e', 'bg-tertiary': '#5b5b3d',
'bg-tertiary-hover': '#646443', 'text-primary': '#e7e7e4', 'text-muted': '#a4a498',
'border-color': '#616141', 'accent': '#c08040', 'accent-hover': '#9a6633',
},
'inked': {
'bg-primary': '#171717', 'bg-secondary': '#292929', 'bg-tertiary': '#3b3b3b',
'bg-tertiary-hover': '#424242', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#404040', 'accent': '#27c9c9', 'accent-hover': '#1f9e9e',
},
'wraith': {
'bg-primary': '#1c1c12', 'bg-secondary': '#323220', 'bg-tertiary': '#48482e',
'bg-tertiary-hover': '#515134', 'text-primary': '#e7e7e4', 'text-muted': '#a4a498',
'border-color': '#4e4e32', 'accent': '#d69c29', 'accent-hover': '#ab7d21',
},
'urban_nocturne': {
'bg-primary': '#171717', 'bg-secondary': '#292929', 'bg-tertiary': '#3b3b3b',
'bg-tertiary-hover': '#424242', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#404040', 'accent': '#bfc927', 'accent-hover': '#969e1f',
},
}
# Groups the 53 Figma-derived theme keys above for the Settings dropdown
# (rendered as <optgroup>s) - matches that source page's own "Minimal and
# neutral / Warm / Cool / Vibrant and bold / Modern" section headings, so
# browsing the dropdown still mirrors how the original schemes were curated.
WEBSITE_SCHEME_CATEGORIES = {
'Minimal & Neutral': ['ink_wash', 'jade_pebble_morning', 'woodland', 'graphite', 'urban_slate', 'pearl', 'vichy', 'sorbet', 'yacht_club'],
'Warm': ['amber_walnut_morning', 'copper_aquamarine_dream', 'cocoa_topaz_noonday', 'sandstone_aquamarine_serenity', 'honey_opal_sunset', 'seashell_garnet_afternoon', 'rose_quartz_evening', 'calcite', 'fireside', 'terrazzo'],
'Cool': ['sapphire_nightfall_whisper', 'marina', 'emerald_lavender_lake', 'sage_peridot_morning', 'amethyst_dawn_haze', 'turquoise_amber_autumn', 'sapphire_ash_morning', 'royal_glimmer', 'neptune'],
'Vibrant & Bold': ['tropical_heat', 'celestial', 'festive_eve', 'freshly_squeezed', 'jelly_shoes'],
'Modern': ['gossamer', 'clockwork', 'lemon_granite_morning', 'arctic_reflection', 'slate', 'autumn_luxe', 'inked', 'wraith', 'urban_nocturne'],
}
# How each choice resolves to an actual CSS value. Keeping this server-side
@@ -2514,15 +2805,23 @@ def bulk_rename_apply_api():
def settings_page():
"""Render the display-settings page."""
current = load_settings()
named_presets = sorted(
(t for t in SETTINGS_CHOICES['theme'] if t not in ('dark', 'light')),
# Grouped for the dropdown as <optgroup>s: the two base themes, the
# original hand-picked editor-inspired presets, then the Figma-derived
# website-scheme themes under their own source-page categories (see
# WEBSITE_SCHEME_CATEGORIES) - a flat 65-option list would be unusable.
website_scheme_keys = {k for keys in WEBSITE_SCHEME_CATEGORIES.values() for k in keys}
editor_presets = sorted(
SETTINGS_CHOICES['theme'] - {'dark', 'light'} - website_scheme_keys,
key=lambda t: THEME_DISPLAY_NAMES[t]
)
theme_groups = [('Base', ['dark', 'light']), ('Editor Themes', editor_presets)]
for category, keys in WEBSITE_SCHEME_CATEGORIES.items():
theme_groups.append((category, sorted(keys, key=lambda t: THEME_DISPLAY_NAMES[t])))
return render_template(
'settings.html',
settings=current,
default_library_path=LIBRARY_PATH,
theme_choices=['dark', 'light'] + named_presets,
theme_groups=theme_groups,
theme_display_names=THEME_DISPLAY_NAMES,
font_family_choices=['system', 'sans', 'serif', 'monospace', 'monaspace'],
font_size_choices=['small', 'medium', 'large', 'xlarge'],
+5 -1
View File
@@ -348,9 +348,13 @@
<span class="setting-desc">Overall light/dark palette</span>
</label>
<select id="theme" data-setting="theme">
{% for choice in theme_choices %}
{% for group_label, choices in theme_groups %}
<optgroup label="{{ group_label }}">
{% for choice in choices %}
<option value="{{ choice }}" {% if settings.theme == choice %}selected{% endif %}>{{ theme_display_names[choice] }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div class="setting-row">
+235
View File
@@ -0,0 +1,235 @@
"""
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']}")
File diff suppressed because one or more lines are too long