///|
/// One generation option of a `hitl_card`: a label, optional reference
/// thumbnails (one per modality), a credit cost, and whether it is
/// checked for the confirm run.
pub(all) struct HitlItem {
label : String
thumbnails : Array[String]
cost : Int
checked : Bool
} derive(Eq)
///|
pub extend HitlItem with Eq::{equal, not_equal}
///|
pub fn HitlItem::new(
label : String,
cost? : Int = 0,
thumbnails? : Array[String] = [],
checked? : Bool = true,
) -> HitlItem {
{ label, thumbnails, cost, checked, }
}
///|
const HitlCardStyle : String = "display:flex;flex-direction:column;gap:24px;width:100%;padding:12px 12px 12px 8px;border:1px solid var(--sumi-stroke-primary,rgba(255,255,255,0.08));border-radius:12px;background:var(--sumi-agent-form-bg,#181818)"
///|
const HitlHeaderStyle : String = "display:flex;align-items:center;gap:10px;height:32px;padding-left:10px"
///|
const HitlTitleStyle : String = "margin:0;font-size:14px;line-height:22px;font-weight:500;color:var(--sumi-text,#ffffff);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
///|
const HitlListStyle : String = "display:flex;flex-direction:column;gap:4px;padding-left:2px;overflow-x:clip;overflow-y:auto"
///|
const HitlRowStyle : String = "display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:7px 12px 7px 8px;border:none;border-radius:8px;background:transparent;font-size:14px;line-height:22px;text-align:left;appearance:none;-webkit-appearance:none;outline:none"
///|
const HitlLabelStyle : String = "flex:1;min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
///|
/// Checked rows read at the secondary tone; unchecked rows sink to the
/// disabled tone until hovered into selection.
fn hitl_label_style(checked : Bool) -> String {
if checked {
HitlLabelStyle + ";color:var(--sumi-text-secondary,rgba(255,255,255,0.7))"
} else {
HitlLabelStyle + ";color:var(--sumi-text-disabled,rgba(255,255,255,0.2))"
}
}
///|
const HitlCostStyle : String = "display:flex;flex:none;align-items:center;gap:2px;color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"
///|
const HitlButtonStyle : String = "height:36px;min-height:36px;max-height:36px;min-width:80px;padding:4px 16px;font-size:12px;line-height:20px"
///|
/// A row's checkbox: a 16px outline box inside a 20px hit cell, with the
/// 12px tick overlaid while checked. The outline drops from full white to
/// the 0.7 secondary tone while unchecked.
fn hitl_checkbox(checked : Bool) -> @html.Html {
let tick : @html.Html = if checked {
@html.span(
style=[
"position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--sumi-text,#ffffff)",
],
[@primitives.icon_check_fill(size=12)],
)
} else {
@html.nothing
}
let box_border = if checked {
"border:1px solid var(--sumi-text,#ffffff)"
} else {
"border:1px solid var(--sumi-text-secondary,rgba(255,255,255,0.7))"
}
@html.span(
style=["position:relative;display:block;width:20px;height:20px;flex:none"],
attrs=@html.Attrs::build()
.data_set("slot", "hitl-card-checkbox")
.data_set("checked", if checked { "true" } else { "false" })
.aria_hidden("true"),
[
@html.span(
style=[
@internal.SumiTransition,
"position:absolute;inset:10%;border-radius:4px",
box_border,
],
([] : Array[@html.Html]),
),
tick,
],
)
}
///|
/// One 24px reference thumbnail of a row.
fn hitl_thumb(src : String) -> @html.Html {
@html.span(
style=[
"position:relative;display:inline-block;width:24px;height:24px;flex:none;border:0.5px solid var(--sumi-stroke-primary,rgba(255,255,255,0.08));border-radius:4px;overflow:hidden",
],
[
@html.img(src~, alt="", style=[
"position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none",
]),
],
)
}
///|
/// A human-in-the-loop confirm card: a titled list of generation options
/// with per-row checkboxes and credit costs, plus a Cancel / confirm
/// footer whose primary button carries the total cost of the checked
/// rows. Controlled: the caller owns each item's `checked` and receives
/// row clicks via `on_toggle(index)`.
///
/// `confirmed=true` renders the read-only receipt state — checkboxes and
/// the footer drop out, leaving the settled list.
///
/// ```moonbit nocheck
/// hitl_card(
/// title="Generate the following 4 videos",
/// items=plan,
/// on_toggle=emit.map(ToggleRow),
/// on_cancel=emit(Dismiss),
/// on_confirm=emit(ConfirmAll),
/// )
/// ```
pub fn hitl_card(
title~ : String,
items~ : Array[HitlItem],
on_toggle? : @internal.Emit[Int],
confirmed? : Bool = false,
on_cancel? : @cmd.Cmd,
on_confirm? : @cmd.Cmd,
cancel_label? : String = "Cancel",
confirm_label? : String = "Generate All",
max_height? : Int = 190,
aria_label? : String,
id? : String,
class? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
let element_attrs = @internal.sumi_attrs(attrs).data_set("slot", "hitl-card")
if confirmed {
ignore(element_attrs.data_set("state", "confirmed"))
}
if aria_label is Some(label_text) {
ignore(element_attrs.aria_label(label_text))
}
let rows : Array[@html.Html] = []
for index, item in items {
let row_children : Array[@html.Html] = []
if !confirmed {
row_children.push(hitl_checkbox(item.checked))
}
if item.thumbnails.length() > 0 {
row_children.push(
@html.span(
style=[
@internal.SumiTransition,
"display:flex;flex:none;align-items:center;gap:6px",
if item.checked {
""
} else {
"opacity:0.5"
},
],
attrs=@html.Attrs::build().data_set("slot", "hitl-card-thumbs"),
item.thumbnails.map(hitl_thumb),
),
)
}
row_children.push(
@html.p(
style=[@internal.SumiTransition, hitl_label_style(item.checked)],
[@html.text(item.label)],
),
)
if item.cost > 0 {
row_children.push(
@html.span(
style=[
@internal.SumiTransition,
HitlCostStyle,
if item.checked {
""
} else {
// Unchecked rows hide the cost but keep its width so the
// row's trailing edge stays put while toggling.
"opacity:0"
},
],
attrs=@html.Attrs::build().data_set("slot", "hitl-card-cost"),
[
@primitives.icon_sparkle_fill(size=16),
@html.span(
style=[
@internal.SumiFontNum,
"font-size:12px;line-height:20px;font-weight:500",
],
[@html.text(item.cost.to_string())],
),
],
),
)
}
let row_attrs = @html.Attrs::build()
.data_set("slot", "hitl-card-row")
.data_set("checked", if item.checked { "true" } else { "false" })
match on_toggle {
Some(_) =>
rows.push(
@html.button(
style=[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
@internal.SumiTransition,
HitlRowStyle,
"cursor:pointer",
],
type_="button",
on_click?=on_toggle.map(emit => emit(index)),
attrs=row_attrs.aria_pressed(@internal.sumi_bool(item.checked)),
row_children,
),
)
None =>
rows.push(
@html.div(
style=[@internal.SumiBoxSizing, HitlRowStyle],
attrs=row_attrs,
row_children,
),
)
}
}
let card_children : Array[@html.Html] = [
@html.div(style=[HitlHeaderStyle], [
@html.p(style=[HitlTitleStyle], [@html.text(title)]),
@html.span(
style=["flex:1;min-width:4px;height:1px"],
attrs=@html.Attrs::build().aria_hidden("true"),
([] : Array[@html.Html]),
),
]),
@html.div(
style=[HitlListStyle, "max-height:\{max_height}px"],
attrs=@html.Attrs::build().data_set("slot", "hitl-card-list"),
rows,
),
]
if !confirmed {
let mut total = 0
for item in items {
if item.checked {
total += item.cost
}
}
let confirm_children : Array[@html.Html] = []
if total > 0 {
confirm_children.push(
@html.span(style=["display:inline-flex;align-items:center;gap:2px"], [
@primitives.icon_sparkle_fill(size=16),
@html.span(
style=[
@internal.SumiFontNum,
"font-size:12px;line-height:20px;font-weight:500",
],
[@html.text(total.to_string())],
),
]),
)
}
confirm_children.push(@html.text(confirm_label))
card_children.push(
@html.div(
style=["display:flex;justify-content:flex-end;gap:12px;width:100%"],
attrs=@html.Attrs::build().data_set("slot", "hitl-card-footer"),
[
@primitives.button(
variant=Secondary,
on_click?=on_cancel,
style=[HitlButtonStyle, "font-weight:400"],
[@html.text(cancel_label)],
),
@primitives.button(
variant=Primary,
on_click?=on_confirm,
style=[HitlButtonStyle],
confirm_children,
),
],
),
)
}
@html.div(
style=@internal.sumi_styles(
[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
if confirmed {
"display:flex;flex-direction:column;gap:24px;width:100%;padding:12px 12px 16px 8px;border:1px solid var(--sumi-stroke-primary,rgba(255,255,255,0.08));border-radius:12px;background:var(--sumi-agent-form-bg,#181818)"
} else {
HitlCardStyle
},
],
style,
),
id?,
class?,
attrs=element_attrs,
card_children,
)
}