commits e1e69ec crates/cli/ents-web/src/lib.rs
@@ -106,6 +106,7 @@
//! # });
//! ```
+pub(crate) mod assets;
pub mod error;
pub mod identity;
pub mod pages;
crates/cli/ents-web/src/render.rs
@@ -99,10 +99,12 @@
pub fn view<T: Facet<'static>>(value: &T) -> Markup {
let rows = fields(value);
html! {
- dl.entity-view {
- @for (name, rendered) in &rows {
- dt { (name) }
- dd { (rendered) }
+ div.card {
+ dl.entity-view {
+ @for (name, rendered) in &rows {
+ dt { (name) }
+ dd { (rendered) }
+ }
}
}
}
@@ -136,21 +138,23 @@
.map(|(_, entity)| fields(entity).into_iter().map(|(name, _)| name).collect())
.unwrap_or_default();
html! {
- table.entity-list {
- thead {
- tr {
- th { (id_header) }
- @for name in &field_names {
- th { (name) }
+ div.card {
+ table.entity-list {
+ thead {
+ tr {
+ th { (id_header) }
+ @for name in &field_names {
+ th { (name) }
+ }
}
}
- }
- tbody {
- @for (id, entity) in rows {
- tr {
- td { a href=(href_for(id)) { (id) } }
- @for (_, rendered) in fields(entity) {
- td { (rendered) }
+ tbody {
+ @for (id, entity) in rows {
+ tr {
+ td { a href=(href_for(id)) { (id) } }
+ @for (_, rendered) in fields(entity) {
+ td { (rendered) }
+ }
}
}
}
@@ -165,9 +169,11 @@
#[must_use]
pub fn string_list(rows: &[String], href_for: impl Fn(&str) -> String) -> Markup {
html! {
- ul.string-list {
- @for row in rows {
- li { a href=(href_for(row)) { (row) } }
+ div.card {
+ ul.string-list {
+ @for row in rows {
+ li { a href=(href_for(row)) { (row) } }
+ }
}
}
}
crates/cli/ents-web/src/router.rs
@@ -19,10 +19,11 @@
use axum::extract::{Request, State};
use axum::http::{HeaderValue, header};
use axum::middleware::{self, Next};
-use axum::response::Response;
+use axum::response::{IntoResponse, Response};
use axum::routing::get;
use gix_object::{Find, Write};
+use crate::assets;
use crate::pages;
use crate::session;
use crate::state::AppState;
@@ -57,6 +58,7 @@
)
.route("/comments/{id}", get(pages::comments::show::<O>))
.route("/inbox", get(pages::inbox::list::<O>))
+ .route("/style.css", get(style))
.layer(middleware::from_fn_with_state(
Arc::clone(&state),
session_middleware::<O>,
@@ -107,6 +109,19 @@
response
}
+/// `GET /style.css`: the one stylesheet [`pages::layout`]'s `head` links --
+/// the hand-rolled, ported pre-redo sheet (`crate::assets::OVERRIDES`). No
+/// session or CSRF gating applies here (the session middleware only ever
+/// attaches a session, never rejects a request), and it must not: every
+/// page, including one reached before a session exists, needs this to
+/// render styled.
+async fn style() -> impl IntoResponse {
+ (
+ [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
+ assets::OVERRIDES,
+ )
+}
+
/// Bind a loopback-or-otherwise socket for [`serve_on`], returning the
/// listener before any request is served so a caller can read back
/// [`std::net::TcpListener::local_addr`] (necessary for `addr`'s port `0`,
crates/cli/ents-web/tests/router.rs
@@ -85,6 +85,47 @@
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
+/// `GET /style.css` is reachable with no session at all (every other route
+/// runs behind the session middleware, but that middleware only ever
+/// attaches a session -- it never gates), and serves this crate's own
+/// hand-rolled stylesheet.
+#[tokio::test]
+async fn style_css_is_served_with_no_session_required() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/style.css")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .expect("content-type")
+ .to_str()
+ .expect("ascii"),
+ "text/css; charset=utf-8"
+ );
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 css");
+ assert!(!body.is_empty());
+ assert!(body.contains("--color-bg"));
+}
+
/// `roots.web-agnostic`: the dashboard actually renders real content
/// in-process, with no socket bound anywhere in this test -- reading the
/// body back (rather than only checking the status) is what makes this
crates/cli/ents-web/src/pages/account.rs
@@ -52,6 +52,7 @@
.unwrap_or_else(|| html! { p { "no account created yet" } });
Ok(super::layout(
+ super::Tab::Account,
"account",
html! {
(view)
crates/cli/ents-web/src/pages/comments.rs
@@ -34,6 +34,7 @@
{
let rows = comment::list(state.refs.as_ref(), &*state.objects())?;
Ok(super::layout(
+ super::Tab::Comments,
"comments",
html! {
ul {
@@ -83,6 +84,7 @@
&query.rev,
)?;
Ok(super::layout(
+ super::Tab::Comments,
&id,
html! {
dl {
crates/cli/ents-web/src/pages/dashboard.rs
@@ -28,16 +28,19 @@
let toolchains = state.refs.iter_prefix("refs/meta/toolchains/")?.count();
Ok(super::layout(
+ super::Tab::Dashboard,
"dashboard",
html! {
- ul {
- li { a href="/members" { "members" } " (" (members) ")" }
- li { a href="/account" { "account" } }
- li { a href="/effects" { "effects" } " (" (effects) ")" }
- li { a href="/redactions" { "redactions" } " (" (redactions) ")" }
- li { a href="/toolchains" { "toolchains" } " (" (toolchains) ")" }
- li { a href="/comments" { "comments" } " (" (comments) ")" }
- li { a href="/inbox" { "inbox" } }
+ div.card {
+ ul.string-list {
+ li { a href="/members" { "members" } span.badge { (members) } }
+ li { a href="/account" { "account" } }
+ li { a href="/effects" { "effects" } span.badge { (effects) } }
+ li { a href="/redactions" { "redactions" } span.badge { (redactions) } }
+ li { a href="/toolchains" { "toolchains" } span.badge { (toolchains) } }
+ li { a href="/comments" { "comments" } span.badge { (comments) } }
+ li { a href="/inbox" { "inbox" } }
+ }
}
},
))
crates/cli/ents-web/src/pages/effects.rs
@@ -28,6 +28,7 @@
{
let rows = read_all(&state)?;
Ok(super::layout(
+ super::Tab::Effects,
"effects",
crate::render::list_table(&rows, "name", |id| format!("/effects/{id}")),
))
@@ -56,6 +57,7 @@
Err(error) => format!("does not parse: {error}"),
};
Ok(super::layout(
+ super::Tab::Effects,
&name,
html! {
(crate::render::view(&effect))
crates/cli/ents-web/src/pages/inbox.rs
@@ -30,6 +30,7 @@
}
}
Ok(super::layout(
+ super::Tab::Inbox,
"inbox",
crate::render::string_list(&rows, |_| "/inbox".to_owned()),
))
crates/cli/ents-web/src/pages/members.rs
@@ -24,6 +24,7 @@
{
let rows = read_all(&state)?;
Ok(super::layout(
+ super::Tab::Members,
"members",
crate::render::list_table(&rows, "username", |id| format!("/members/{id}")),
))
@@ -47,7 +48,11 @@
.ok_or_else(|| Error::NotFound {
what: format!("member {username}"),
})?;
- Ok(super::layout(&username, crate::render::view(&member)))
+ Ok(super::layout(
+ super::Tab::Members,
+ &username,
+ crate::render::view(&member),
+ ))
}
fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Member)>> {
crates/cli/ents-web/src/pages/mod.rs
@@ -50,31 +50,61 @@
Ok(commit.tree())
}
+/// The tab-nav page families this crate exposes -- one variant per tab in
+/// [`layout`]'s nav bar, so a handler can name which tab it renders behind
+/// without `layout` re-deriving it from the request path (mirrors
+/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `Tab` enum,
+/// trimmed to this crate's page families).
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Tab {
+ Dashboard,
+ Members,
+ Account,
+ Effects,
+ Redactions,
+ Toolchains,
+ Comments,
+ Inbox,
+}
+
/// Wrap `title` and `body` in the one page shell every route renders
-/// through -- navigation to every generic and custom page family this
-/// crate exposes.
-pub(crate) fn layout(title: &str, body: Markup) -> Markup {
+/// through -- the pre-redo header bar and tab nav
+/// (`pre-redo:crates/git-ents-server/src/web/style.css`'s `.site-nav`/
+/// `.tabs` rules), `active` naming which tab is current.
+pub(crate) fn layout(active: Tab, title: &str, body: Markup) -> Markup {
html! {
(maud::DOCTYPE)
- html {
+ html lang="en" {
head {
meta charset="utf-8";
+ meta name="viewport" content="width=device-width, initial-scale=1";
+ meta name="color-scheme" content="light dark";
title { "git ents: " (title) }
+ link rel="preconnect" href="https://fonts.googleapis.com";
+ link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
+ link rel="stylesheet" href=(crate::assets::FONTS_HREF);
+ link rel="stylesheet" href="/style.css";
}
body {
- nav {
- a href="/" { "dashboard" } " | "
- a href="/members" { "members" } " | "
- a href="/account" { "account" } " | "
- a href="/effects" { "effects" } " | "
- a href="/redactions" { "redactions" } " | "
- a href="/toolchains" { "toolchains" } " | "
- a href="/comments" { "comments" } " | "
- a href="/inbox" { "inbox" }
+ nav.site-nav {
+ div.nav-inner {
+ a.nav-logo href="/" { span.nav-mark { "✳" } "git-ents" }
+ }
+ }
+ nav.tabs {
+ a.tab.active[active == Tab::Dashboard] href="/" { "dashboard" }
+ a.tab.active[active == Tab::Members] href="/members" { "members" }
+ a.tab.active[active == Tab::Account] href="/account" { "account" }
+ a.tab.active[active == Tab::Effects] href="/effects" { "effects" }
+ a.tab.active[active == Tab::Redactions] href="/redactions" { "redactions" }
+ a.tab.active[active == Tab::Toolchains] href="/toolchains" { "toolchains" }
+ a.tab.active[active == Tab::Comments] href="/comments" { "comments" }
+ a.tab.active[active == Tab::Inbox] href="/inbox" { "inbox" }
+ }
+ main.content {
+ div.page-header { h1.page-title { (title) } }
+ (body)
}
- hr;
- h1 { (title) }
- (body)
}
}
}
crates/cli/ents-web/src/pages/redactions.rs
@@ -23,6 +23,7 @@
{
let rows = read_all(&state)?;
Ok(super::layout(
+ super::Tab::Redactions,
"redactions",
crate::render::list_table(&rows, "id", |id| format!("/redactions/{id}")),
))
@@ -46,7 +47,11 @@
.ok_or_else(|| Error::NotFound {
what: format!("redaction {id}"),
})?;
- Ok(super::layout(&id, crate::render::view(&redaction)))
+ Ok(super::layout(
+ super::Tab::Redactions,
+ &id,
+ crate::render::view(&redaction),
+ ))
}
fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Redaction)>> {
crates/cli/ents-web/src/pages/toolchains.rs
@@ -29,6 +29,7 @@
{
let names = toolchain::list(state.refs.as_ref())?;
Ok(super::layout(
+ super::Tab::Toolchains,
"toolchains",
crate::render::string_list(&names, |name| format!("/toolchains/{name}")),
))
@@ -51,6 +52,7 @@
let (toolchain, recipe) = toolchain::view(state.refs.as_ref(), &*state.objects(), &name)?;
let log = toolchain::log(state.refs.as_ref(), &*state.objects(), &name)?;
Ok(super::layout(
+ super::Tab::Toolchains,
&name,
html! {
dl {
crates/cli/ents-web/src/assets.rs
@@ -1,0 +1,14 @@
+//! Static assets embedded at compile time so the built binary stays
+//! self-contained -- no runtime fetch, no separate asset bundle to ship
+//! alongside `git-ents`. `ents.css` is the hand-rolled pre-redo stylesheet
+//! (`pre-redo:crates/git-ents-server/src/web/style.css`), ported rather
+//! than vendored. [`FONTS_HREF`] is this crate's one exception: the
+//! pre-redo brand type stack is only available from Google Fonts, so it is
+//! loaded at request time rather than embedded.
+
+pub(crate) const OVERRIDES: &str = include_str!("assets/ents.css");
+
+/// Google Fonts stylesheet URL for the pre-redo brand type stack (DM Sans,
+/// IBM Plex Mono, Lora) -- mirrors
+/// `pre-redo:crates/git-ents-server/src/web/assets.rs`'s `FONTS` const.
+pub(crate) const FONTS_HREF: &str = "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Lora:wght@500;600;700&display=swap";
crates/cli/ents-web/src/assets/ents.css
@@ -1,0 +1,182 @@
+/* Hand-rolled stylesheet, ported from the pre-redo web UI
+ * (`pre-redo:crates/git-ents-server/src/web/style.css`) and reverified
+ * against the markup `crate::pages::layout` and `crate::render` actually
+ * emit. Dropped from the pre-redo sheet: syntax-highlight token consumers
+ * (`.code .keyword` and friends -- no highlighter is ported, see
+ * `crate::pages::files`), diff views (`.diff`, `--diff-add`/`--diff-del`),
+ * the clone-URL copy button (`.copy-btn`, `.clone`), live-check output
+ * (`.checks-grid`, `.status-*`, `.terminal-*`), and every chrome class for a
+ * page family this crate does not have (releases, issues, settings,
+ * account-strip sign-in). The `--s-*` syntax tokens are dropped with their
+ * only consumers.
+ */
+:root {
+ --font-sans: "DM Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-serif: "Lora", Georgia, "Times New Roman", serif;
+ --font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace;
+ --max-width: 78rem;
+ --color-bg: #faf8f4;
+ --color-surface: #fff;
+ --color-text: #2a2518;
+ --color-text-muted: #8a7e6a;
+ --color-link: #b07d10;
+ --color-link-hover: #96690a;
+ --color-border: #ede9de;
+ --color-code-bg: #f5f3eb;
+ --color-accent: #b07d10;
+ --color-accent-subtle: #b07d100f;
+ --shadow-sm: 0 1px 3px #0000000d;
+ --shadow-md: 0 4px 16px #0000000f;
+ --radius-sm: 10px;
+ --radius-pill: 100px;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-bg: #171510;
+ --color-surface: #211f17;
+ --color-text: #ede8d8;
+ --color-text-muted: #a89e88;
+ --color-link: #d4a030;
+ --color-link-hover: #e4b850;
+ --color-border: #383324;
+ --color-code-bg: #211f17;
+ --color-accent: #d4a030;
+ --color-accent-subtle: #d4a03012;
+ --shadow-sm: 0 1px 3px #00000040;
+ --shadow-md: 0 4px 16px #0000004d;
+ }
+}
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { font-size: 17px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
+body {
+ font-family: var(--font-sans);
+ background: var(--color-bg);
+ color: var(--color-text);
+ line-height: 1.7;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background-image: radial-gradient(58rem 30rem at 50% -10rem, var(--color-accent-subtle), transparent 72%);
+ background-attachment: fixed;
+}
+h1, h2, h3, h4, h5, h6 { font-family: var(--font-serif); }
+a { color: var(--color-link); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--color-link) 25%, transparent); text-underline-offset: 2px; transition: color .15s, text-decoration-color .15s; }
+a:hover { color: var(--color-link-hover); text-decoration-color: currentColor; }
+a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; }
+.icon { flex-shrink: 0; fill: currentColor; vertical-align: -0.125em; }
+
+/* Header band: site wordmark, then the tab-style page nav. */
+.site-nav { position: sticky; top: 0; z-index: 100; background: color-mix(in srgb, var(--color-bg) 82%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--color-border); }
+.nav-inner { max-width: var(--max-width); margin: 0 auto; height: 58px; padding: 0 1.5rem; display: flex; align-items: center; gap: 1.25rem; }
+.nav-logo { display: inline-flex; align-items: center; gap: .5rem; font-family: var(--font-mono); font-weight: 700; font-size: 1.02rem; color: var(--color-text); letter-spacing: -.01em; text-decoration: none; white-space: nowrap; transition: color .15s; }
+.nav-mark { color: var(--color-accent); font-size: 1.1rem; }
+.nav-logo:hover { color: var(--color-accent); }
+
+.tabs { display: flex; gap: .15rem; max-width: var(--max-width); margin: 0 auto; padding: 0 1.5rem; border-bottom: 1px solid var(--color-border); overflow-x: auto; }
+.tab { display: inline-flex; align-items: center; gap: .4rem; padding: 10px 14px; font-size: .88rem; color: var(--color-text-muted); text-decoration: none; white-space: nowrap; position: relative; transition: color .15s; }
+.tab:hover { color: var(--color-text); text-decoration: none; }
+.tab.active { color: var(--color-text); font-weight: 600; }
+.tab.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--color-accent); }
+
+.content { max-width: var(--max-width); width: 100%; margin: 0 auto; padding: 2.25rem 1.5rem 3rem; flex: 1; }
+.page-header { margin-bottom: 1.75rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
+.page-header::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
+.page-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 700; letter-spacing: -.01em; line-height: 1.3; }
+
+/* Cards: the container every generic (reflection-driven) view and every
+ * bare list/definition list a custom page renders lands inside. */
+.card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); margin-bottom: 1.5rem; overflow: hidden; }
+.card-header { display: flex; align-items: center; gap: .5rem; font-family: var(--font-mono); font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); background: var(--color-code-bg); padding: .55rem 1.1rem; border-bottom: 1px solid var(--color-border); }
+.card-row { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; font-family: var(--font-mono); font-size: .9rem; }
+.card-row + .card-row { border-top: 1px solid var(--color-border); }
+.card-row .icon { color: var(--color-text-muted); }
+.card-row.is-dir .icon { color: var(--color-accent); }
+.card-row a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); flex: 1; min-width: 0; word-break: break-all; }
+.card-row a:hover { color: var(--color-accent); }
+.card-row a.row-link { display: flex; align-items: center; gap: .65rem; text-decoration: none; }
+.card-row:has(.row-link):hover { background: var(--color-code-bg); }
+
+/* The generic reflection-driven views (`crate::render`): a definition list
+ * for one entity, a table for a list of entities, a plain list for bare
+ * name lists (toolchains, inbox). */
+.entity-view { font-family: var(--font-mono); font-size: .9rem; }
+.entity-view dt { padding: .7rem 1.1rem .1rem; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); }
+.entity-view dd { padding: 0 1.1rem .7rem; word-break: break-all; }
+.entity-view dt + dt { padding-top: .1rem; }
+
+.entity-list { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: .85rem; }
+.entity-list th, .entity-list td { padding: .6rem 1.1rem; text-align: left; word-break: break-all; }
+.entity-list thead { background: var(--color-code-bg); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); }
+.entity-list tbody tr + tr { border-top: 1px solid var(--color-border); }
+.entity-list td a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+.entity-list td a:hover { color: var(--color-accent); }
+
+.string-list { list-style: none; font-family: var(--font-mono); font-size: .9rem; }
+.string-list li { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; }
+.string-list li + li { border-top: 1px solid var(--color-border); }
+.string-list a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+.string-list a:hover { color: var(--color-accent); }
+
+/* Bare `dl`/`ul`/`form` markup a custom page (toolchains, comments) emits
+ * directly, styled the same as the generic views above so a page needs no
+ * per-page CSS hook to look consistent. */
+dl { font-family: var(--font-mono); font-size: .9rem; margin-bottom: 1.5rem; }
+dl dt { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); margin-top: .6rem; }
+dl dd { word-break: break-all; }
+ul:not(.string-list) { list-style: none; font-family: var(--font-mono); font-size: .9rem; margin-bottom: 1.5rem; }
+ul:not(.string-list) li { padding: .35rem 0; }
+
+.badge { font-family: var(--font-mono); font-size: .68rem; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: 0 .5rem; margin-left: .5rem; }
+
+/* Forms: the one write flow this crate demonstrates (`account`, `comments`). */
+form { display: flex; flex-direction: column; gap: .5rem; max-width: 40rem; margin-bottom: 1.5rem; }
+form label { display: flex; flex-direction: column; gap: .25rem; font-family: var(--font-sans); font-size: .82rem; font-weight: 600; color: var(--color-text-muted); }
+form input, form textarea { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem; }
+form input:focus, form textarea:focus { border-color: var(--color-accent); }
+form textarea { resize: vertical; }
+form button { align-self: flex-start; font-size: .88rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: .45rem 1rem; cursor: pointer; transition: background .15s; }
+form button:hover { background: var(--color-link-hover); }
+
+/* Breadcrumbs and the file browser (`crate::pages::files`). */
+.crumbs { font-family: var(--font-mono); font-size: .92rem; margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; word-break: break-all; }
+.crumbs a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+.crumbs .sep { color: var(--color-text-muted); opacity: .55; }
+.crumbs .here { color: var(--color-text-muted); }
+
+.blob { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: auto; margin-bottom: 1.5rem; }
+.blob pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; margin: 0; padding: 1rem 1.25rem; white-space: pre; overflow-x: auto; }
+.binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); }
+
+/* Rendered Markdown/AsciiDoc documents (`crate::markdown`, `crate::asciidoc`). */
+.doc-body { padding: 40px 48px 52px; max-width: 44rem; overflow-wrap: break-word; }
+.doc-body > :first-child { margin-top: 0; }
+.doc-body h1, .doc-body h2, .doc-body h3, .doc-body h4 { font-family: var(--font-serif); font-weight: 700; letter-spacing: -.01em; line-height: 1.25; margin: 1.8rem 0 .9rem; }
+.doc-body h1 { font-size: 2.4rem; letter-spacing: -.02em; margin-top: 0; }
+.doc-body .doc-subtitle { font-family: var(--font-serif); font-style: italic; font-size: 1.18rem; color: var(--color-text-muted); margin: -.4rem 0 1.2rem; }
+.doc-body h2 { font-size: 1.4rem; font-weight: 600; position: relative; padding-bottom: .55rem; }
+.doc-body h2::after { content: ""; position: absolute; left: 0; bottom: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
+.doc-body h3 { font-size: 1.15rem; font-weight: 600; }
+.doc-body p, .doc-body ul, .doc-body ol { margin: 0 0 1rem; }
+.doc-body ul, .doc-body ol { padding-left: 1.4rem; list-style: revert; font-family: inherit; }
+.doc-body li { margin: .25rem 0; padding: 0; }
+.doc-body a { font-weight: 500; }
+.doc-body code, .doc-body .literal { font-family: var(--font-mono); font-size: .86em; background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; }
+.doc-body pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 1rem 1.2rem; overflow-x: auto; margin: 0 0 1rem; }
+.doc-body pre code { background: none; padding: 0; font-size: inherit; }
+.doc-body blockquote { border-left: 3px solid var(--color-accent); padding: .2rem 0 .2rem 1.1rem; margin: 0 0 1rem; color: var(--color-text-muted); }
+.doc-body table { border-collapse: collapse; margin: 0 0 1rem; font-size: .92rem; }
+.doc-body th, .doc-body td { border: 1px solid var(--color-border); padding: .4rem .7rem; text-align: left; }
+.doc-body th { background: var(--color-code-bg); font-weight: 600; }
+.doc-body img { max-width: 100%; height: auto; }
+.doc-body hr { border: none; border-top: 1px solid var(--color-border); margin: 1.8rem 0; }
+
+@media (max-width: 640px) {
+ html { font-size: 16px; }
+ .content { padding: 1.5rem 1.25rem 2.5rem; }
+ .nav-inner { gap: .75rem; padding: 0 1.25rem; }
+ .tabs { padding: 0 1.25rem; }
+ .page-title { font-size: 1.3rem; }
+ .doc-body { padding: 1.75rem 1.5rem 2rem; }
+ .doc-body h1 { font-size: 1.9rem; }
+ .doc-body h2 { font-size: 1.25rem; }
+}