AI-Pricing-Agent/dashboard/theme.py

155 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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 p10p90 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'<span style="background:{hexcolor}17;color:{INK};border:1px solid {hexcolor}55;'
f'border-radius:999px;padding:3px 11px;font-size:0.78rem;font-weight:600;'
f'white-space:nowrap;margin-right:6px;display:inline-block;margin-bottom:4px">{text}</span>'
)
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'<div class="tl-n">{note}</div>' if note else ""
ic_style = f' style="background:{accent}"' if accent else ""
return (
f'<div class="tl"><div class="tl-ic"{ic_style}>{icon}</div>'
f'<div class="tl-v">{value}</div><div class="tl-l">{label}</div>{note_html}</div>'
)
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"