DocsThe ecosystem
UI components
shadcn/ui, ported to Rahti and installed into your application as source you own. One tool, no runtime crate, sixty-two components fetched once and written into src/components/rahti_ui/ — where they are yours to read, to change, and to review in a diff.
Install
One crate, and it is a tool rather than a dependency. There is no rahti-ui to cargo add: a component is several hundred lines of class strings and markup, and there is nothing left to put in a runtime behind it. So nothing your application depends on moves when the installer does.
# One tool, and no runtime crate beside it: a component is source, and
# the source is the whole of it.
cargo install cargo-rahti-ui
cargo rahti-ui add buttonbutton becomes src/components/rahti_ui/button.rs, holding a Button component. The Rahti build already scans src/components/, so it is in scope on the next build with nothing to wire up — and Tailwind already reads that directory, so its classes are in the stylesheet too.
use crate::components::rahti_ui::button::Button;
html! {
<Button variant="outline" size="sm" onclick={save()}>"Save"</Button>
}What add installs
One name, and the whole graph behind it. A Sidebar calls a Button, opens a Sheet on small screens, and reaches for two icons — and every one of those is a compile error if it is missing.
$ cargo rahti-ui add sidebar
added sidebar -> src/components/rahti_ui/sidebar.rs
added button -> src/components/rahti_ui/button.rs
added sheet -> src/components/rahti_ui/sheet.rs
added utils -> src/components/rahti_ui/utils.rs
added attrs -> src/components/rahti_ui/attrs.rs
5 written, 0 updated.
wrote src/app/_vendor/tw-animate-css.css
wrote src/app/globals.css
Icons: panel-left, x
wrote rahti-ui.json, .github/instructions/rahti-ui.instructions.mdThe dependencies are not a list maintained beside the component. They are read out of its use lines — the same ones the compiler reads — so they cannot drift from the code. Comments are stripped before the scan, because these files document themselves partly by naming other components, and a raw text search would install half the catalog with every button.
Two things arrive with the first component whatever it is: utils and attrs, which every component in the catalog imports. Icons are handed to cargo-rahti-icons, which already owns that catalog, the name derivation and rahti-icons.json — installed for you if the tool is on PATH, and printed as a line to run if it is not.
The file is yours
This is where the package parts company with the icons. An icon file is six lines of data handed to a macro: it is generated, it says so, and the runtime behind it can be upgraded underneath it. A rahti-ui component is the opposite — the file is the component. It is shadcn's bargain, and Rahti takes the same one: the code lands in the repository, and a variant nobody upstream thought of is an edit rather than a feature request.
So the banner records where the file came from, and explicitly does not tell you to leave it alone.
// rahti-ui: button
// Installed from https://rahti.ui.tsnc.tech/cli by cargo-rahti-ui 0.0.1.
//
// This is source, not a generated file — it is the application's to read and to
// change. `cargo rahti-ui update` re-fetches it, and leaves it alone once it has
// been edited; `--force` overwrites either way.
//! Button — a port of shadcn/ui's `button`.
//!
//! The class strings are shadcn's, copied rather than reinterpreted, so the
//! rendered button is pixel-identical to the React one under the same
//! Tailwind theme. What changes is how the props get there.Which is why an edit survives an update
rahti-ui.json records the SHA-256 of every file as it was installed, banner included. On cargo rahti-ui update, a file that still hashes to that is untouched and is replaced; one that does not has been worked on, and is reported and left alone.
$ cargo rahti-ui update
Re-fetching 6 components...
updated sheet -> src/components/rahti_ui/sheet.rs
Edited since they were installed, so left alone:
src/components/rahti_ui/button.rs
`--force` overwrites them.| On disk | What update does |
|---|---|
| Byte-for-byte what was installed | Replaced with the current version |
| Already the current version | Nothing, and nothing reported |
| Edited since it was installed | Reported, and left alone |
| Never installed by this tool | Treated as yours: left alone |
Anything, with --force | Overwritten |
Props
The call site is the JSX one. Every prop is optional — html! closes a props literal with ..Default::default(), so a tag names what it changes and nothing else, and <Button variant="outline"> reads the way it reads in TSX. The trade is that a forgotten prop is no longer a compile error, and every prop type must be Default.
// Every prop is optional. `html!` closes a props literal with
// `..Default::default()`, so a tag names what it changes and nothing else.
<Button>"Save"</Button>
<Button variant="outline" size="sm">"Cancel"</Button>
<Button variant="destructive" disabled>"Delete"</Button>
// Values are Rust expressions, so `variant` and `size` are the strings
// that read well at the call site — the slugs of ButtonVariant and
// ButtonSize, which are still the real types underneath.
<Button variant="link" href="/docs">"Docs"</Button>
// Anything the component does not name goes through `attrs`, which is
// what `{...props}` spreads in the React original.
<Button attrs=@{Attrs::new().set("form", "signup").unset("data-size")}>
"Sign up"
</Button>Values are Rust expressions, so a number is written as a string. variant and size are strings for the same reason they are strings in TypeScript — it is what reads well at the call site. ButtonVariant and ButtonSize are still the real types underneath, and the strings are their slugs.
attrs is shadcn's {...props} as a named prop: an attribute set the component spreads onto the element it renders. It is named rather than implicit because an unknown prop has to stay a compile error instead of quietly becoming an HTML attribute nobody meant to write.
The component boundary
One rule decides whether a {…} written on a component tag works, and its two halves fail in opposite ways. It is the same rule as everywhere else in PulsePoint, and it is worth stating in terms of these components, because they are where you meet it first.
- An event is resolved when it fires, by which time it reaches the calling page's state.
onclick={setCount(count + 1)}drives a counter declared in the page's<script>, through the component, with nothing passed down. It works. - An attribute binding is compiled against the component's own scope, which is a different one.
disabled={count === 0}finds nocountthere and quietly does nothing. Neither does a{…}written between the tags.
// An event crosses the boundary. `onclick={…}` is handed to the
// component as source, put on the <button>, and resolved when it fires —
// by which time it reaches this page's state.
<Button variant="outline" onclick={setCount(count + 1)}>"Increment"</Button>
// An attribute binding does not. This one is compiled against the
// component's own scope, finds no `count`, and quietly does nothing.
<Button disabled={count === 0}>"Increment"</Button> // silently inert
// The props builder is the way out: `render` returns the element and
// nothing around it, so interpolating it puts the button in *this*
// block's scope, where the binding resolves.
@{button().outline()
.on("click", "setCount(count + 1)")
.bind("disabled", "count === 0")
.render(Html::escape("Increment"))}The way out is the props builder every component ships beside its tag. render returns the element and nothing around it, so interpolating it puts the element in the calling block's scope — where the binding resolves against the state you actually declared.
Class overrides
shadcn builds a class list out of three layers — base, the chosen variants, and the caller's override — and runs the result through cn(), which is clsx plus tailwind-merge. The merge is not decoration: Tailwind decides which of two conflicting utilities wins by their order in the stylesheet, not in the class attribute, so a plain join of bg-primary and bg-emerald-600 renders the primary colour.
Rahti already ships a tailwind-merge: public/js/main.js publishes twMerge as a global for every mounted PulsePoint script, and that is the one these components use. There is no Rust merge and no dependency carrying one, which has one consequence worth knowing.
// A class override is merged in the browser, by the twMerge that
// public/js/main.js already publishes — Tailwind resolves a conflict by
// stylesheet order, not attribute order, so joining the two strings
// would be wrong.
<Button class="bg-emerald-600 hover:bg-emerald-700">"Publish"</Button>
// With no override there is nothing to resolve, so the class list is a
// finished string written by the server and the button is styled with
// JavaScript or without it.
<Button>"Publish"</Button>
// shadcn's own escape hatch, for an element this component does not
// render: the class list alone.
<a href="/docs" class=@{button_variants(ButtonVariant::Link, ButtonSize::Sm, "")}>
"Docs"
</a>With no override there is nothing to resolve: the class list is a finished string written by the server, and the element is styled whether or not JavaScript runs. Write an override and class becomes a {twMerge(…)} binding — literal text in the served HTML, real when PulsePoint mounts the block. Which is also why a component never needs the merge for its own class list: the layers are kept conflict-free, so a plain join is exact.
The stylesheet
Every class string is shadcn's, and shadcn's classes name shadcn's theme: bg-primary, border-input, animate-in, data-horizontal:h-px. None of those exist in the scaffold's placeholder stylesheet, so a Button installed into a fresh project would render as unstyled text — which looks like a broken component rather than a missing @theme block. So the theme goes in with the first component.
| File | What it is |
|---|---|
| src/app/globals.css | The application's stylesheet. The theme is written into it on the first add — and only when nobody has touched it |
| src/app/_vendor/tw-animate-css.css | A vendored npm package. There is no bundler to resolve @import, so it lives on disk beside the stylesheet that imports it |
The vendored file is written whenever it differs — nothing else writes there and nobody edits minified CSS. globals.css is the application's, and is replaced in exactly two situations: it is not there, or it is still byte-for-byte what cargo rahti new wrote. rahti.config.json answers that second question — the scaffold records a SHA-256 for every file it writes, and this asks it the same thing cargo rahti upgrade does.
cargo rahti-ui theme # check globals.css and the vendored stylesheet
cargo rahti-ui theme --print # write the theme to stdout, to merge by hand
cargo rahti-ui theme --force # replace a stylesheet you have editedCommands
cargo rahti-ui add <component>... # install one or several, and their graph
cargo rahti-ui add --all # every component in the catalog
cargo rahti-ui add <name> --force # overwrite what is already there
cargo rahti-ui update # re-fetch everything installed
cargo rahti-ui remove <component>... # delete the files
cargo rahti-ui list --search menu # what the catalog has
cargo rahti-ui list --installed # what this project has, and its tags
cargo rahti-ui theme # check the stylesheet| Flag | What it does |
|---|---|
| -a, --all | Every component in the catalog — all 62, and a long compile. Takes no names beside it. |
| -f, --force | Overwrite files that are already there, edits included. |
| --no-icons | Do not run cargo rahti-icons add; print the line instead. |
| --no-css | Do not install the theme on the first add. |
| --installed | List what this project has, with the tags each file declares, rather than what the catalog has. |
| --search <text> | Narrow either list. |
theme only: write the stylesheet to stdout instead of installing it. |
remove deletes the files and then names anything still importing them, because a component removed out from under its callers is an error inside a file you did not write. Adding it back, or removing them too, is the answer it offers.
What the project records
Every command that changes what is installed rewrites two files at the project root: rahti-ui.json and .github/instructions/rahti-ui.instructions.md. Between them they answer, for anything that cannot read Rust, which components are here and how to add another. Neither carries a timestamp, so a run that changed nothing is an empty diff.
{
"schema": 1,
"generatedWith": "0.0.1",
"project": {
"componentsDirectory": "src/components/rahti_ui",
"modulePath": "crate::components::rahti_ui",
"iconsDirectory": "src/components/rahti_icons",
"stylesheet": "src/app/globals.css",
"iconInstaller": "cargo-rahti-icons"
},
"components": [
{
"name": "sidebar",
"module": "crate::components::rahti_ui::sidebar",
"components": ["Sidebar", "SidebarContent", "SidebarInset", "SidebarMenu",
"SidebarMenuSubButton", "SidebarProvider", "SidebarTrigger"],
"files": [
{ "path": "src/components/rahti_ui/sidebar.rs", "sha256": "9f86d081…" }
]
}
]
}A component file is not one component. button.rs declares Button; sidebar.rs declares eleven, and table.rs nine. Nothing about the name sidebar says where SidebarInset lives, so every #[component] is listed beside its module, and the answer is one grep of one file. Across the catalog that is 268 tags in 62 files.
Where the components come from
A catalog service, fetched over HTTPS at install time and never at run time. It serves the components written and reviewed in rahti-ui-components — itself a running Rahti application, whose src/components/rahti_ui/ is the corpus and whose pages are the demonstration of each one.
GET https://rahti.ui.tsnc.tech/cli?component=all
["accordion", "alert", "alert-dialog", "avatar", "badge", …]
GET https://rahti.ui.tsnc.tech/cli?component=button
{ "name": "button",
"files": [ { "name": "button.rs", "content": "//! Button — …" } ] }It speaks the same protocol as the maddex catalog, deliberately: one service answers both, and a component that gains a second file gains it for every client at once. A file name the catalog sends is not trusted for where it goes — an absolute path, a backslash or a .. segment is refused. The catalog says what a component is; the tool says where it lands.
What is in the catalog
Sixty-two entries, of which utils and attrs are the two every component shares. cargo rahti-ui list is the answer that cannot go stale; this is the shape of it.
| Group | Components |
|---|---|
| Forms | button, button-group, checkbox, combobox, field, input, input-group, input-otp, label, radio-group, select, slider, switch, textarea, toggle, toggle-group |
| Overlays | alert-dialog, context-menu, dialog, drawer, dropdown-menu, hover-card, menubar, popover, sheet, tooltip |
| Layout | accordion, aspect-ratio, card, collapsible, item, resizable, scroll-area, separator, sidebar, tabs |
| Data | calendar, carousel, chart, command, date-picker, pagination, table |
| Feedback | alert, empty, progress, skeleton, spinner, toast |
| Content | avatar, badge, breadcrumb, kbd, marker, navigation-menu |
| Conversation | attachment, bubble, message, message-scroller, questionnaire |
| Shared | attrs, utils — installed with the first component, whatever it is |
For a coding agent
Rahti is not in any model's training data, and neither is this port. Three things in a project answer for it: the instructions file above, rahti-ui.json for the tags, and the component files themselves — which are written to be read, each one opening with a module comment on what its React original did and what changed on the way over.
The one thing worth putting in front of an agent explicitly is the boundary rule. An attribute binding on a component tag is the failure that produces no error message at all, and a model that has written React will reach for it. See the component boundary above, and the MCP server for what else a project can be asked about itself.
