155 lines
6.1 KiB
Python
155 lines
6.1 KiB
Python
"""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'<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"
|