wavefunk-ui 0.1.5

Askama and htmx UI component base for Wave Funk Rust applications.
Documentation

wavefunk-ui

Common Askama and htmx UI components for Wave Funk Rust applications.

The crate vendors the Wave Funk CSS, fonts, pinned htmx scripts, and small JavaScript helpers and embeds them into the Rust library. Consumers can ship self-contained binaries by mounting the provided asset handler instead of copying a runtime static/ directory or loading htmx from a CDN.

Install

[dependencies]
wavefunk-ui = "0.1"

For Axum asset serving:

[dependencies]
wavefunk-ui = { version = "0.1", features = ["axum"] }

Embedded Assets

The crate embeds the runtime assets under the stable public mount path /static/wavefunk:

  • css/wavefunk.css
  • css/fonts/MartianGrotesk-VF.woff2
  • css/fonts/MartianMono-VF.woff2
  • js/wavefunk.js
  • js/htmx.min.js
  • js/htmx-sse.js

This repository is the source of truth for Wave Funk runtime assets. CSS, fonts, and JavaScript changes should land in static/wavefunk/ here and ship through the crate. Do not overwrite these files by importing from a separate design repository.

No runtime static/ directory is required in consuming binaries.

With Axum, enable the axum feature and mount the optional router:

let app = axum::Router::new().nest(
    "/static/wavefunk",
    wavefunk_ui::axum::asset_router(),
);

Templates can use:

wavefunk_ui::html::stylesheet_link(wavefunk_ui::assets::DEFAULT_BASE_PATH);
wavefunk_ui::html::htmx_script_link(wavefunk_ui::assets::DEFAULT_BASE_PATH);
wavefunk_ui::html::script_link(wavefunk_ui::assets::DEFAULT_BASE_PATH);

The raw framework-neutral API is also available:

let css = wavefunk_ui::assets::get("css/wavefunk.css").unwrap();
let htmx = wavefunk_ui::assets::get("/static/wavefunk/js/htmx.min.js").unwrap();

assets::get normalizes both crate-relative paths and paths under assets::DEFAULT_BASE_PATH, rejects traversal, and returns bytes plus a content type. CSS and JavaScript are served as UTF-8 text, fonts as font/woff2, and unknown extensions as application/octet-stream.

Framework adapters use assets::CACHE_CONTROL, currently public, max-age=0, must-revalidate, so deployments can refresh unchanged asset paths safely.

The vendored htmx and htmx SSE assets are covered by LICENSES.htmx.txt. The embedded Martian fonts are covered by LICENSES.fonts.txt. The Wave Funk CSS and JavaScript helper are distributed with this crate under the package license.

Component API Contract

Public UI primitives are exported as typed Askama template structs from modules such as wavefunk_ui::components and wavefunk_ui::layouts.

Component constructors follow a consistent pattern:

  • Use Type::new(...) for the neutral variant.
  • Use named constructors for common variants, such as Button::primary(...) and Tag::status(...).
  • Use by-value with_* methods for optional state, classes, attributes, htmx behavior, and trusted slots.
  • Treat struct fields as readable implementation detail. Consumer code should use constructors and builders so new fields can be added without breaking semver.

Askama escapes normal text and attribute values. Use HtmlAttr helpers for common htmx attributes:

use askama::Template;
use wavefunk_ui::components::{Button, HtmlAttr};

let attrs = [
    HtmlAttr::hx_post("/contacts"),
    HtmlAttr::hx_swap("none"),
];

let button = Button::primary("Save").with_attrs(&attrs);
let html = button.render()?;

Any slot that must contain already-rendered markup is explicit:

use askama::Template;
use wavefunk_ui::components::{Field, TrustedHtml};

