"""Design tokens + plotly template — CRAI / Utopia Brands design system. Palette lifted from crai.utopiabrands.com (auth.css / app.css): warm cream paper, off-white cards, teal primary, coral accent, navy chrome, Inter type. """ import plotly.graph_objects as go import plotly.io as pio # --- brand (chrome only — never a data series color) --- BRAND = "#0c8276" # primary teal (buttons, active states) BRAND_DARK = "#0a6f65" BRAND_SOFT = "#d9eee9" # tinted backgrounds (icons, active states) CORAL = "#df4f33" # accent / danger CORAL_SOFT = "#fbe6df" NAVY = "#22304e" # dark chrome (logo gradient start) PLUM = "#7a4a8c" # --- chrome & ink (light mode, warm-cream look) --- SURFACE = "#fffdf9" # cards + chart surface PAGE = "#f4f0e8" # page plane (warm paper) INK = "#1b2030" INK2 = "#4a4f60" MUTED = "#7c8092" GRID = "#ece6d9" AXIS = "#d6cfbf" BORDER = "#e3ddd0" DELTA_GOOD = "#0a6f65" # --- categorical series (fixed slot order — color follows the entity) --- SERIES = { "us": "#22304e", # slot 1 navy: our price / our units "competitor": "#df4f33", # slot 2 coral: competitor median "alt": "#0c8276", # slot 3 teal: secondary measure "gray": "#7c8092", # individual competitors (muted, non-slot) } US_BAND = "rgba(34,48,78,0.13)" # forecast p10–p90 band fill # --- inventory cover bands (COSMOS Inventory Planning) ----------------------------- # The same five bands and the same colours the INVP grid uses, so a number that reads green # there does not read amber here. Alpha and Beta are COSMOS's own velocity classes and they # band differently — a Beta SKU is judged against tighter thresholds than an Alpha. # # Purple = too little cover, green = healthy, red = too much. Colour is never the only # signal: every use pairs it with the number and a word. # # These are DISPLAY bands. They are deliberately NOT the pricing thresholds # (LOW_COVER_DAYS 35 / HIGH_COVER_DAYS 90 in live_data.py), which trigger price moves and # are a separate policy question — COSMOS's own API default is a 60-day REPLENISHMENT # target, not a pricing trigger. Keeping them apart is intentional; collapsing them would # silently re-tune the cascade. COVER_BANDS = { "alpha": [(20, "#dcd0ec", "very low"), (40, "#b9a3dc", "low"), (70, "#77e800", "healthy"), (100, "#fadbd9", "high"), (None, "#fb5b57", "excess")], "beta": [(15, "#dcd0ec", "very low"), (30, "#b9a3dc", "low"), (60, "#77e800", "healthy"), (90, "#fadbd9", "high"), (None, "#fb5b57", "excess")], } def cover_band(days: float | None, inv_class: str = "alpha") -> tuple[str, str]: """`(hex colour, label)` for a cover-days figure, on COSMOS's own banding. An unknown cover gets a neutral swatch and the word "unknown" rather than a colour that would imply a judgement we have not made. """ if days is None: return (BORDER, "unknown") bands = COVER_BANDS.get((inv_class or "alpha").lower(), COVER_BANDS["alpha"]) for upper, colour, label in bands: if upper is None or days < upper: return (colour, label) return bands[-1][1], bands[-1][2] # --- status palette (reserved; always icon + label, never color alone) --- STATUS = { "good": "#0c8276", "warning": "#cf971f", "serious": "#df4f33", "critical": "#a83a24", "neutral": "#7c8092", } CONFIDENCE = { # tier -> (status key, dot emoji for labels) "High": ("good", "🟢"), "Medium": ("warning", "🟡"), "Low": ("serious", "🟠"), "Insufficient": ("neutral", "⚪"), } REC_STATUS = { # workflow status -> (status key, icon) "Pending": ("warning", "⏳"), "Approved": ("good", "✅"), "Rejected": ("neutral", "✖️"), } def chip(text: str, hexcolor: str) -> str: """Soft pill chip; text stays readable ink-on-tint (never color-alone meaning).""" return ( f'{text}' ) def conf_badge(tier: str, score: int) -> str: key, dot = CONFIDENCE[tier] return chip(f"{dot} {tier} confidence ({score})", STATUS[key]) def status_badge(status: str) -> str: key, icon = REC_STATUS[status] return chip(f"{icon} {status}", STATUS[key]) def tile(icon: str, value: str, label: str, note: str = "", accent: str | None = None) -> str: """Off-white stat card with a teal-tinted icon square (CRAI style). `accent` tints the icon square only — a band colour marks the reading without repainting the card, so the tiles still read as one set and the colour never becomes the only signal (the band's word travels in `note`). """ note_html = f'
{note}
' if note else "" ic_style = f' style="background:{accent}"' if accent else "" return ( f'
{icon}
' f'
{value}
{label}
{note_html}
' ) def register_template() -> None: tpl = go.layout.Template() tpl.layout = go.Layout( paper_bgcolor=SURFACE, plot_bgcolor=SURFACE, font=dict(family='Inter, system-ui, -apple-system, "Segoe UI", sans-serif', color=INK, size=12), colorway=[SERIES["us"], SERIES["competitor"], SERIES["alt"]], xaxis=dict(gridcolor=GRID, linecolor=AXIS, zerolinecolor=AXIS, tickcolor=AXIS, tickfont=dict(color=MUTED)), yaxis=dict(gridcolor=GRID, linecolor=AXIS, zerolinecolor=AXIS, tickcolor=AXIS, tickfont=dict(color=MUTED)), legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0, font=dict(color=INK2, size=11)), margin=dict(l=10, r=10, t=42, b=10), hovermode="x unified", hoverlabel=dict(bgcolor=SURFACE, font=dict(color=INK, size=11), bordercolor=AXIS), ) pio.templates["pricing"] = tpl pio.templates.default = "pricing"