"""Deterministic noir pixel palettes derived from a descriptor accent + seed. Calm, professional, muted - dark slate base, warm parchment, a single accent. No neon. Everything is computed (no assets), so portraits are instant and offline. """ from __future__ import annotations import hashlib from dataclasses import dataclass RGBA = tuple[int, int, int, int] _INK: RGBA = (18, 20, 28, 255) _BG: RGBA = (20, 22, 31, 255) _PANEL: RGBA = (33, 36, 49, 255) _PARCHMENT: RGBA = (217, 200, 160, 255) _SKIN_TONES: tuple[RGBA, ...] = ( (224, 187, 153, 255), (198, 156, 121, 255), (165, 124, 92, 255), (122, 90, 66, 255), (90, 66, 50, 255), ) _HAIR_TONES: tuple[RGBA, ...] = ( (35, 28, 24, 255), (62, 44, 32, 255), (120, 110, 105, 255), (95, 70, 45, 255), ) @dataclass(frozen=True) class Palette: bg: RGBA panel: RGBA ink: RGBA parchment: RGBA skin: RGBA hair: RGBA cloth: RGBA cloth_dark: RGBA accent: RGBA def seed_from(*parts: str) -> int: digest = hashlib.sha256("|".join(parts).encode("utf-8")).digest() return int.from_bytes(digest[:4], "big") def _hex_to_rgba(value: str, fallback: RGBA) -> RGBA: value = value.strip().lstrip("#") if len(value) != 6: return fallback try: return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16), 255) except ValueError: return fallback def _darken(color: RGBA, factor: float = 0.6) -> RGBA: return (int(color[0] * factor), int(color[1] * factor), int(color[2] * factor), 255) def build_palette(seed: int, accent_hex: str | None) -> Palette: accent = _hex_to_rgba(accent_hex or "", (184, 134, 11, 255)) skin = _SKIN_TONES[seed % len(_SKIN_TONES)] hair = _HAIR_TONES[(seed >> 3) % len(_HAIR_TONES)] return Palette( bg=_BG, panel=_PANEL, ink=_INK, parchment=_PARCHMENT, skin=skin, hair=hair, cloth=accent, cloth_dark=_darken(accent, 0.55), accent=accent, )