Tidemill Design System.
A warm orange-based palette designed for financial SaaS dashboards. One source of truth — mirrored across the frontend, the Plotly report style, the documentation theme, and the logo set.
Three swept blades. One amber hub.
The mark is a three-blade turbine — tide and mill, evoked in a single rotation. Orange-to-amber gradients on the upper and right blades, a single amber-to-amber blade for the lower-left accent, all spinning around a solid #EA580C hub.
| Name | Hex | Usage | |
|---|---|---|---|
| Amber 500 PRIMARY | #F59E0B | Primary brand · UI primary · colorway lead | |
| Orange 600 | #EA580C | Hub fill · favicon · past-due status | |
| Orange 500 | #F97316 | Logo blade gradient start · wordmark | |
| Orange 700 | #C2410C | Logo blade gradient end | |
| Amber 600 | #D97706 | Logo accent blade gradient end |
Single source of truth, four scales.
Implemented in frontend/src/lib/colors.ts, frontend/src/index.css, and tidemill/reports/_style.py. Every chart, every report, every component reads from the same constants.
Semantic — charts & metrics
9 colors mapped to specific roles| Role | Hex | Mapped to | |
|---|---|---|---|
| Positive / Growth | #16A34A | new · active · converted · GRR | |
| Expansion | #2563EB | expansion · NRR | |
| Contraction | #EAB308 | contraction | |
| Churn / Negative | #DC2626 | churn · canceled · expired · logo churn | |
| Special | #8B5CF6 | reactivation · ARPU | |
| Brand accent | #F59E0B | trialing · revenue churn | |
| Warning | #EA580C | past due | |
| Neutral | #78716C | starting MRR · pending · grey | |
| Dark | #1C1917 | ending MRR |
Multi-series colorway
Default cycle for n-series charts, in orderSequential scale — heatmaps
Orange ramp used by Plotlycolorscale_sequentialCohort retention buckets
Discrete · green→red#DCFCE7
≥ 70% #BBF7D0
≥ 50% #FEF08A
≥ 30% #FED7AA
< 30% #FECACA
Neutral tones — stone
Warm grays for text, grids, borders| Hex | Tailwind | Usage | |
|---|---|---|---|
| #E7E5E4 | stone-200 | Grid lines | |
| #D6D3D1 | stone-300 | Axis lines | |
| #78716C | stone-500 | Secondary text · neutral data | |
| #44403C | stone-700 | Body text · hover labels | |
| #1C1917 | stone-900 | Titles · headings |
UI theme — CSS custom properties
frontend/src/index.css| Property | Value | Purpose | |
|---|---|---|---|
| --color-primary | #F59E0B | Buttons · links · focus rings | |
| --color-primary-foreground | #FFFFFF | Text on primary | |
| --color-accent | #FFFBEB | Hover / highlight backgrounds | |
| --color-accent-foreground | #1C1917 | Text on accent | |
| --color-destructive | #DC2626 | Danger actions | |
| --color-ring | #F59E0B | Focus outlines | |
| --color-border | #E5E5E5 | Default borders | |
| --color-muted | #F5F5F5 | Page background |
Inter, set tight.
A single typeface — Inter — for headlines, body, and UI. Numerals are tabular by default; titles use stone-900 with negative letter-spacing; secondary text settles into stone-700. Monospace appears only where developers need to read literals: code, endpoints, eyebrows, metric values.
4-px base, three radii.
Components step in 4-pixel increments up to 32, then jump to 48, 72, and 112 for section-level rhythm. Radii are inherited from the frontend: --radius-sm · --radius-md · --radius-lg.
Semantic colors carry the story.
Charts always reach for the semantic palette first — green for growth, red for churn, blue for expansion, amber for the brand series. The multi-series colorway is only used when a chart has no inherent meaning to its categories.
MRR waterfall · semantic mappingMRR Waterfall · Nov 2025 → Apr 2026
LIVE DEMO DATAA small kit, composed from tokens.
Every component below is rendered from the canonical CSS variables. Adjust a token in tokens.css and the whole page updates.
Transparent by construction
Every metric is a Python class. Read the formula, read the SQL.
Pluggable connectors
Stripe webhooks. QuickBooks. Same-database mode. Add one in < 200 LOC.
Yours to run
Docker Compose on a €4/mo Hetzner box, or a k3s HA cluster.
# Active recurring revenue at time t. @register class MRR(Metric): async def query(self, spec: QuerySpec) -> Select: c = self.cube return spec.fragment( select=[c.sub.mrr_base_cents.sum().label("mrr")], where=[c.sub.is_active_at(spec.at)], )
Every metric, with its formula.
Six metric families ship today. Each one is a separate module with its own tables, event handlers, and API routes — adding a new metric requires zero changes to existing code.