Skip to main content

fc_dioxus/components/
semantic.rs

1//! Lifecycle-aware cards for agent interfaces: agent status, metric, transfer, approval,
2//! user prompt, and device authorization, from their stylesheets of the same names.
3//!
4//! Each carries upstream's dual-audience contract: `data-fc-kind` names what it is, and
5//! `data-fc-id`, `data-fc-seq`, `data-fc-state`, and `data-fc-ts` mirror the runtime record,
6//! so automation can read the state without parsing text. Styling keys off
7//! `data-fc-state`, the single source of truth for both audiences.
8use crate::{
9    core::{
10        assets,
11        attrs::{component_attributes, FcData, Kind},
12    },
13    prelude::*,
14    theme::use_stylesheets,
15};
16const STATUS: Bem = Bem::new("fc-agent-status");
17const METRIC: Bem = Bem::new("fc-metric");
18const TRANSFER: Bem = Bem::new("fc-transfer");
19const APPROVAL: Bem = Bem::new("fc-approval");
20const PROMPT: Bem = Bem::new("fc-user-prompt");
21const DEVICE: Bem = Bem::new("fc-device-auth");
22
23/// The runtime record a card mirrors into `data-fc-*`.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct Record {
26    /// Stable runtime id; never a credential.
27    pub id: Option<Id>,
28    /// Monotonic ordering.
29    pub seq: Option<u64>,
30    /// Lifecycle state, which also drives the styling.
31    pub state: Option<Lifecycle>,
32    /// When the record was produced or last changed.
33    pub ts: Option<Timestamp>,
34}
35impl Record {
36    fn attributes(&self, kind: Kind) -> Spread {
37        FcData {
38            kind: Some(kind),
39            id: self.id.clone(),
40            seq: self.seq,
41            state: self.state,
42            ts: self.ts.clone(),
43            ..FcData::default()
44        }
45        .attributes()
46    }
47}
48
49fn shell(
50    bem: Bem,
51    kind: Kind,
52    label: &AccessibleName,
53    record: &Record,
54    class: Option<&str>,
55    attributes: Vec<Attribute>,
56) -> Spread {
57    let mut attributes = component_attributes(
58        attributes,
59        ClassList::new(bem.block()).extend(class),
60        &["aria-label"],
61    );
62    attributes.push(Attribute::new("aria-label", label.to_string(), None, false));
63    attributes.extend(record.attributes(kind));
64    attributes
65}
66
67/// One live line saying what an agent is doing, with a sparkle and an elapsed time.
68#[component]
69pub fn AgentStatus(
70    state: Lifecycle,
71    /// What is happening, announced politely as it changes.
72    label: Element,
73    #[props(default)] meta: Option<Element>,
74    #[props(default = SpinnerKind::Sparkle)] spinner: SpinnerKind,
75    #[props(default)] class: Option<String>,
76    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
77) -> Element {
78    let mut attributes = component_attributes(
79        attributes,
80        ClassList::new(STATUS.block()).extend(class.as_deref()),
81        &[],
82    );
83    attributes.extend(
84        FcData {
85            kind: Some(Kind::AgentStatus),
86            state: Some(state),
87            ..FcData::default()
88        }
89        .attributes(),
90    );
91    rsx! {
92        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_AGENT_STATUS_CSS } }
93        div { ..attributes,
94            Spinner { kind: spinner, size: SpinnerSize::Sm, label: SpinnerLabel::Decorative }
95            span { class: STATUS.element("label"), role: "status", {label} }
96            if let Some(meta) = meta { span { class: STATUS.element("meta"), {meta} } }
97        }
98    }
99}
100
101/// A number worth watching, as a term and its value.
102#[component]
103pub fn Metric(
104    label: AccessibleName,
105    title: Element,
106    /// The machine-readable number, written to `<data value>`. `None` when there is no
107    /// value to report, and `display` says why.
108    value: Option<f64>,
109    /// How people read the value.
110    display: String,
111    #[props(default)] meta: Option<Element>,
112    #[props(default)] actions: Option<Element>,
113    /// A line above the term, e.g. the state in words.
114    #[props(default)]
115    status: Option<Element>,
116    /// A standalone named region rather than one item among many.
117    #[props(default)]
118    region: bool,
119    #[props(default)] record: Record,
120    #[props(default)] class: Option<String>,
121    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
122) -> Element {
123    let attributes = shell(
124        METRIC,
125        Kind::Metric,
126        &label,
127        &record,
128        class.as_deref(),
129        attributes,
130    );
131    let body = rsx! {
132        {status}
133        dl {
134            dt { class: METRIC.element("title"), {title} }
135            dd { class: METRIC.element("value"),
136                if let Some(value) = value { data { value: "{value}", "{display}" } } else { "{display}" }
137            }
138            if let Some(meta) = meta { dd { class: METRIC.element("meta"), {meta} } }
139        }
140        if let Some(actions) = actions { div { class: METRIC.element("actions"), {actions} } }
141    };
142    rsx! {
143        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_METRIC_CSS } }
144        {card(region, attributes, body)}
145    }
146}
147
148/// A file moving in or out, with progress while it runs.
149#[component]
150pub fn Transfer(
151    label: AccessibleName,
152    title: Element,
153    #[props(default = HeadingLevel::H3)] heading: HeadingLevel,
154    /// The file and how far along it is. Wrap names in `bdi` so mixed-direction text holds.
155    #[props(default)]
156    meta: Option<Element>,
157    /// Progress while it runs, named by `label`.
158    #[props(default)]
159    progress: Option<ProgressValue>,
160    #[props(default)] actions: Option<Element>,
161    #[props(default)] status: Option<Element>,
162    #[props(default)] region: bool,
163    #[props(default)] record: Record,
164    #[props(default)] class: Option<String>,
165    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
166    children: Element,
167) -> Element {
168    let attributes = shell(
169        TRANSFER,
170        Kind::Transfer,
171        &label,
172        &record,
173        class.as_deref(),
174        attributes,
175    );
176    let body = rsx! {
177        {status}
178        {super::heading(heading, TRANSFER.element("title"), title)}
179        if let Some(meta) = meta { p { class: TRANSFER.element("meta"), {meta} } }
180        {children}
181        if let Some(progress) = progress { Progress { label: label.clone(), value: progress } }
182        if let Some(actions) = actions { div { class: TRANSFER.element("actions"), {actions} } }
183    };
184    rsx! {
185        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TRANSFER_CSS } }
186        {card(region, attributes, body)}
187    }
188}
189
190/// A request for the user's permission, e.g. to run a tool, with what exactly is asked.
191#[component]
192pub fn Approval(
193    label: AccessibleName,
194    title: Element,
195    #[props(default = HeadingLevel::H3)] heading: HeadingLevel,
196    #[props(default)] meta: Option<Element>,
197    /// Allow and deny, as buttons. Deny comes first so it is not the default by position.
198    #[props(default)]
199    actions: Option<Element>,
200    #[props(default)] status: Option<Element>,
201    #[props(default)] region: bool,
202    #[props(default)] record: Record,
203    #[props(default)] class: Option<String>,
204    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
205    children: Element,
206) -> Element {
207    let attributes = shell(
208        APPROVAL,
209        Kind::Approval,
210        &label,
211        &record,
212        class.as_deref(),
213        attributes,
214    );
215    let body = rsx! {
216        {status}
217        {super::heading(heading, APPROVAL.element("title"), title)}
218        {children}
219        if let Some(meta) = meta { p { class: APPROVAL.element("meta"), {meta} } }
220        if let Some(actions) = actions { div { class: APPROVAL.element("actions"), {actions} } }
221    };
222    rsx! {
223        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_APPROVAL_CSS } }
224        {card(region, attributes, body)}
225    }
226}
227
228/// A question an agent puts to the user, as a form. `onsubmit` receives nothing: read the
229/// answer from the controls' own state. With `region` it is a plain named section, for
230/// when there is nothing to submit.
231#[component]
232pub fn UserPrompt(
233    label: AccessibleName,
234    title: Element,
235    #[props(default = HeadingLevel::H3)] heading: HeadingLevel,
236    #[props(default)] meta: Option<Element>,
237    #[props(default)] actions: Option<Element>,
238    #[props(default)] onsubmit: Option<EventHandler<()>>,
239    #[props(default)] status: Option<Element>,
240    #[props(default)] region: bool,
241    #[props(default)] record: Record,
242    #[props(default)] class: Option<String>,
243    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
244    children: Element,
245) -> Element {
246    let attributes = shell(
247        PROMPT,
248        Kind::UserPrompt,
249        &label,
250        &record,
251        class.as_deref(),
252        attributes,
253    );
254    let body = rsx! {
255        {status}
256        {super::heading(heading, PROMPT.element("title"), title)}
257        {children}
258        if let Some(meta) = meta { p { class: PROMPT.element("meta"), {meta} } }
259        if let Some(actions) = actions { div { class: PROMPT.element("actions"), {actions} } }
260    };
261    rsx! {
262        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_USER_PROMPT_CSS } }
263        if region {
264            section { ..attributes, {body} }
265        } else {
266            form { onsubmit: move |event| { event.prevent_default(); if let Some(onsubmit) = onsubmit { onsubmit.call(()); } },
267                ..attributes, {body}
268            }
269        }
270    }
271}
272
273/// A device-code sign-in: where to go and the code to enter there.
274///
275/// The code is rendered as text only. It is a credential, so it never goes into an
276/// attribute, where it would be serialized into logs and caches.
277#[component]
278pub fn DeviceAuth(
279    label: AccessibleName,
280    title: Element,
281    #[props(default = HeadingLevel::H3)] heading: HeadingLevel,
282    /// The code to enter, once one has been issued.
283    #[props(default)]
284    code: Option<String>,
285    #[props(default)] meta: Option<Element>,
286    #[props(default)] actions: Option<Element>,
287    #[props(default)] status: Option<Element>,
288    #[props(default)] region: bool,
289    #[props(default)] record: Record,
290    #[props(default)] class: Option<String>,
291    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
292    children: Element,
293) -> Element {
294    let attributes = shell(
295        DEVICE,
296        Kind::DeviceAuth,
297        &label,
298        &record,
299        class.as_deref(),
300        attributes,
301    );
302    let body = rsx! {
303        {status}
304        {super::heading(heading, DEVICE.element("title"), title)}
305        {children}
306        if let Some(code) = code { code { class: DEVICE.element("code"), "{code}" } }
307        if let Some(meta) = meta { p { class: DEVICE.element("meta"), {meta} } }
308        if let Some(actions) = actions { div { class: DEVICE.element("actions"), {actions} } }
309    };
310    rsx! {
311        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_DEVICE_AUTH_CSS } }
312        {card(region, attributes, body)}
313    }
314}
315
316/// `<section>` for a card that stands alone as a named region (an empty state, a single
317/// expanded view), `<article>` for one item in a stream of them.
318fn card(region: bool, attributes: Spread, body: Element) -> Element {
319    if region {
320        rsx! { section { ..attributes, {body} } }
321    } else {
322        rsx! { article { ..attributes, {body} } }
323    }
324}