No description
  • Rust 78.5%
  • CSS 9.9%
  • JavaScript 9.7%
  • Nix 1.4%
  • Fluent 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
JMARyA fe125d6a78
feat: fit containers; tabs and search shrink to make room
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>
2026-11-03 19:14:22 +01:00
.claude feat: appkit spike - shared components for Dioxus and maud/htmx apps 2026-11-01 00:48:51 +01:00
crates feat: fit containers; tabs and search shrink to make room 2026-11-03 19:14:22 +01:00
examples feat: fit containers; tabs and search shrink to make room 2026-11-03 19:14:22 +01:00
openspec feat: fit containers; tabs and search shrink to make room 2026-11-03 19:14:22 +01:00
.envrc feat: appkit spike - shared components for Dioxus and maud/htmx apps 2026-11-01 00:48:51 +01:00
.gitignore feat: appkit spike - shared components for Dioxus and maud/htmx apps 2026-11-01 00:48:51 +01:00
Cargo.lock feat: Fluent i18n for components, apps, servers and desktop 2026-11-01 02:51:55 +01:00
Cargo.toml feat: Fluent i18n for components, apps, servers and desktop 2026-11-01 02:51:55 +01:00
CLAUDE.md docs: CLAUDE.md with UX rules 2026-11-01 23:14:18 +01:00
flake.lock feat: appkit spike - shared components for Dioxus and maud/htmx apps 2026-11-01 00:48:51 +01:00
flake.nix feat: Fluent i18n for components, apps, servers and desktop 2026-11-01 02:51:55 +01:00
README.md feat: fit containers; tabs and search shrink to make room 2026-11-03 19:14:22 +01:00

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-lived VirtualDom (fine for one
    badge, but it has to be measured for a thousand table rows); appkit.css/appkit.js ship
    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 and data-* hooks
    (data-tone, data-kind, ...), merge your class, 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.js only 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 stop
  • lazy: true: load when scrolled into view; children are the placeholder
  • refresh_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= → lang cookie → your resolver, e.g. the
    user's setting → Accept-Language → default), keeps it across .awaits and
    only for that request, and sets Content-Language and Vary.
    .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: WindowSetup picks the OS languages before the first frame
    (APPKIT_LANG=de to try one); DesktopShell provides the switchable
    locale and sets lang.
  • t! passes arguments to fl!, which calls .into() without parentheses:
    write count = n or count = (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 first h1, else the title) and selector, never per URL; a dialog
    never overwrites the page's memory
  • HoldButton { ms: 2000, onconfirm, "Power off" } (or hx_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 > scroller ak-tabs-scroller > items ak-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) or Tablist
    (role="tab", aria-selected, aria-controls via TabItem::controls)
  • onselect(key) in Dioxus; TabItem::href for server-rendered links
  • cycle_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: "]".

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; value only 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: true adds 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
  • status is 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.

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::Always keeps 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 with onclick; for router links use your own
    Link { class: "ak-icon-link", "data-reveal": "hover", title, IconLinkBody { .. } }
    (in Dioxus desktop a plain a href would leave the app)
  • tone for states (Bad = attention, Running = busy); badge sits 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 "...".