- Rust 78.5%
- CSS 9.9%
- JavaScript 9.7%
- Nix 1.4%
- Fluent 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
A `data-ak-fit` container (the top bar) shrinks children marked
`data-ak-shrink="<order>"` while it or a tab scroller in it overflows,
lowest order first, then undoes steps a later one made unnecessary.
Measured on container resize and content change, never polled, with
transitions off while measuring. Scrolling stays the last resort.
- Tabs { shrink }: icon-only items; active and focused keep their label,
every label stays the accessible name.
- SearchField { shrink }: the icon alone until focused or holding text.
- .ak-topbar-end keeps its width, as the top-bar spec says: its tools no
longer overflow it (the Deck's clock sat off-screen).
- APPKIT_SMOKE=fit in the desktop demo; OpenSpec change add-fit-container.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
| .claude | ||
| crates | ||
| examples | ||
| openspec | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
appkit
One UI vocabulary for every kind of Rust app: Dioxus apps (desktop, web) and
axum + maud + htmx apps. Components own function and structure; each app keeps
its own look.
use appkit::prelude::*;
use appkit::ui;
use dioxus::prelude::*;
use maud::html;
// Dioxus
let live = dioxus_ssr::render_element(rsx! { StatusBadge { code: 503 } });
// maud, with rsx syntax ...
let from_ui = html! { (ui! { StatusBadge { code: 503 } }) }.into_string();
// ... or with the props builder
let from_builder = html! { (StatusBadgeProps::builder().code(503).build()) }.into_string();
assert_eq!(live, from_ui);
assert_eq!(live, from_builder);
All three render identical markup; this example is a doctest, and crates/appkit/tests/ checks it for every component.
Vision
appkit is the layer under every Shinra app: everything that is not the app itself.
Shell, window chrome, dialogs, fields, tables, empty states, input handling, focus,
translations, server plumbing. An app should only have to write its core.
- Dev XP: a new app starts as polished as the last one. The measure is how much code
sits between an empty crate and a themed, translated app shell with dialogs, empty
and error states. That number should keep shrinking. - User XP: polish is the default, not a discipline. Focus, Esc, keyboard and
gamepad navigation, loading and empty states, accessibility: the component does
them, so no app can forget them. A fix here fixes every app. - Open standards underneath. Apps lean on the platform (HTML, CSS layers and
tokens,<dialog>, popover, invoker commands), not on appkit inventions. That is
what lets the behaviour be shared while every app keeps its own look. - Extract, don't invent. Something enters appkit when two apps need it, starting
from the best existing implementation. Proof that it works: pilot apps delete more
code than they add.
Two risks can sink this, and every change is weighed against them:
- Performance. Shared layers tend to get slow without anyone noticing. Known pressure points:
each maud render of a component builds a short-livedVirtualDom(fine for one
badge, but it has to be measured for a thousand table rows);appkit.css/appkit.jsship
to every page; and a webview app has to start and scroll as fast as a native one. appkit
must never be the reason an app is slower than writing it by hand, so performance
claims get benchmarks, not intuition. - Native platform feel. "One vocabulary everywhere" must not become "the same
everywhere". macOS keeps its traffic lights and conventions, Linux and Windows get
their own chrome, and mobile and touch (safe areas, touch targets, no hover
assumptions, platform back gestures) are targets, not afterthoughts. Behaviour is
verified in WKWebView and WebKitGTK, not only Chromium. A component that feels
foreign on a platform is a bug.
Principles
- Function, not look. Components emit an
ak-*class anddata-*hooks
(data-tone,data-kind, ...), merge yourclass, pass through any attribute. - Your CSS wins. appkit's CSS lives in
@layer appkit; any unlayered rule beats it.
It reads the token names your apps already use (--bg --surface --border --text --accent --radius --ok --fail ...), so an existing app is themed for free. - Native first.
<dialog>, invoker commands (commandfor),closedby: the
browser does focus trapping, Esc and stacking.appkit.jsonly polyfills, the
same in a webview and a server-rendered page.
Components
| components | |
|---|---|
| page structure | PageHeader, Breadcrumbs (+ Crumb), Section |
| data display | Stats (+ StatItem), KeyValue (+ Fact), Table (+ Column), Notice, Meter, StackedMeter (+ Part) |
| controls | Button, Dialog, Field, Input |
| small things | Badge, StatusBadge, EmptyState, Icon |
| layout (fixed set) | Stack, Cluster |
A surface is a class, not a component: put class: "ak-card" on anything.
Slots: maud markup straight into a component
Content slots (children, hint, lead, aside, actions, a fact's
value) take rsx, text, or maud Markup. Markup is placed inside the slot's own
container with no wrapper, so existing maud rows land directly in tbody:
use appkit::prelude::*;
use maud::html;
let rows = html! { tr { td { "docs.rs" } td.num { "18204" } } };
let page = html! {
(TableProps::builder()
.columns(vec![Column::new("Site"), Column::new("Pages").end()])
.children(rows)
.build())
};
assert!(page.into_string().contains("<tbody><tr><td>docs.rs</td>"));
Plain strings are always escaped; only Markup (escaped by maud already) is
inserted as HTML.
Icons
Apps keep their own icon sets; Icon renders your 24×24 stroke path data at
1em, in currentColor, hidden from assistive technology:
use appkit::prelude::*;
use dioxus::prelude::*;
const CHEVRON: &str = r#"<path d="m9 18 6-6-6-6"/>"#;
let html = dioxus_ssr::render_element(rsx! { Icon { shape: CHEVRON } });
assert!(html.contains(r#"width="1em""#));
The app skeleton
AppShell is a title-bar region, an optional sidebar and main. While it is
on the page the document never scrolls or rubber-bands: only main (and the
sidebar) scroll, and chrome text is not selectable. It renders the same from
Dioxus and from maud:
use appkit::prelude::*;
use maud::{Render, html};
let page = AppShellProps::builder()
.bar(html! { header.ak-titlebar { span.ak-titlebar-title { "webarc" } } })
.sidebar(html! { nav { a href="/" { "Sites" } } })
.children(html! { h1 { "Sites" } })
.build()
.render()
.into_string();
assert!(page.contains(r#"<main class="ak-shell-main"><h1>Sites</h1></main>"#));
Desktop windows: appkit-desktop
Native-feeling window chrome for Dioxus desktop, distilled from noita2, shinra
and gamenix. macOS keeps its real traffic lights over a transparent title bar;
Linux and Windows get no system frame, and appkit draws controls and resize
edges. No menu bar, no white start-up flash, no "Inspect Element" in release.
use appkit_desktop::{DesktopShell, TitleBar, WindowSetup};
fn main() {
WindowSetup::new("gamenix").size(1280.0, 800.0).background(13, 14, 18).launch(App);
// or: dioxus::LaunchBuilder::desktop().with_cfg(setup.config().with_…(…)).launch(App)
}
#[component]
fn App() -> Element {
rsx! {
DesktopShell {
titlebar: rsx! { TitleBar { title: "gamenix", trailing: rsx! { /* tabs, tools */ } } },
sidebar: rsx! { /* navigation */ },
/* content */
}
}
}
(Compiled for real in examples/desktop-demo. APPKIT_SMOKE=1 cargo run -p desktop-demo checks the first live render and prints SMOKE ok.)
| pressing the title bar | moves the window; a second press within 400 ms maximises |
|---|---|
things in leading / trailing |
stay clickable, never move the window |
WindowControls |
minimise, maximise/restore, close (onclose to override); not on macOS or fullscreen |
ResizeEdges |
eight handles; gone when maximised, fullscreen, or on macOS |
Where it came from:
| app | replaces |
|---|---|
| noita2 | ui/window_frame.rs (use_grab, keep, WindowControls, ResizeEdges), window setup in ui/shell.rs |
| shinra | widgets.rs Chrome/ResizeEdges, window setup in main.rs (incl. traffic_light_inset) |
| gamenix | chrome.rs Chrome/ResizeEdges/use_maximized, window builder in main.rs (GPU policy and fullscreen mode stay app-side via .config()) |
One route, many representations
A resource route loads its data once and lets the request pick the
representation: the component for an htmx swap, the full page for a browser,
JSON for an API client, a versioned vendor type (json_as) for old clients.
Each representation has its own type, so the UI can change without breaking the
API. Responses carry Vary: Accept, HX-Request, a strong ETag per
representation and Cache-Control: no-cache; a repeat request with
If-None-Match gets 304. Nothing acceptable gets 406.
use appkit_server::{Negotiate, Respond};
use axum::{Router, body::{Body, to_bytes}, extract::Path, http::Request, routing::get};
use maud::html;
use serde::Serialize;
use tower::ServiceExt;
#[derive(Serialize)]
struct ApiRun { id: u32, status: &'static str } // the API model
async fn run(neg: Negotiate, Path(id): Path<u32>) -> Respond {
let status = "passed"; // load once
neg.respond()
.html(html! { div #run { "Run " (id) ": " (status) } }, // the view
|content| html! { main { (content) } }) // the page around it
.json(ApiRun { id, status })
}
let app = Router::new().route("/runs/{id}", get(run));
let ask = |accept: &'static str| {
let app = app.clone();
async move {
let request = Request::get("/runs/7").header("accept", accept).body(Body::empty()).unwrap();
let body = app.oneshot(request).await.unwrap().into_body();
String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap()
}
};
assert_eq!(ask("application/json").await, r#"{"id":7,"status":"passed"}"#);
assert_eq!(ask("text/html").await, r#"<main><div id="run">Run 7: passed</div></main>"#);
(Runs as a doctest in appkit_server's crate docs; cargo run -p htmx-demo
serves the same idea at /runs/42.)
Migrating webarc: one resource per thing. /records/{id} serves the record
page, the fragment and application/vnd.webarc.record.v1+json from the
existing API type, and /api/v1/records/{id} stays as an alias until clients
move.
Islands: pages built from parts that fetch themselves
An Island knows the URL that renders it and swaps its own outer HTML with
that URL's fragment. The server stays in charge: a live run comes back with
every, a finished one without it, and polling stops by itself.
use appkit::prelude::*;
use dioxus::prelude::*;
let live = dioxus_ssr::render_element(rsx! {
Island { src: "/runs/42", every: "2s", refresh_on: vec!["runs-changed".to_string()],
Badge { tone: Tone::Running, "running" }
}
});
assert!(live.contains(r#"id="ak-island-runs-42""#));
assert!(live.contains(r#"hx-trigger="every 2s, runs-changed from:body, refresh""#));
assert!(live.contains(r#"hx-swap="outerHTML""#));
every: "2s": poll; render the next island without it to stoplazy: true: load when scrolled into view; children are the placeholderrefresh_on: refresh when any response announces the event
(neg.respond()…trigger("runs-changed"))- the id comes from the URL, so swaps keep it and two islands never share one
Links come from typed paths (appkit_server::{TypedPath, RouterExt}, from
axum-extra): one struct is the route and every link to it, percent-encoded,
with malformed parameters rejected before the handler. Add
axum-extra = { version = "0.10", features = ["typed-routing"] } next to axum
(the derive's generated code names it).
#[derive(TypedPath, Deserialize)]
#[typed_path("/runs/{id}")]
struct RunPath { id: u32 }
router.typed_get(run); // the route
Island { src: RunPath { id }.to_string(), … } // a link that cannot drift
cargo run -p htmx-demo, then open /runs: a summary refreshed by events,
runs that poll until they pass, a restart that announces runs-changed, and
a lazy archive further down.
moira's helpers map onto it directly:
moira (web/ui.rs) |
appkit |
|---|---|
live(id, url, interval, active, body) |
Island { src: url, every: active.then(…), id, body } |
poll_status(active) (286 when settled) |
not needed: render the island without every |
fetch_once(url) |
Island { src: url, lazy: true, … } |
dom_id(prefix, key) |
ids derived from the island's URL |
Translations (Fluent)
Every word appkit's components say ("Close", "Minimise", "Restore", the
breadcrumb label, "3 days ago") comes from appkit's own catalogue, in English
and German, so components follow the app's language with no work:
use appkit::i18n::Locale;
use appkit::prelude::*;
use dioxus::prelude::*;
let de = Locale::parse("de").unwrap();
let html = dioxus_ssr::render_element(rsx! {
LocaleProvider { locale: de, Dialog { id: "d", "…" } }
});
assert!(html.contains(r#"aria-label="Schließen""#));
An app's own words live in i18n/<lang>/<package>.ftl next to an i18n.toml
(fallback_language = "en", [fluent] assets_dir = "i18n"):
#[derive(appkit::__private::rust_embed::RustEmbed)]
#[folder = "i18n/"]
#[crate_path = "appkit::__private::rust_embed"]
struct Words;
static APP: LazyLock<Catalog> = LazyLock::new(|| appkit::catalog!(Words));
appkit::t!(APP, "run-title", id = id) // a wrong key does not compile
appkit::i18n::register(&APP); // optional: override appkit-* wording
#[test]
fn translations_are_whole() { // keys, arguments, parse errors
appkit::i18n::assert_catalogs::<Words>("my-app", "en");
}
- English sits under every language: a missing translation reads as English,
never as a key. No bidi isolation marks around arguments. - The language of a render is the Dioxus context (
use_locale(), set it to
switch live), else the server request's, else the process default. - Servers:
router.with_locales(Locales::new(APP.languages()))picks the
language per request (?lang=→langcookie → your resolver, e.g. the
user's setting →Accept-Language→ default), keeps it across.awaits and
only for that request, and setsContent-LanguageandVary.
.setting_first(true)ranks the saved setting above?lang=/the cookie;
set_locale(..)switches a request mid-handler (a settings page answering
in the new language). - Desktop:
WindowSetuppicks the OS languages before the first frame
(APPKIT_LANG=deto try one);DesktopShellprovides the switchable
locale and setslang. t!passes arguments tofl!, which calls.into()without parentheses:
writecount = norcount = (a + b).
Migrating shinra: i18n.rs's LOADER/fl!/settle() become
catalog!/t!/WindowSetup, and its parity test becomes assert_catalogs.
The catalogue (i18n/{en,de}/shinra_app.ftl) stays as it is.
Mouse, touch, keyboard and gamepad: one focus model
appkit.js marks which input drives the UI and styles follow it:
on <html> |
when |
|---|---|
data-ak-input="pointer" |
a mouse moved (a tap that only looks like a mouse does not count) |
data-ak-input="touch" |
a touch or pen press |
data-ak-input="keys" |
a key press, or a gamepad press through the bridge |
data-ak-gamepad |
a gamepad is connected |
data-ak-background |
the window lost focus while the gamepad bridge is on (animations pause) |
Before any input there is no data-ak-input (a handheld's parked cursor must
not light up hover). Opt into a preset with <html data-ak-input-initial="auto">.
appkit's own hover styles apply only under pointer; the cursor hides for
touch and for a gamepad; gamepad focus shows the focus ring.
Gamepad as keyboard (opt-in: <html data-ak-gamepad-keys>,
appkit.gamepad.enable(), or WindowSetup::gamepad(true)), standard mapping:
A→Enter (clicks buttons and links), B/Select→Escape (also closes the topmost
dialog), Y→/, L1/R1→[/], Start→ContextMenu, d-pad and left stick→arrows
(repeat after 380 ms, every 110 ms), X unbound; appkit.gamepad.map replaces
it. A press with nothing focused only restores focus; an unfocused window
(a game running) is ignored until every button has been released.
Focus:
- arrow groups:
data-ak-arrows="horizontal|vertical|both"moves focus with
the arrow keys (and the d-pad), skipping hidden and disabled controls appkit::focus::{first, step, recover}from Rust
(appkit.focus.*in JS): focus memory per page (data-ak-focus-scope,
else the firsth1, else the title) and selector, never per URL; a dialog
never overwrites the page's memoryHoldButton { ms: 2000, onconfirm, "Power off" }(orhx_post): confirms
only when held, progress in--ak-hold
APPKIT_SMOKE=input cargo run -p desktop-demo drives all of it with a
scripted gamepad in the live webview.
Migrating gamenix (assets/input.js, assets/focus.js):
| gamenix | appkit |
|---|---|
.pointer, .touch, .gamepad, html.background |
[data-ak-input="pointer"], [data-ak-input="touch"], [data-ak-gamepad], [data-ak-background] |
| input.js | WindowSetup::gamepad(true) |
gamenixFocus.first/step/recover |
arrow groups, or appkit::focus::{first, step, recover} |
data-focus-key, .page-title memory key |
data-ak-focus-key, data-ak-focus-scope |
data-hold, --hold, next-sibling target |
HoldButton (data-ak-hold, --ak-hold) |
.overlay Tab trap |
native <dialog> (Dialog) |
Tabs and filters
A row of choices with one active, scrolling sideways when it overflows:
use appkit::prelude::*;
use dioxus::prelude::*;
let items = vec![TabItem::new("all", "All"), TabItem::new("gba", "GBA").title("Game Boy Advance")];
let html = dioxus_ssr::render_element(rsx! {
Tabs { items, active: "gba", label: "Platforms", cycle_prev: "[", cycle_next: "]" }
});
assert!(html.contains(r#"aria-pressed="true""#)); // a filter, by default
assert!(html.contains(r#"data-ak-tabs-next="]""#));
- container
ak-tabs> scrollerak-tabs-scroller> itemsak-tab
(ak-tab-icon+ak-tab-label), separate boxes, so a blur on the container
and a fade mask on the scroller do not clip each other in WebKit mode: TabsMode::Filter(default,aria-pressed) orTablist
(role="tab",aria-selected,aria-controlsviaTabItem::controls)onselect(key)in Dioxus;TabItem::hreffor server-rendered linkscycle_prev/cycle_next: pressed anywhere (not while typing; inside the
open modal only), they select the previous/next item with wrap and leave
focus where it is. A gamepad's L1/R1 arrive as[/]- the active item is kept in view by scrolling only the bar; edges that hide
items fade (data-ak-overflow-start/-end); a vertical wheel scrolls
sideways; arrow keys move focus. Smoothness is CSS's call
(scroll-behavior), so one rule turns motion off
APPKIT_SMOKE=tabs cargo run -p desktop-demo checks it live.
gamenix's platform pills map as .platforms → .ak-tabs,
.platform-tabs → .ak-tabs-scroller, .pill → .ak-tab, .pill.active
→ .ak-tab[data-active], .pill-label → .ak-tab-label; cycle and its
scroll-into-view eval become cycle_prev: "[", cycle_next: "]".
Search
use appkit::prelude::*;
use dioxus::prelude::*;
let html = dioxus_ssr::render_element(rsx! {
SearchField { label: "Search games", placeholder: "Search", focus_key: "/", status: "12 games" }
});
assert!(html.starts_with(r#"<label class="ak-search""#)); // the whole pill is the target
assert!(html.contains(r#"role="status" aria-live="polite""#));
- the pill is a
<label>: a click anywhere focuses the input; focus shows
on the pill, never on the input;valueonly seeds it (no dropped keys) - Escape clears (the app gets an empty
oninput);focus_key(/, and
a gamepad's Y) focuses it from anywhere outside a text field, without
typing the key;clear_button: trueadds a × for mouse and touch only - Dioxus:
onleave(SearchLeave::{Escape, Commit, Up})for your own focus
logic; keys the field consumes (typing, caret keys, Escape/Enter/arrows)
stop at the field, while F-keys, Tab, ContextMenu and Ctrl/Meta/Alt
shortcuts still reach app-wide handlers statusis announced politely and visually hidden unless you show it
gamenix: .search → .ak-search, .search input → .ak-search-input;
the (Zone::Search, _) swallow arm and the Esc/Enter/Up arms become
onleave; the / binding becomes focus_key: "/".
Controller hints
use appkit::prelude::*;
use dioxus::prelude::*;
let hints = vec![Hint::new("Select").button(PadButton::South), Hint::new("Back").button(PadButton::East)];
let html = dioxus_ssr::render_element(rsx! { ControllerHints { hints, glyphs: GlyphSet::Nintendo } });
assert!(html.contains(r#"data-button="south">B</kbd>"#)); // Nintendo: confirm is south, labelled B
- buttons are standard-mapping positions (
PadButton::South,L1, ...), the
same ones the gamepad bridge maps;GlyphSet::{Generic, Xbox, PlayStation, Nintendo}prints them (Generic: A B X Y, L1 R1, ←→) - shown only while a gamepad drives the UI (connected, mouse and touch not in
use); a modal's own hints replace the page's while it is open (CSS:has,
WebKitGTK 2.42+) - position is yours (gamenix pins it bottom-right)
gamenix: footer.hints / kbd → ControllerHints, .hints → .ak-hints.
Icon links and the top bar
use appkit::prelude::*;
use dioxus::prelude::*;
let html = dioxus_ssr::render_element(rsx! {
IconLink { label: "Downloads", href: "/downloads", tone: Tone::Running, badge: "2" }
});
assert!(html.contains(r#"title="Downloads""#));
assert!(html.contains(r#"<span class="ak-icon-link-label">Downloads</span>"#)); // collapsed, still read
- the label slides out on pointer hover, keyboard focus and gamepad focus;
reveal: Reveal::Alwayskeeps it shown (ellipsised at
--ak-icon-link-label-max); it is clipped, never removed, so screen readers
always have it href→ link, else a button withonclick; for router links use your own
Link { class: "ak-icon-link", "data-reveal": "hover", title, IconLinkBody { .. } }
(in Dioxus desktop a plaina hrefwould leave the app)tonefor states (Bad = attention, Running = busy);badgesits on the
corner, out of the layout
The top bar is a recipe, not a component (the breakpoint is yours):
header.ak-topbar > .ak-topbar-brand + .ak-topbar-center + .ak-topbar-end,
and to give the centre its own row when the bar gets narrow. Query the bar's
container, not the viewport: beside a sidebar, or in a phone's portrait
window, the bar is narrower than the screen, and the end group never shrinks:
.page { container-type: inline-size } /* whatever holds the bar */
@container (max-width: 64rem) {
.ak-topbar { flex-wrap: wrap; row-gap: .75rem }
.ak-topbar-center { order: 3; flex: 1 1 100% }
}
Before wrapping or scrolling, a bar can make room by shrinking its children.
Mark the bar data-ak-fit and give children an order with shrink; while
anything overflows, appkit shrinks them lowest first, then undoes steps a
later one made unnecessary. Tabs go icon-only (the active and focused item
keep their label; every label stays the accessible name); search becomes its
icon until focused or holding text. Scrolling stays the last resort.
header { class: "ak-topbar", "data-ak-fit": "true",
Tabs { class: "ak-topbar-center", items, active, shrink: 2, .. }
div { class: "ak-topbar-end", SearchField { label: "Search", shrink: 1, .. } }
}
APPKIT_SMOKE=fit cargo run -p desktop-demo checks it live.
gamenix: .icon-link/.icon-label → .ak-icon-link/.ak-icon-link-label,
.attention/.busy → [data-tone="bad"]/[data-tone="running"],
.playing-link → [data-reveal="always"], .bar/.brand/.bar-end →
.ak-topbar/.ak-topbar-brand/.ak-topbar-end.
Pointer-free and scalable
Nothing is hover-only (stat hints are real text behind aria-describedby),
every interactive part takes keyboard focus with one tokenised focus style
(--ak-focus, --ak-focus-width, --ak-focus-offset), and all sizing is
rem/em, so a TV app raising its root font size scales everything. The
desktop demo's tv theme is the gamenix check.
Migrating webarc
webarc's components map one to one. Keep its function names as thin wrappers
first, then move call sites:
webarc (src/ui/components) |
appkit |
|---|---|
card(body) |
any element with class: "ak-card" |
section(h, body), section_with(h, aside, body) |
Section { title, actions } |
page_header(title, subtitle, actions) |
PageHeader { title, lead, actions } |
breadcrumbs(site, segments) |
Breadcrumbs { crumbs } |
stats(&[(value, label)]) |
Stats { items: Vec<StatItem> } |
table(headers, body) |
Table { columns, children: rows } |
kv(pairs) |
KeyValue { items: Vec<Fact> } |
notice(tone, body) |
Notice { tone } |
progress(percent, tone) |
Meter { ratio, tone } |
empty(message) |
EmptyState |
badge, status_badge, Tone |
Badge, StatusBadge, Tone (same tone names) |
rows, counted, bar_chart, load_more, grid |
stay in webarc for now |
Tokens: webarc's names already match except one, so add
--ak-muted: var(--text-3);. The demos' webarc theme is webarc's real CSS
retargeted to ak-* selectors.
Crates
| crate | what |
|---|---|
appkit |
components, ui!, Tone, fmt (ago/duration/bytes), CSS/JS assets |
appkit-macros |
#[appkit::component] (Dioxus component + maud::Render), ui! |
appkit-server |
Negotiate/Respond (content negotiation, ETags, 304), Hx extractor, page_or_fragment, redirect, HashedAsset |
appkit-desktop |
WindowSetup, DesktopShell, TitleBar, WindowControls, ResizeEdges (Dioxus desktop only) |
Try it
nix develop
cargo run -p htmx-demo # http://127.0.0.1:3080/?theme=paper|night|candy|webarc, /app for the shell
cargo run -p desktop-demo # same components, live; themes incl. webarc and tv
cargo test --workspace
Changes
Spec-driven with OpenSpec: baseline specs in openspec/specs/, sequencing in
openspec/roadmap.md. Start a change with /opsx:propose "...".