1use tokenburn_core::utils::fmt::thousands;
4use tokenburn_core::{
5 Bucket, BucketSavings, CURRENCIES, Currency, Metric, Query, ReportRow, Savings, Summary,
6 ToolFilter, Window,
7};
8use topcoat::{
9 Result,
10 runtime::{Event, Signal},
11 view::{View, component, view},
12};
13
14use crate::charts::{self, Bars, Col, SliceGeom};
15use crate::data::{
16 Dashboard, MAX_BUCKET_ROWS, bucket_noun, bucket_rows, savings_rows, share_percent,
17};
18use crate::params::WINDOW_CHOICES;
19use crate::theme;
20use tokenburn_core::Selection;
21
22#[component]
24pub async fn brand(active: String) -> Result<impl View> {
25 let pick = |name: &str| if active == name { "tab active" } else { "tab" };
26 let (c_dash, c_set, c_about) = (pick("dashboard"), pick("settings"), pick("about"));
27 Ok(view! {
28 <div class="brand">
29 <h1><a href="/" style="color:inherit">"🔥 TokenBurn"</a> <small class="ver">(tokenburn_core::appinfo::version_label())</small></h1>
30 <nav class="tabs" aria-label="Pages">
31 <a class=(c_dash) href="/">"Dashboard"</a>
32 <a class=(c_set) href="/settings">"⚙ Settings"</a>
33 <a class=(c_about) href="/about">"ℹ About"</a>
34 </nav>
35 </div>
36 })
37}
38
39#[component]
42pub async fn limits_panel(
43 check: &tokenburn_core::LimitCheck,
44 currency: Currency,
45) -> Result<impl View> {
46 let rows: Vec<(String, String, u32, &'static str)> = check
47 .statuses
48 .iter()
49 .map(|s| {
50 (
51 format!(
52 "{} · {} of {} ({}%)",
53 s.period.label(),
54 currency.format(s.spent_usd),
55 currency.format(s.limit_usd),
56 s.percent()
57 ),
58 match s.level {
59 tokenburn_core::Level::Ok => String::new(),
60 tokenburn_core::Level::Warn => "⚠ nearly reached".to_string(),
61 tokenburn_core::Level::Over => "🚨 exceeded".to_string(),
62 },
63 crate::data::bar_percent(s),
64 crate::data::level_class(s.level),
65 )
66 })
67 .collect();
68 Ok(view! {
69 if !rows.is_empty() {
70 <section class="limits">
71 for (label, note, pct, class) in &rows {
72 <div class=(*class)>
73 <div class="label">(label.clone()) " " <b>(note.clone())</b></div>
74 <progress max="100" value=(pct.to_string())>(pct.to_string()) "%"</progress>
75 </div>
76 }
77 </section>
78 }
79 })
80}
81
82#[component]
84pub async fn summary_cards(
85 metric: Metric,
86 total: Summary,
87 currency: Currency,
88 savings: &Savings,
89) -> Result<impl View> {
90 let cards = tokenburn_core::cards(metric, &total, savings, ¤cy);
92 Ok(view! {
93 <section class="cards">
94 for c in &cards {
95 <div class=(card_class(c.kind))><div class="label">(c.label.clone())</div><div class="value">(c.value.clone())</div></div>
96 }
97 </section>
98 })
99}
100
101pub fn card_class(kind: tokenburn_core::CardKind) -> &'static str {
103 match kind {
104 tokenburn_core::CardKind::Plain => "card",
105 tokenburn_core::CardKind::Cost => "card cost",
106 tokenburn_core::CardKind::Saved => "card saved",
107 }
108}
109
110pub fn chart_title(metric: Metric) -> &'static str {
112 match metric {
113 Metric::Tokens => "Tokens over time",
114 Metric::Cost => "Cost over time",
115 Metric::Saved => "Saved over time (cache + compaction)",
116 }
117}
118
119#[component]
122pub async fn savings_panel(
123 savings: &Savings,
124 currency: Currency,
125 spent_usd: f64,
126 input_price: f64,
127 buckets: &[BucketSavings],
128 noun: &str,
129) -> Result<impl View> {
130 let rows = savings_rows(savings, currency, spent_usd);
131 let per_bucket = bucket_rows(buckets, currency, MAX_BUCKET_ROWS);
132 let per_title = format!("Saved per {noun}");
133 let basis = format!(
134 "estimate · valued at {}/M input tokens",
135 currency.format(input_price)
136 );
137 Ok(view! {
138 if !rows.is_empty() {
139 <section class="savings">
140 <h2>"Saved " <small class="ver">(basis)</small></h2>
141 <div class="table-wrap"><table>
142 <thead>
143 <tr>
144 <th class="left">"source"</th>
145 <th>"tokens"</th>
146 <th>"saved"</th>
147 <th class="left opt2">"basis"</th>
148 </tr>
149 </thead>
150 <tbody>
151 for r in &rows {
152 <tr class=(if r.total { "total" } else { "" })>
153 <td class="left">(r.label.clone())</td>
154 <td>(r.tokens.clone())</td>
155 <td class="cost">(r.money.clone())</td>
156 <td class="left opt2">(r.basis.clone())</td>
157 </tr>
158 }
159 </tbody>
160 </table></div>
161 if !per_bucket.is_empty() {
162 <h2>(per_title)</h2>
163 <div class="table-wrap"><table class="per-bucket">
164 <thead>
165 <tr>
166 <th class="left">"interval"</th>
167 <th class="opt2">"tokens"</th>
168 <th>"saved"</th>
169 <th class="left opt2">"by source"</th>
170 </tr>
171 </thead>
172 <tbody>
173 for b in &per_bucket {
174 <tr>
175 <td class="left">(b.label.clone())</td>
176 <td class="opt2">(b.tokens.clone())</td>
177 <td class="cost">(b.money.clone())</td>
178 <td class="left opt2">(b.split.clone())</td>
179 </tr>
180 }
181 </tbody>
182 </table></div>
183 }
184 </section>
185 }
186 })
187}
188
189#[component]
191pub async fn filters(
192 query: Query,
193 theme: usize,
194 metric: Metric,
195 currency: Currency,
196) -> Result<impl View> {
197 Ok(view! {
198 <form class="filters" method="get" action="/">
199 <label>"chart "
200 <select name="metric" onchange="this.form.submit()">
201 for m in Metric::ALL {
202 <option value=(m.label()) selected=(m == metric)>(m.label())</option>
203 }
204 </select>
205 </label>
206 <label>"tool "
207 <select name="tool" onchange="this.form.submit()">
208 for t in ToolFilter::ALL {
209 <option value=(t.as_str()) selected=(t == query.tool)>(t.as_str())</option>
210 }
211 </select>
212 </label>
213 <label>"view "
214 <select name="bucket" onchange="this.form.submit()">
215 for b in Bucket::ALL {
216 <option value=(b.label()) selected=(b == query.bucket)>(b.label())</option>
217 }
218 </select>
219 </label>
220 <label>"window "
221 <select name="window" onchange="this.form.submit()">
222 for w in Window::ALL {
223 <option value=(w.label()) selected=(w == query.window)>(w.label())</option>
224 }
225 </select>
226 </label>
227
228 <label>"currency "
229 <select name="currency" onchange="this.form.submit()">
230 for cur in CURRENCIES {
231 <option value=(cur.code) selected=(cur.code == currency.code)>(format!("{} · {}", cur.code, cur.name))</option>
232 }
233 </select>
234 </label>
235 <label>"theme "
236 <select name="theme" onchange="this.form.submit()">
237 for n in theme::names() {
238 <option value=(n) selected=(n == theme::name(theme))>(n)</option>
239 }
240 </select>
241 </label>
242 <noscript><button type="submit">"Apply"</button></noscript>
243 </form>
244 })
245}
246
247#[component]
249pub async fn report_table(report: &[ReportRow], currency: Currency) -> Result<impl View> {
250 let bucketed = report.iter().any(|r| r.bucket.is_some());
251 let max = report
252 .iter()
253 .filter(|r| r.tool != "TOTAL")
254 .map(|r| r.summary.total_tokens)
255 .max()
256 .unwrap_or(0);
257
258 Ok(view! {
259 <div class="table-wrap"><table>
260 <thead>
261 <tr>
262 if bucketed { <th>"bucket"</th> }
263 <th class="left">"tool"</th>
264 <th class="opt2">"turns"</th>
265 <th class="opt2">"input"</th>
266 <th class="opt2">"output"</th>
267 <th class="opt1">"cache read"</th>
268 <th class="opt1">"cache write"</th>
269 <th>"total"</th>
270 <th class="left opt2">"share"</th>
271 <th>"cost"</th>
272 </tr>
273 </thead>
274 <tbody>
275 for r in report {
276 <tr class=(if r.tool == "TOTAL" { "total" } else { "" })>
277 if bucketed { <td>(r.bucket.clone().unwrap_or_default())</td> }
278 <td class="left">(r.tool.clone())</td>
279 <td class="opt2">(thousands(r.summary.turns))</td>
280 <td class="opt2">(thousands(r.summary.input))</td>
281 <td class="opt2">(thousands(r.summary.output))</td>
282 <td class="opt1">(thousands(r.summary.cache_read))</td>
283 <td class="opt1">(thousands(r.summary.cache_write))</td>
284 <td>(thousands(r.summary.total_tokens))</td>
285 <td class="left opt2">
286 if r.tool != "TOTAL" {
287 <span class="bar"><span style=(format!("width:{}%;background:{}", share_percent(r.summary.total_tokens, max), theme::series_var(&r.tool)))></span></span>
288 }
289 </td>
290 <td class="cost">(currency.format(r.summary.cost_usd))</td>
291 </tr>
292 }
293 </tbody>
294 </table></div>
295 })
296}
297
298#[component]
300pub async fn projects_table(
301 projects: &[(String, Summary)],
302 currency: Currency,
303) -> Result<impl View> {
304 let max = projects
305 .iter()
306 .map(|(_, s)| (s.cost_usd * 10_000.0) as u64)
307 .max()
308 .unwrap_or(0);
309
310 Ok(view! {
311 <div class="table-wrap"><table>
312 <thead>
313 <tr>
314 <th class="left">"tool:project/model"</th>
315 <th class="opt2">"turns"</th>
316 <th>"total tokens"</th>
317 <th class="left opt2">"share"</th>
318 <th>"cost"</th>
319 </tr>
320 </thead>
321 <tbody>
322 for (name, s) in projects {
323 <tr>
324 <td class="left">(name.clone())</td>
325 <td class="opt2">(thousands(s.turns))</td>
326 <td>(thousands(s.total_tokens))</td>
327 <td class="left opt2">
328 <span class="bar"><span style=(format!("width:{}%;background:{}", share_percent((s.cost_usd * 10_000.0) as u64, max), theme::series_var(name.split(':').next().unwrap_or(""))))></span></span>
329 </td>
330 <td class="cost">(currency.format(s.cost_usd))</td>
331 </tr>
332 }
333 </tbody>
334 </table></div>
335 })
336}
337
338#[component]
341pub async fn live_filters(
342 tool: &Signal<String>,
343 bucket: &Signal<String>,
344 window: &Signal<String>,
345 theme: &Signal<String>,
346 metric: &Signal<String>,
347 currency: &Signal<String>,
348) -> Result<impl View> {
349 let cur = currency.get_untracked();
350 let (t, b, w, th, m) = (
351 tool.get_untracked(),
352 bucket.get_untracked(),
353 window.get_untracked(),
354 theme.get_untracked(),
355 metric.get_untracked(),
356 );
357 Ok(view! {
358 <div class="filters">
359 <label>"chart "
360 <select :value=$(metric.get()) @change=$(|e: Event| metric.set(e.target.value))>
361 for x in Metric::ALL {
362 <option value=(x.label()) selected=(x.label() == m)>(x.label())</option>
363 }
364 </select>
365 </label>
366 <label>"tool "
367 <select :value=$(tool.get()) @change=$(|e: Event| tool.set(e.target.value))>
368 for x in ToolFilter::ALL {
369 <option value=(x.as_str()) selected=(x.as_str() == t)>(x.as_str())</option>
370 }
371 </select>
372 </label>
373 <label>"view "
374 <select :value=$(bucket.get()) @change=$(|e: Event| bucket.set(e.target.value))>
375 for x in Bucket::ALL {
376 <option value=(x.label()) selected=(x.label() == b)>(x.label())</option>
377 }
378 </select>
379 </label>
380 <label>"window "
381 <select :value=$(window.get()) @change=$(|e: Event| window.set(e.target.value))>
382 for x in WINDOW_CHOICES {
383 <option value=(x) selected=(x == w)>(x)</option>
384 }
385 </select>
386 </label>
387
388 <label>"currency "
389 <select :value=$(currency.get()) @change=$(|e: Event| currency.set(e.target.value))>
390 for cc in CURRENCIES {
391 <option value=(cc.code) selected=(cc.code == cur)>(format!("{} · {}", cc.code, cc.name))</option>
392 }
393 </select>
394 </label>
395 <label>"theme "
396 <select :value=$(theme.get()) @change=$(|e: Event| theme.set(e.target.value))>
397 for n in theme::names() {
398 <option value=(n) selected=(n == th)>(n)</option>
399 }
400 </select>
401 </label>
402 </div>
403 })
404}
405
406#[component]
411pub async fn dashboard_body(
412 d: &Dashboard,
413 live: bool,
414 metric: Metric,
415 base: &str,
416 click_bucket: Option<&Signal<String>>,
417 click_project: Option<&Signal<String>>,
418) -> Result<impl View> {
419 Ok(view! {
420 for w in &d.warnings {
421 <div class="warn">"⚠ " (w.clone())</div>
422 }
423
424 limits_panel(check: &d.limits, currency: d.currency)
425
426 summary_cards(metric: metric, total: d.total, currency: d.currency, savings: &d.savings)
427
428
429 charts_panel(d: d, metric: metric, base: base, animate: live, click_bucket: click_bucket, click_project: click_project)
430
431 if metric == Metric::Saved {
432 savings_panel(savings: &d.savings, currency: d.currency, spent_usd: d.total.cost_usd, input_price: d.input_price, buckets: &d.savings_by_bucket, noun: bucket_noun(&d.query))
433 }
434
435 <section>
436 <h2>"Report · " (d.query.bucket.label())</h2>
437 if d.report.is_empty() {
438 <div class="empty">
439 "No tokenburn results found for tool=" (d.query.tool.as_str())
440 ", window=" (d.query.window.label()) "."
441 </div>
442 } else {
443 report_table(report: &d.report, currency: d.currency)
444 }
445 </section>
446
447 if tokenburn_core::projects_add_detail(&d.report, &d.projects) {
448 <section>
449 <h2>"Projects / models (by cost)"</h2>
450 projects_table(projects: &d.projects, currency: d.currency)
451 </section>
452 }
453
454 <footer>
455 if live {
456 <span class="badge live">"● live"</span>
457 } else {
458 <span class="badge">"○ static"</span>
459 }
460 " "
461 (d.row_count) " rows · updated " (d.updated_at.clone())
462 " · local logs only · JSON at "
463 <a href="/api/report">"/api/report"</a>
464 </footer>
465 })
466}
467
468#[component]
470pub async fn theme_style(index: usize) -> Result<impl View> {
471 let css = theme::css(index);
472 Ok(view! { <style>(css)</style> })
473}
474
475#[derive(Clone, Copy)]
482pub struct Clicks<'a> {
483 pub bucket: Option<&'a Signal<String>>,
484 pub project: Option<&'a Signal<String>>,
485}
486
487#[component]
490pub async fn bar_col(col: &Col) -> Result<impl View> {
491 Ok(view! {
492 <title>(col.title.clone())</title>
493 <rect class="hit" x=(col.slot_x.clone()) y=(col.plot_y.clone()) width=(col.slot_w.clone()) height=(col.plot_h.clone())></rect>
494 for seg in &col.segs {
495 <rect class="seg" x=(seg.x.clone()) y=(seg.y.clone()) width=(seg.w.clone()) height=(seg.h.clone()) fill=(seg.var.clone())></rect>
496 }
497 <rect class="outline" x=(col.slot_x.clone()) y=(col.plot_y.clone()) width=(col.slot_w.clone()) height=(col.plot_h.clone())></rect>
498 })
499}
500
501#[component]
503pub async fn slice_path(slice: &SliceGeom) -> Result<impl View> {
504 Ok(view! {
505 <path d=(slice.path.clone()) fill=(slice.var.clone())>
506 <title>(slice.title.clone())</title>
507 </path>
508 })
509}
510
511fn col_class(c: &Col) -> String {
512 if c.selected {
513 "col sel".into()
514 } else {
515 "col".into()
516 }
517}
518
519fn slice_class(s: &SliceGeom) -> String {
520 let mut c = String::from("slice");
521 if s.selected {
522 c.push_str(" sel");
523 }
524 if s.key.is_none() {
525 c.push_str(" other");
526 }
527 c
528}
529
530fn bar_delay(index: usize, n: usize) -> String {
532 let step = (600.0 / n.max(1) as f32).min(14.0);
533 format!("--d:{:.0}ms", step * index as f32)
534}
535
536#[component]
538pub async fn bars_chart(
539 bars: &Bars,
540 metric: Metric,
541 base: &str,
542 sel: &Selection,
543 animate: bool,
544 click: Option<&Signal<String>>,
545) -> Result<impl View> {
546 let n = bars.cols.len();
547 let cols: Vec<(Col, String, String, String)> = bars
549 .cols
550 .iter()
551 .map(|c| {
552 let mut next = sel.clone();
553 next.toggle_bucket(&c.label);
554 (
555 c.clone(),
556 charts::href(base, &next),
557 next.bucket.clone().unwrap_or_default(),
558 format!("{} {}", col_class(c), if animate { "anim" } else { "" }),
559 )
560 })
561 .collect();
562 let class = format!(
563 "chart bars{}{}",
564 if bars.has_selection { " has-sel" } else { "" },
565 if animate { " animate" } else { "" }
566 );
567 let label = format!("{} over time", metric.label());
568
569 Ok(view! {
570 <svg class=(class) viewBox=(format!("0 0 {} {}", charts::BARS_W, charts::BARS_H)) width="100%" role="img" aria-label=(label)>
571 if bars.empty {
572 <text class="empty-note" x=(charts::BARS_W / 2.0) y=(charts::BARS_H / 2.0) text-anchor="middle">"No data in this window"</text>
573 }
574 for g in &bars.grid {
575 <line class="grid" x1=(bars.left.clone()) x2=(bars.right.clone()) y1=(g.y.clone()) y2=(g.y.clone())></line>
576 <text class="axis" x=(charts::LEFT - 6.0) y=(g.text_y.clone()) text-anchor="end">(g.label.clone())</text>
577 }
578 for (c, href, next, class) in cols {
579 if let Some(sig) = click {
580 <g class=(class) style=(bar_delay(c.index, n)) @click=$(|_e| sig.set(next))>
581 bar_col(col: &c)
582 </g>
583 } else {
584 <a href=(href)>
585 <g class=(class) style=(bar_delay(c.index, n))>
586 bar_col(col: &c)
587 </g>
588 </a>
589 }
590 }
591 for l in &bars.x_labels {
592 <text class=(if l.alt { "axis alt" } else { "axis" }) x=(l.x.clone()) y=(l.y.clone()) text-anchor="middle">(l.text.clone())</text>
593 }
594 </svg>
595 })
596}
597
598#[component]
600pub async fn donut_chart(
601 donut: &charts::Donut,
602 base: &str,
603 sel: &Selection,
604 animate: bool,
605 click: Option<&Signal<String>>,
606) -> Result<impl View> {
607 let slices: Vec<(SliceGeom, String, String, String)> = donut
608 .slices
609 .iter()
610 .map(|s| {
611 let mut next = sel.clone();
612 if let Some(k) = &s.key {
613 next.toggle_project(k);
614 }
615 (
616 s.clone(),
617 charts::href(base, &next),
618 next.project.clone().unwrap_or_default(),
619 format!("--dx:{};--dy:{}", s.dx, s.dy),
620 )
621 })
622 .collect();
623 let class = format!(
624 "chart donut{}{}",
625 if donut.has_selection { " has-sel" } else { "" },
626 if animate { " animate" } else { "" }
627 );
628 let total = donut.total_label.clone();
629
630 Ok(view! {
631 <svg class=(class) viewBox="0 0 200 200" width="100%" role="img" aria-label="share by project">
632 if donut.empty {
633 <text class="empty-note" x="100" y="104" text-anchor="middle">"No data"</text>
634 } else {
635 <defs>
636 <mask id="sweep" maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
637 <circle class="sweep" cx="100" cy="100" r="74" fill="none" stroke="white" stroke-width="60" pathLength="1" transform="rotate(-90 100 100)"></circle>
638 </mask>
639 </defs>
640 <g mask="url(#sweep)">
641 for (s, href, next, vars) in slices {
642 if s.key.is_none() {
643 <g class=(slice_class(&s)) style=(vars)>
644 slice_path(slice: &s)
645 </g>
646 } else if let Some(sig) = click {
647 <g class=(slice_class(&s)) style=(vars) @click=$(|_e| sig.set(next))>
648 slice_path(slice: &s)
649 </g>
650 } else {
651 <a href=(href)>
652 <g class=(slice_class(&s)) style=(vars)>
653 slice_path(slice: &s)
654 </g>
655 </a>
656 }
657 }
658 </g>
659 <text class="total" x="100" y="106" text-anchor="middle">(total)</text>
660 }
661 </svg>
662 })
663}
664
665#[component]
667pub async fn selection_bar(
668 sel: &Selection,
669 base: &str,
670 click_bucket: Option<&Signal<String>>,
671 click_project: Option<&Signal<String>>,
672) -> Result<impl View> {
673 let chips = sel.describe();
674 let clear = charts::href(base, &Selection::default());
675 let (blank_a, blank_b) = (String::new(), String::new());
677 Ok(view! {
678 <div class="selbar">
679 if chips.is_empty() {
680 <span class="hint">"click a bar or a slice to drill down"</span>
681 } else {
682 <span class="hint">"Showing"</span>
683 for chip in chips {
684 <span class="chip">(chip)</span>
685 }
686 if let (Some(b), Some(p)) = (click_bucket, click_project) {
687 <button class="clear" @click=$(|_e| { b.set(blank_a); p.set(blank_b); })>"✕ clear"</button>
688 } else {
689 <a class="clear" href=(clear)>"✕ clear"</a>
690 }
691 }
692 </div>
693 })
694}
695
696#[component]
698pub async fn charts_panel(
699 d: &Dashboard,
700 metric: Metric,
701 base: &str,
702 animate: bool,
703 click_bucket: Option<&Signal<String>>,
704 click_project: Option<&Signal<String>>,
705) -> Result<impl View> {
706 let cur = d.currency;
707 let bars = charts::bars(
708 &d.series.in_currency(&cur),
709 metric,
710 d.selection.bucket.as_deref(),
711 &cur,
712 );
713 let donut = charts::donut(
714 &d.pie.in_currency(&cur),
715 d.selection.project.as_deref(),
716 &cur,
717 );
718 let sel = d.selection.clone();
719 let pie_metric = d.pie.metric;
720
721 let legend: Vec<(SliceGeom, String, String)> = donut
723 .slices
724 .iter()
725 .map(|s| {
726 let mut next = sel.clone();
727 if let Some(k) = &s.key {
728 next.toggle_project(k);
729 }
730 (
731 s.clone(),
732 charts::href(base, &next),
733 next.project.clone().unwrap_or_default(),
734 )
735 })
736 .collect();
737 let swatches: Vec<(String, String)> = d
738 .series
739 .active_tools(metric)
740 .into_iter()
741 .map(|t| (charts::tool_var(t), t.display_name().to_string()))
742 .collect();
743
744 Ok(view! {
745 <section class="charts">
746 <div class="panel">
747 <h2>(chart_title(metric))</h2>
748 bars_chart(bars: &bars, metric: metric, base: base, sel: &sel, animate: animate, click: click_bucket)
749 <ul class="legend">
750 for (var, name) in swatches {
751 <li><span class="sw" style=(format!("background:{var}"))></span>(name)</li>
752 }
753 </ul>
754 </div>
755 <div class="panel">
756 <h2>"Share by project · " (pie_metric.label())</h2>
757 <div class="donut-row">
758 <div class="donut-box">
759 donut_chart(donut: &donut, base: base, sel: &sel, animate: animate, click: click_project)
760 </div>
761 <ul class=(if donut.has_selection { "legend vertical has-sel" } else { "legend vertical" })>
762 for (s, href, next) in legend {
763 if s.key.is_none() {
764 <li class="item other"><span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span></li>
765 } else if let Some(sig) = click_project {
766 <li class=(if s.selected { "item sel" } else { "item" }) @click=$(|_e| sig.set(next))>
767 <span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span>
768 </li>
769 } else {
770 <li class=(if s.selected { "item sel" } else { "item" })>
771 <a href=(href)><span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span></a>
772 </li>
773 }
774 }
775 </ul>
776 </div>
777 </div>
778 </section>
779 selection_bar(sel: &sel, base: base, click_bucket: click_bucket, click_project: click_project)
780 })
781}