let control = TrustedHtml::new(r#"<input class="wf-input" name="email">"#);
let field = Field::new("Email", control).with_hint("Used for receipts.");
let html = field.render()?;

Only pass TrustedHtml content that was produced by your own templates or otherwise sanitized. User-provided text should remain normal &str data so Askama can escape it.

Component rendering uses Askama's Result type. Propagate render errors from request handlers instead of hiding them in shared UI code.

Feature flags are additive. The default feature set stays framework-neutral; framework adapters such as Axum are enabled with feature flags.

Dynamic table rows can use the owned table API, avoiding parallel storage just to satisfy borrowed cell lifetimes:

use askama::Template;
use wavefunk_ui::components::{
    DataTableHeader, OwnedDataTable, OwnedDataTableCell, OwnedDataTableRow, TrustedHtmlBuf,
};

let headers = [DataTableHeader::new("Name"), DataTableHeader::new("Actions").action_column()];
let rows = records
    .iter()
    .map(|record| {
        OwnedDataTableRow::new([
            OwnedDataTableCell::strong(record.name.clone()),
            OwnedDataTableCell::html(TrustedHtmlBuf::new(
                r#"<button class="wf-icon-btn">Edit</button>"#,
            )),
        ])
    })
    .collect::<Vec<_>>();
let html = OwnedDataTable::new(&headers, rows).render()?;

Plain owned table cells are escaped. Cells that contain markup require TrustedHtmlBuf, the owned counterpart to TrustedHtml.

Marketing page CSS is part of the embedded asset bundle. Stable repeated primitives such as marketing sections, feature grids, step grids, pricing plans, and testimonials are exposed as typed components. Full landing-page composition, hero copy, and app-specific page structure should stay in consumer templates so this crate does not freeze one marketing layout into the semver surface.

Migration-Ready Composition

Product apps should compose generic primitives rather than asking this crate for product-specific pages. The crate includes reusable shell, navigation, status, sensitive-value, checklist, snippet, and meter building blocks:

use askama::Template;
use wavefunk_ui::components::{
    Checklist, ChecklistItem, CodeBlock, ContextSwitcher, ContextSwitcherItem,
    FormPanel, Modeline, ModelineSegment, SecretValue, Sidenav, SidenavItem,
    SidenavSection, SplitShell, StrengthMeter, TrustedHtml,
};

let form = FormPanel::new(
    "Setup surface",
    TrustedHtml::new(r#"<form class="wf-form">...</form>"#),
);
let split = SplitShell::new(TrustedHtml::new(&form.render()?))
    .with_visual(TrustedHtml::new("<p>Preview slot</p>"))
    .with_mode("dark");

let contexts = [ContextSwitcherItem::link("Production", "/workspaces/prod").active()];
let switcher = ContextSwitcher::new("Workspace", "Production", &contexts);
let nav_items = [SidenavItem::link("Overview", "/overview").active()];
let nav_sections = [SidenavSection::new("Manage", &nav_items)];
let nav = Sidenav::new(&nav_sections);
let embedded_nav = Sidenav::new(&nav_sections).embedded();

let modeline_segments = [ModelineSegment::chevron("WF"), ModelineSegment::buffer("dashboard")];
let modeline = Modeline::new(&modeline_segments);

let secret = SecretValue::new("Generated value", "value", "wf_live_example");
let checklist_items = [ChecklistItem::ok("Domain verified")];
let checklist = Checklist::new(&checklist_items);
let code = CodeBlock::new("cargo test").with_language("shell");
let strength = StrengthMeter::new(3, 4, "Strong");

Consumers still own page semantics: routes, domain behavior, lifecycle rules, copy, scoring algorithms, and product-specific page structs stay outside wavefunk-ui.

Interaction Primitives

The shared JavaScript in wavefunk.js is intentionally generic:

  • Popovers open when a trigger inside .wf-pop-anchor has data-popover-toggle; the matching .wf-popover closes when the user clicks outside it.
  • Toasts are emitted with an htmx HX-Trigger payload for wfToast.
  • Echo/minibuffer messages are emitted with wfEcho and update elements marked with data-wf-echo. Use Minibuffer for the visible echo target and MinibufferEcho::info("...") in swapped fragments when markup should emit an echo message without hand-written data attributes.

Use the htmx helpers to build response headers:

let (name, value) = wavefunk_ui::htmx::trigger_header_pair(&[
    wavefunk_ui::htmx::Trigger::toast("ok", "Saved."),
    wavefunk_ui::htmx::Trigger::echo("info", "Queued."),
])?;

Modal and drawer wrappers render the overlay plus the panel markup. Add .open() when server-rendering the visible state, and omit it for the hidden state. Popover wrappers render the .wf-pop-anchor plus .wf-popover; pass trigger markup that includes data-popover-toggle.

Askama Performance

Use Askama's render, render_into, or write_into methods for template output. Avoid converting templates through to_string() or format!() in hot paths.

This crate optimizes askama_derive in the dev profile so local incremental builds stay practical as the component template set grows.

Askama-derived templates already implement FastWritable. TrustedHtml and TrustedHtmlBuf implement it manually because component slots pass trusted markup through repeatedly; add manual implementations only for non-template wrapper types that show up in hot render paths and can write directly to fmt::Write.

Cached local rebuild check on 2026-05-16: after touching src/components.rs, cargo check --all-features --example axum_gallery completed in 1.10s real time. The gallery is the template-heavy smoke target for local path override iteration.

Local Consumer Iteration

Committed Wave Funk consumers should depend on the crates.io version:

wavefunk-ui = "0.1"

For local iteration, add a gitignored .cargo/config.toml in the consumer repo:

paths = [
    "../ui",
]

Do not commit the local path override. Release and CI should resolve the published crate from crates.io.

Release Readiness

Release notes live in CHANGELOG.md; publish steps and versioning rules live in RELEASE.md. Woodpecker checks live in .woodpecker/ci.yml, and tag-based publishing lives in .woodpecker/release.yml.

Before publishing, run:

direnv exec . just release-check