bcode_hyperchad 0.0.1-alpha.0

HyperChad application host for Bcode sessions
//! HTML/Actix backend construction for the Bcode `HyperChad` application.

mod context;

pub use self::context::HtmlActixPresentationContext;
use std::net::IpAddr;
use std::sync::LazyLock;

use bcode_client::ClientError;
use bcode_session_models::SessionSummary;
use bcode_session_view_models::SessionViewSnapshot;
use hyperchad::app::{App, AppBuilder, renderer::DefaultRenderer};
use hyperchad::color::Color;

use crate::{HyperChadAppState, router, router_from_state};

static BACKGROUND_COLOR: LazyLock<Color> = LazyLock::new(|| Color::from_hex("#0d1117"));

const ACCESSIBILITY_CSS: &str = r#"
:where(a, button, input, select, textarea, summary):focus-visible {
    outline: 3px solid #58a6ff;
    outline-offset: 3px;
}
:where(button, input:not([type="hidden"]), select, summary) {
    min-height: 44px;
}
:where(button, input:not([type="hidden"]), select, textarea, summary) {
    min-width: 44px;
}
:where(button, input, select, textarea) {
    font: inherit;
}
textarea {
    min-height: 88px;
    resize: vertical;
}
html, body {
    margin: 0;
    max-width: 100%;
    overflow-x: hidden;
}
*, *::before, *::after {
    box-sizing: border-box;
}
#bcode-web-shell, #bcode-application-content, #application-layout, #conversation-main {
    max-width: 100%;
    min-width: 0;
}
#application-layout > *, #conversation-main > * {
    min-width: 0;
}
@media (max-width: 900px) {
    #application-layout {
        flex-direction: column;
    }
    #session-navigation, #conversation-main {
        width: 100%;
    }
}
@media (max-width: 600px) {
    #bcode-web-shell {
        padding: 16px;
    }
    button, input, select, textarea {
        max-width: 100%;
    }
}
:where(pre, code) {
    max-width: 100%;
    overflow-x: auto;
    overflow-wrap: anywhere;
    white-space: pre-wrap;
    word-break: break-word;
}
:where(p, li, dd, blockquote, a) {
    overflow-wrap: anywhere;
}
img {
    height: auto;
    max-width: 100%;
}
"#;

#[cfg(test)]
pub const fn accessibility_css() -> &'static str {
    ACCESSIBILITY_CSS
}

/// Default loopback address for the local HTML/Actix renderer.
pub const DEFAULT_BIND_ADDRESS: IpAddr = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);

/// Default viewport meta tag for responsive HTML rendering.
pub static VIEWPORT: LazyLock<String> =
    LazyLock::new(|| "width=device-width, initial-scale=1".to_string());

/// Build the guarded local browser launch URL for an HTML/Actix backend.
#[must_use]
pub fn build_launch_url(
    address: std::net::SocketAddr,
    access_token: &str,
    initial_session_id: Option<bcode_session_models::SessionId>,
) -> String {
    let query = initial_session_id.map_or_else(
        || format!("token={access_token}"),
        |session_id| {
            format!("token={access_token}&hyperchad-event-scope={access_token}:{session_id}")
        },
    );
    format!("http://{address}/?{query}")
}

/// Validate a requested HTML/Actix renderer bind address.
///
/// # Errors
///
/// Returns an error when a non-loopback address is requested without explicit opt-in.
pub const fn validate_bind_address(
    address: IpAddr,
    allow_non_loopback: bool,
) -> Result<IpAddr, &'static str> {
    if address.is_loopback() || allow_non_loopback {
        Ok(address)
    } else {
        Err("non-loopback web binds require explicit opt-in")
    }
}

fn with_browser_runtime(builder: AppBuilder) -> AppBuilder {
    builder
        .with_inline_css(ACCESSIBILITY_CSS)
        .with_static_asset_route(hyperchad::renderer::assets::StaticAssetRoute {
            route: format!(
                "js/{}",
                hyperchad::renderer_vanilla_js::SCRIPT_NAME_HASHED.as_str()
            ),
            target: hyperchad::renderer::assets::AssetPathTarget::FileContents(
                hyperchad::renderer_vanilla_js::SCRIPT.as_bytes().into(),
            ),
            not_found_behavior: None,
        })
}

/// Initialize the HTML/Actix application builder with a static initial snapshot.
#[must_use]
pub fn init_with_snapshot(
    snapshot: SessionViewSnapshot,
    sessions: Vec<SessionSummary>,
) -> AppBuilder {
    with_browser_runtime(
        AppBuilder::new()
            .with_actix_bind_address(DEFAULT_BIND_ADDRESS.to_string())
            .with_router(router(snapshot, sessions))
            .with_background(*BACKGROUND_COLOR)
            .with_title("bcode web".to_string())
            .with_description("HyperChad application for Bcode sessions".to_string())
            .with_viewport(VIEWPORT.clone())
            .with_size(1200.0, 800.0),
    )
}

/// Initialize the HTML/Actix application builder from daemon state.
///
/// # Errors
///
/// Returns an error when initial daemon state cannot be loaded.
pub async fn init(state: &HyperChadAppState) -> Result<AppBuilder, ClientError> {
    state.client().ensure_daemon_available().await?;
    Ok(with_browser_runtime(
        AppBuilder::new()
            .with_actix_bind_address(DEFAULT_BIND_ADDRESS.to_string())
            .with_router(router_from_state(state.clone()))
            .with_background(*BACKGROUND_COLOR)
            .with_title("bcode web".to_string())
            .with_description("HyperChad application for Bcode sessions".to_string())
            .with_viewport(VIEWPORT.clone())
            .with_size(1200.0, 800.0),
    ))
}

/// Build the selected HTML/Actix application with an owned renderer runtime.
///
/// The returned runtime must remain alive until the application runner exits.
///
/// # Errors
///
/// Returns an error if the runtime or application fails to build.
pub fn build_app_with_runtime(
    builder: AppBuilder,
) -> Result<(App<DefaultRenderer>, switchy::unsync::runtime::Runtime), hyperchad::app::Error> {
    let runtime = switchy::unsync::runtime::Builder::new().build()?;
    let app = build_app(builder.with_runtime_handle(runtime.handle()))?;
    Ok((app, runtime))
}

/// Build the selected HTML/Actix application from the provided builder.
///
/// # Errors
///
/// Returns an error if the application fails to build.
pub fn build_app(builder: AppBuilder) -> Result<App<DefaultRenderer>, hyperchad::app::Error> {
    use hyperchad::renderer::Renderer as _;

    let mut app = builder.build_default()?;
    app.renderer.add_responsive_trigger(
        "narrow".to_owned(),
        hyperchad::renderer::transformer::ResponsiveTrigger::MaxWidth(
            hyperchad::renderer::transformer::Number::Integer(640),
        ),
    );
    app.renderer.add_responsive_trigger(
        "tablet".to_owned(),
        hyperchad::renderer::transformer::ResponsiveTrigger::MaxWidth(
            hyperchad::renderer::transformer::Number::Integer(960),
        ),
    );
    Ok(app)
}