Skip to main content

tokenburn_web/
style.rs

1//! Stylesheet served at `/style.css`.
2//!
3//! Responsive on the same breakpoints as the TUI and the GUI (`LayoutClass` in
4//! `tokenburn-core`): **wide** ≥ 980 px · **medium** ≥ 700 px · **narrow** below.
5//! Medium stacks the charts and drops the cache columns; narrow also compacts the
6//! header and controls, keeps only the essential columns and enlarges the chart text.
7
8pub const CSS: &str = r#"
9:root { color-scheme: dark; --bg:#14161f; --panel:#1c1f2b; --line:#2b3042; --fg:#d8dcea; --dim:#8b92aa; --accent:#7aa2f7; --ok:#9ece6a; --warn:#e0af68; --err:#f7768e; --fg2:#e0af68; --sel:#232840; --s0:#7aa2f7; --s1:#bb9af7; --s2:#e0af68; --s3:#9ece6a; --s4:#f7768e; --s5:#7dcfff; --s6:#ff9e64; --s7:#a9b1d6; }
10* { box-sizing: border-box; }
11body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; }
12main { max-width:1200px; margin:0 auto; padding:24px; display:flex; flex-direction:column; gap:20px; }
13header.top { display:flex; flex-wrap:wrap; align-items:center; gap:16px; justify-content:space-between; }
14h1 { margin:0; font-size:22px; color:var(--accent); }
15h2 { margin:0 0 8px; font-size:14px; color:var(--dim); text-transform:uppercase; letter-spacing:.06em; }
16form.filters { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
17select, button { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 10px; font:inherit; }
18button { cursor:pointer; border-color:var(--accent); }
19.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:10px; }
20.card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px; }
21.card .label { color:var(--dim); font-size:12px; }
22.card .value { font-size:22px; font-variant-numeric:tabular-nums; }
23.card.cost .value { color:var(--ok); }
24table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line); border-radius:8px; overflow:hidden; }
25th, td { padding:8px 12px; text-align:right; font-variant-numeric:tabular-nums; border-bottom:1px solid var(--line); }
26th { color:var(--accent); font-weight:600; background:var(--sel); }
27th:first-child, td:first-child, td.left, th.left { text-align:left; }
28tr.total td { color:var(--warn); font-weight:700; }
29td.cost { color:var(--ok); }
30.bar { width:140px; height:8px; background:var(--line); border-radius:4px; overflow:hidden; display:inline-block; }
31.bar > span { display:block; height:100%; background:var(--accent); }
32tr:hover td { background:var(--sel); }
33.empty, .warn { padding:16px; background:var(--panel); border:1px solid var(--line); border-radius:8px; color:var(--dim); }
34.warn { color:var(--warn); }
35.badge { padding:2px 8px; border-radius:10px; border:1px solid var(--line); }
36.badge.live { color:var(--ok); border-color:var(--ok); }
37.filters { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
38.charts { display:grid; grid-template-columns:3fr 2fr; gap:16px; }
39.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px 14px; }
40.panel h2 { margin:0 0 6px; }
41svg.chart { display:block; max-width:100%; height:auto; overflow:visible; }
42svg.chart text { font-family:inherit; }
43svg.chart text.axis { fill:var(--dim); font-size:12px; }
44svg.chart text.total { fill:var(--fg); font-size:17px; }
45svg.chart text.empty-note { fill:var(--dim); font-size:14px; }
46svg.chart line.grid { stroke:var(--line); stroke-width:1; }
47svg.chart a { cursor:pointer; }
48
49/* ── bars: click to select a bucket ── */
50.bars .col { cursor:pointer; transition:opacity .2s ease; }
51.bars .col .hit { fill:transparent; }
52.bars .col .seg { transition:y .45s ease, height .45s ease, filter .15s ease; }
53.bars .col .outline { fill:none; stroke:var(--accent); stroke-width:2; opacity:0; pointer-events:none; transition:opacity .15s ease; }
54.bars .col:hover .seg { filter:brightness(1.2); }
55.bars .col:hover .outline { opacity:.45; }
56.bars .col.sel .outline { opacity:1; }
57.bars.has-sel .col:not(.sel) { opacity:.35; }
58.bars.has-sel .col:not(.sel):hover { opacity:.75; }
59
60/* ── donut: click to select a project ── */
61.donut .slice { cursor:pointer; transition:transform .22s cubic-bezier(.2,.8,.3,1), opacity .2s ease; }
62.donut .slice.other { cursor:default; }
63.donut .slice:not(.other):hover, .donut .slice.sel { transform:translate(calc(var(--dx) * 1px), calc(var(--dy) * 1px)); }
64.donut .slice path { transition:filter .15s ease; }
65.donut .slice:not(.other):hover path { filter:brightness(1.15); }
66.donut .slice.sel path { stroke:var(--accent); stroke-width:1.5; }
67.donut.has-sel .slice:not(.sel) { opacity:.35; }
68
69/* ── entry animation: bars grow up from the axis, the ring sweeps round ── */
70@keyframes bar-grow { from { transform:scaleY(0); } to { transform:scaleY(1); } }
71@keyframes ring-sweep { from { stroke-dashoffset:1; } to { stroke-dashoffset:0; } }
72@keyframes fade-in { from { opacity:0; } to { opacity:1; } }
73.bars.animate .col.anim { transform-box:fill-box; transform-origin:50% 100%; animation:bar-grow .6s cubic-bezier(.2,.8,.25,1) both; animation-delay:var(--d, 0ms); }
74.donut.animate .sweep { stroke-dasharray:1; stroke-dashoffset:1; animation:ring-sweep .9s cubic-bezier(.3,.7,.2,1) forwards; }
75.donut.animate text.total { animation:fade-in .6s ease .3s both; }
76@media (prefers-reduced-motion: reduce) {
77  .bars.animate .col.anim, .donut.animate .sweep, .donut.animate text.total { animation:none; }
78  .donut .sweep { stroke-dasharray:none; stroke-dashoffset:0; }
79  .bars .col, .bars .col .seg, .donut .slice { transition:none; }
80}
81.donut .sweep { stroke-dasharray:1; stroke-dashoffset:0; }
82
83/* ── selection bar and legends ── */
84.selbar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin:-8px 0 0; font-size:12px; color:var(--dim); min-height:26px; }
85.selbar .chip { background:var(--sel); border:1px solid var(--line); border-radius:12px; padding:1px 10px; color:var(--fg); }
86.selbar .clear { cursor:pointer; background:var(--panel); color:var(--fg); border:1px solid var(--accent); border-radius:6px; padding:1px 8px; font:inherit; text-decoration:none; }
87.legend .item { cursor:pointer; padding:1px 6px; margin:0 -6px; border-radius:4px; transition:background .15s ease, opacity .2s ease; }
88.legend .item:hover { background:var(--sel); }
89.legend .item.sel { background:var(--sel); outline:1px solid var(--accent); }
90.legend .item.other { cursor:default; }
91.legend .item.other:hover { background:none; }
92.legend .item a { color:inherit; text-decoration:none; display:block; }
93.legend.has-sel .item:not(.sel) { opacity:.45; }
94.legend { list-style:none; margin:6px 0 0; padding:0; display:flex; flex-wrap:wrap; gap:4px 14px; font-size:12px; color:var(--dim); }
95.legend.vertical { flex-direction:column; flex-wrap:nowrap; margin:0; gap:6px; color:var(--fg); }
96.legend .sw { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:6px; }
97.legend .pct { color:var(--dim); margin-left:6px; }
98.donut-row { display:flex; align-items:center; gap:16px; }
99.donut-box { width:190px; flex:none; }
100footer { color:var(--dim); font-size:12px; }
101a { color:var(--accent); }
102
103/* ── responsive ── */
104.table-wrap { overflow-x:auto; border-radius:8px; }
105.table-wrap table { min-width:100%; }
106.donut-box svg { max-width:240px; margin:0 auto; }
107html { -webkit-text-size-adjust:100%; }
108.legend.vertical { min-width:0; }
109
110/* medium: below 980 px — charts stack, cache columns drop out */
111@media (max-width:979px) {
112  .charts { grid-template-columns:1fr; }
113  .opt1 { display:none; }
114  .cards { grid-template-columns:repeat(4, minmax(0,1fr)); }
115  .donut-row { justify-content:center; }
116}
117
118/* narrow: below 700 px — compact header and controls, essential columns only */
119@media (max-width:699px) {
120  main { padding:12px 10px; gap:14px; }
121  header.top { flex-direction:column; align-items:stretch; gap:10px; }
122  header.top h1 { font-size:20px; }
123  form.filters, .filters { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:8px 10px; }
124  .filters label { display:flex; flex-direction:column; gap:2px; font-size:12px; color:var(--dim); min-width:0; }
125  .filters select { width:100%; min-width:0; }
126  .cards { grid-template-columns:repeat(2, minmax(0,1fr)); }
127  .card .value { font-size:19px; }
128  .card.cost { grid-column:1 / -1; }
129  .opt2 { display:none; }
130  th, td { padding:7px 8px; }
131  .panel { padding:10px; }
132  .donut-row { flex-direction:column; align-items:stretch; gap:8px; }
133  .donut-box { width:min(220px, 70vw); margin:0 auto; }
134  .legend.vertical { flex-direction:row; flex-wrap:wrap; gap:4px 12px; }
135  .selbar { margin:0; }
136  /* The SVG is scaled to the screen, so its text must be bigger and sparser. */
137  svg.chart text.axis { font-size:22px; }
138  svg.chart text.axis.alt { display:none; }
139  svg.chart text.total { font-size:20px; }
140  .bars .col .outline { stroke-width:3; }
141  footer { word-break:break-word; }
142}
143"#;
144
145#[cfg(test)]
146mod tests {
147    use super::CSS;
148    use tokenburn_core::utils::layout::{MEDIUM_PX, WIDE_PX};
149
150    #[test]
151    fn the_media_queries_use_the_shared_breakpoints() {
152        // max-width is inclusive, so "below WIDE" is WIDE - 1.
153        assert!(CSS.contains(&format!("@media (max-width:{}px)", WIDE_PX as u32 - 1)));
154        assert!(CSS.contains(&format!("@media (max-width:{}px)", MEDIUM_PX as u32 - 1)));
155    }
156
157    #[test]
158    fn narrow_screens_hide_the_secondary_columns_and_enlarge_the_chart_text() {
159        let narrow = CSS.split("@media (max-width:699px)").nth(1).unwrap();
160        assert!(narrow.contains(".opt2 { display:none; }"));
161        assert!(narrow.contains("svg.chart text.axis { font-size:22px; }"));
162        assert!(narrow.contains("svg.chart text.axis.alt { display:none; }"));
163        let medium = CSS.split("@media (max-width:979px)").nth(1).unwrap();
164        assert!(
165            medium.contains(".opt1 { display:none; }")
166                && medium.contains(".charts { grid-template-columns:1fr; }")
167        );
168    }
169
170    #[test]
171    fn wide_screens_never_hide_anything() {
172        // The hiding rules live only inside the media queries.
173        let before_queries = CSS.split("/* medium: below 980 px").next().unwrap();
174        assert!(!before_queries.contains(".opt1 { display:none"));
175        assert!(!before_queries.contains(".opt2 { display:none"));
176    }
177}