首页加夜晚模式:右上角月亮/太阳切换,样式由 tools/gen-night.py 自动翻色 (v26100460)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 11:48:54 +00:00
parent e9cfa10fca
commit 57e845eb2b
4 changed files with 1568 additions and 2 deletions

220
tools/gen-night.py Normal file
View File

@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""夜晚模式样式生成器:读 mama-box.css,把浅色底 / 深色字自动翻成夜色,写进 night.css 的「自动生成」段。
为什么用生成而不是手写:mama-box.css 有 4700 行、几百处写死的 #fff / 暖白,手写一份夜间版
既漏得多、又会和日间版各改各的。这里只做「颜色翻转」这一件机械的事:
· 背景 / 边框类属性里,亮且不太艳的颜色(暖白、米色、浅灰线)→ 同色相的深色(卡片比页面底略亮);
· color / fill / stroke 里,暗的颜色(正文墨色、灰字)→ 同色相的浅色;
· 品牌橙、工具卡的绿 / 紫 / 蓝这些高饱和色、白字、透明度低的玻璃白,一律不动。
每条规则原样复制选择器、前面加 html.night,所以优先级比日间版恰好高一级,顺序关系不变。
自动翻不好的地方写在 night.css 的「手调」段(在生成段之后,同优先级后写的赢)。
用法:python3 tools/gen-night.py [mama-box.css] 改了 mama-box.css 的颜色后重跑一次。
"""
import re, sys, colorsys, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
SRC = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / 'mama-box.css'
OUT = ROOT / 'night.css'
MARK_A = '/* ==== 自动生成段:tools/gen-night.py 写入,别手改 ==== */'
MARK_B = '/* ==== 自动生成段结束 ==== */'
BG_PROPS = {'background', 'background-color', 'background-image', 'border', 'border-color', 'border-top',
'border-bottom', 'border-left', 'border-right', 'border-top-color', 'border-bottom-color',
'border-left-color', 'border-right-color', 'outline', 'outline-color', 'box-shadow', 'caret-color'}
FG_PROPS = {'color', 'fill', 'stroke', '-webkit-text-fill-color', 'text-decoration-color'}
SKIP_SEL = re.compile(r'home-hero|hero-|\.hi-|\.hd-|\.letter-card|zd-|ar-hero|page-hero|::selection|\.skip')
COL = re.compile(r'#([0-9a-fA-F]{3,8})\b|rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)')
def parse_hex(h):
if len(h) in (3, 4):
h = ''.join(c * 2 for c in h)
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
a = int(h[6:8], 16) / 255 if len(h) == 8 else 1
return r, g, b, a
def fmt(r, g, b, a):
R, G, B = (round(max(0, min(1, x)) * 255) for x in (r, g, b))
return f'#{R:02X}{G:02X}{B:02X}' if a >= .999 else f'rgba({R},{G},{B},{round(a, 3)})'
NIGHT_H = 280 / 360
def bg_map(r, g, b, a):
h, l, s = colorsys.rgb_to_hls(r, g, b)
if l < .80: # 艳色 / 中间调:品牌色、工具卡底,不动(浅粉浅黄这类高饱和的淡色照样翻)
return None
if a < .2: # 几乎透明的白:只是渐变收尾,不动
return None
nl = max(.115, .17 - (1 - l) * .5) # #fff→.17(卡片) 米色浅底 .93→.135 浅线→.12;页底另由 --bg 定
if s < .3: # 近中性的暖白 / 灰:统一染成夜色的一点紫,不然一片死灰
h, ns = NIGHT_H, .10
else:
ns = min(s, .5) * .45
return fmt(*colorsys.hls_to_rgb(h, nl, ns), a)
def fg_map(r, g, b, a):
h, l, s = colorsys.rgb_to_hls(r, g, b)
if l > .55 or (s > .55 and l > .3): # 已经够亮 / 艳色强调字,不动
return None
nl = min(.93, .95 - l * .55) # 墨色 .15→.87 灰字 .42→.72
ns = min(s, .3) * .5
if s < .3:
h, ns = 25 / 360, .12 # 字色带一点暖,跟日间的暖墨色同一气质
return fmt(*colorsys.hls_to_rgb(h, nl, ns), a)
def vivid(val):
"""这条背景里有没有「主色」(品牌橙、工具卡的绿紫蓝):有就整条不动,否则渐变里的白高光被翻黑、主色还在,成了脏灰。"""
for m in COL.finditer(val):
if m.group(1):
if len(m.group(1)) not in (3, 4, 6, 8):
continue
r, g, b, a = parse_hex(m.group(1))
else:
r, g, b = (float(m.group(i)) / 255 for i in (2, 3, 4))
a = m.group(5); a = 1 if a is None else (float(a[:-1]) / 100 if a.endswith('%') else float(a))
h, l, s = colorsys.rgb_to_hls(r, g, b)
if a > .5 and s > .45 and .3 < l < .78:
return True
return False
def recolor(val, fn):
changed = False
def sub(m):
nonlocal changed
if m.group(1):
if len(m.group(1)) not in (3, 4, 6, 8):
return m.group(0)
c = parse_hex(m.group(1))
else:
a = m.group(5)
a = 1 if a is None else (float(a[:-1]) / 100 if a.endswith('%') else float(a))
c = (float(m.group(2)) / 255, float(m.group(3)) / 255, float(m.group(4)) / 255, a)
n = fn(*c)
if n is None:
return m.group(0)
changed = True
return n
out = COL.sub(sub, val)
return out if changed else None
def map_keyword(prop, val):
v = val.strip().lower().replace('!important', '').strip()
if prop in BG_PROPS and v in ('white',):
return val.lower().replace('white', '#2A2530')
return None
def strip_comments(s):
return re.sub(r'/\*.*?\*/', '', s, flags=re.S)
def split_decls(body):
out, depth, cur = [], 0, ''
for ch in body:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if ch == ';' and depth == 0:
out.append(cur); cur = ''
else:
cur += ch
out.append(cur)
return [d.strip() for d in out if d.strip()]
def prefix_sel(sel):
parts, depth, cur = [], 0, ''
for ch in sel:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if ch == ',' and depth == 0:
parts.append(cur); cur = ''
else:
cur += ch
parts.append(cur)
res = []
for p in parts:
p = p.strip()
if p.startswith(':root'):
continue
if re.match(r'html\b', p):
p = 'html.night' + p[4:]
else:
p = 'html.night ' + p
res.append(p)
return ','.join(res)
def walk(css):
"""返回 [(prelude 或 None, 内容)]。嵌套 @media / @supports 递归,@keyframes / @font-face 跳过。"""
i, n, out = 0, len(css), []
while i < n:
j = css.find('{', i)
if j < 0:
break
pre = css[i:j].strip()
depth, k = 1, j + 1
while k < n and depth:
if css[k] == '{':
depth += 1
elif css[k] == '}':
depth -= 1
k += 1
body = css[j + 1:k - 1]
i = k
if pre.startswith('@'):
if re.match(r'@(media|supports)', pre):
inner = walk(body)
if inner:
out.append(pre + '{\n' + '\n'.join(inner) + '\n}')
continue
# ⚠️ 所有规则的**所有**颜色声明(含没翻的)都抄一份:
# 日间版里后写的规则会盖掉先写的;只抄翻过色的那几条,先写的规则加了 html.night 反而赢过后写的(踩过:工具卡变灰)。
# 同理,头图那些不翻色的规则也照抄,免得被前面翻过色的通用规则压住。
skip = bool(SKIP_SEL.search(pre))
changed = skip
decls = []
for d in split_decls(body):
if ':' not in d or d.startswith('--'):
continue
prop, val = d.split(':', 1)
prop = prop.strip().lower()
if prop not in BG_PROPS and prop not in FG_PROPS:
continue
nv = None
if not skip and prop in BG_PROPS and not vivid(val):
nv = recolor(val, bg_map) or map_keyword(prop, val)
elif not skip and prop in FG_PROPS:
nv = recolor(val, fg_map)
if nv:
changed = True
decls.append(f'{prop}:{(nv or val).strip()}')
sel = prefix_sel(pre)
if decls and sel: # 没翻色的也抄:它可能是在覆盖前面某条翻过色的规则(工具卡彩色底就是这么丢的)
out.append(sel + '{' + ';'.join(decls) + '}')
return out
rules = walk(strip_comments(SRC.read_text()))
gen = MARK_A + '\n' + '\n'.join(rules) + '\n' + MARK_B
old = OUT.read_text() if OUT.exists() else ''
if MARK_A in old:
a, b = old.index(MARK_A), old.index(MARK_B) + len(MARK_B)
new = old[:a] + gen + old[b:]
else:
new = old + '\n' + gen + '\n'
OUT.write_text(new)
print(f'{len(rules)} 条规则 → {OUT}')