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