handterm-leptos (0.0.4)

Published 2026-07-27 15:57:23 +02:00 by alex

Installation

[registries.forgejo]
index = "sparse+" # Sparse index
# index = "" # Git

[net]
git-fetch-with-cli = true
cargo add handterm-leptos@0.0.4 --registry forgejo

About this package

Typed Leptos components over the handterm CSS and custom elements.

handterm-leptos

Typed Leptos components for handterm.

handterm is CSS first. Tier 0 and Tier 1 components are classes over markup you write, and Tier 2 components are light-DOM custom elements that enhance markup you write — which is why there is no React binding, no Vue binding, and no runtime. This crate is the one place that knowledge is written down for a language: a component per shipped component, rendering the markup the library documents, so a mistyped class is a compile error rather than something that quietly renders unstyled.

use handterm_leptos::prelude::*;
use leptos::prelude::*;

#[component]
fn Panel() -> impl IntoView {
    view! {
        <Card>
            <CardHeader>
                <CardTitle>"Air recycler"</CardTitle>
                <Badge variant=BadgeVariant::Ok>"Nominal"</Badge>
            </CardHeader>
            <CardBody>"Scrubber stack at 96% efficiency."</CardBody>
        </Card>
    }
}

Install

Published to a private Forgejo registry, which cargo has to be told about by name:

# .cargo/config.toml
[registries.handterm]
index = "sparse+https://git.shiverpeak.xyz/api/packages/alex/cargo/"
# Cargo.toml
[dependencies]
handterm-leptos = { version = "0.0.1", registry = "handterm" }

Reading the registry needs no token. Enable a renderer feature on leptos in your own binary, not here — see below.

What it does not do

It ships no CSS. @handterm/tokens and @handterm/css are still the product, and @handterm/elements still has to load for the Tier 2 components to be anything but static markup. Add them the way any consumer would:

pnpm add @handterm/tokens @handterm/css @handterm/elements
<html lang="en" data-faction="mcrn" data-scheme="dark">
  <head>
    <link data-trunk rel="css" href="node_modules/@handterm/tokens/dist/tokens.css" />
    <link data-trunk rel="css" href="node_modules/@handterm/css/dist/handterm.css" />
    <link
      data-trunk
      rel="copy-dir"
      href="node_modules/@handterm/elements/dist"
      data-target-path="elements"
    />
    <script type="module" src="/elements/index.js"></script>
  </head>
</html>

crates/showcase is that wiring in full, and renders every component.

handterm-assets is the same three packages' built output as a crate, for a project that would rather not have a Node toolchain and a second registry in it. Same bytes, same ordering rules; it serves them, this crate writes the markup.

It picks no renderer. No csr, ssr or hydrate feature is enabled here — whether you build for the browser, the server or both stays your binary's decision.

Conventions

Three rules, so knowing one component is knowing all of them.

class adds. Each component writes the classes that make it what it is and appends yours. <Badge class="wide"> renders class="ht-badge wide"; it never replaces.

Variants are enums, and the default writes no attribute. handterm's CSS reads a missing data-variant as its default styling, so ButtonVariant::Solid emits nothing at all rather than a second name for the same thing. Every prop takes a value or a signal.

Everything else is Leptos. Each component renders one root element, so {..} spreads ids, form attributes and event handlers straight onto it.

# use handterm_leptos::prelude::*;
# use leptos::prelude::*;
# fn view() -> impl IntoView {
view! { <Button {..} id="engage" on:click=move |_| ()>"Engage"</Button> }
# }

One wrinkle, and it is Rust's rather than this crate's: a prop whose value is a path, written immediately before {..}, parses as a struct literal and fails to compile. Parenthesise it — variant=(ButtonVariant::Danger) {..} — and put every prop before the spread. unused_parens then calls those parentheses redundant, because they are gone by the time it looks, so allow the lint on modules that use both together.

The custom elements

Tabs, Combobox, MultiSelect, Calendar, Menu, Toaster, Toolbar and Field are generated from custom-elements.json, which is itself generated from the elements' JSDoc. Their attributes, their events and their documentation cannot drift from the elements they wrap, because none of it is typed twice.

They take state as props and report changes through on_* callbacks, which is the whole of those elements' contract — state is attributes, changes are events, and no imperative handle is ever needed:

# use handterm_leptos::prelude::*;
# use leptos::prelude::*;
# fn view() -> impl IntoView {
let (showing, set_showing) = signal(String::from("drive"));

view! {
    <Tabs value=showing on_change=move |_: CustomEvent| set_showing.set("comms".into())>
        <TabsList label="Ship systems">
            <Tab value="reactor">"Reactor"</Tab>
            <Tab value="drive">"Drive"</Tab>
        </TabsList>
        <TabPanel value="reactor">"Core temperature nominal."</TabPanel>
        <TabPanel value="drive">"Sustained burn."</TabPanel>
    </Tabs>
}
# }

The inner markup is yours: these elements attach behaviour to what you render rather than rendering it for you, which is what makes them work under SSR with no flash and no hydration mismatch. The part components — TabsList, Tab, TabPanel and their equivalents — write that markup.

To regenerate after changing an element:

pnpm run gen:leptos          # write the wrappers
pnpm run gen:leptos:check    # fail if what is committed is stale (CI runs this)

Dependencies

ID Version
leptos ^0.8.20
leptos ^0.8.20
Details
Cargo
2026-07-27 15:57:23 +02:00
1
MIT
52 KiB
Assets (1)
Versions (4) View all
0.0.4 2026-07-27
0.0.3 2026-07-27
0.0.2 2026-07-26
0.0.1 2026-07-25