Qirava DMS docs
Studio UI architecture: SSR, components, shell
Studio's UI is server-side rendered with Quill — the same view layer, theming, and design components used by the public website and docs. There is no client-side framework, no bundler, and no static asset server: every page is a complete HTML document assembled in Rust from view nodes and shipped whole, with critical CSS inlined into the <head>. The StudioShell builds that document — a sticky header (brand, theme toggle, user@role, logout), a left sidebar nav filtered to your role, and a main content column — and page handlers fill the content with qquill-design components (Stat, Alert) plus small zero-dependency table/card/form helpers. A no-flicker theme island sets dark/light before first paint. This page explains the SSR pipeline, the shell anatomy, the component and CSS strategy, and the theme island.
Server-side rendering with Quill#
Studio is pure SSR. A page handler builds a tree of view Nodes with qquill-view (el for elements, text for escaped text, raw for trusted markup, fragment to group), renders it to bytes with render_bytes, and returns it as an HTML response. All Studio routes are declared OutputKind::Html. There is no hydration of page content — only one small theme island runs on the client (see below).
- qquill-view
- The node builder. el("div").class(..).attr(..).child(..); text(..) escapes; raw(..) injects trusted strings verbatim; fragment([..]) groups siblings (used to prepend <!doctype html>).
- render_bytes / render
- Serialize a Node tree to HTML bytes. The renderer escapes all text and attribute values — every dynamic value (usernames, flash messages) goes through text(..) and is escaped.
- OutputKind::Html
- Every Studio route is served as Html; the handlers frame headers (Set-Cookie / Location / X-Status) via the __headers seam.
The StudioShell#
StudioShell builds the full <html> chrome for every authenticated page via document(user, current, content, extra_css). It owns three regions: a sticky header, a left sidebar nav, and the main content column. The unauthenticated login page uses auth_document(title, content, extra_css) instead, which has the brand and centered card but NO session chrome.
┌──────────────────────────────────────────────────────────┐
│ .qs-header (sticky) │
│ [brand lockup] [Theme] [ada@custodian][Log out]│
├───────────────┬──────────────────────────────────────────┤
│ .qs-sidebar │ <main id="main"> .qs-content │
│ Dashboard │ <h1>…</h1> │
│ Users & RBAC │ (optional flash/error alert) │
│ Databases │ stat grid / cards / tables / forms │
│ Workers&Apps │ │
│ Governance │ │
│ (RBAC-filtered)│ │
└───────────────┴──────────────────────────────────────────┘
grid-template-columns: 220px 1fr- Header brand: the full qbrand wordmark lockup, recolored to currentColor so it adapts to the theme with no second asset (ink on light, light on dark) — the same single brand source as the website.
- Header right: the theme toggle island, the user@role badge, and a Logout <form> POST carrying the session-bound CSRF token.
- Sidebar: the five Screen entries in nav order, FILTERED by screen.can_access(role); the current screen is marked aria-current="page".
- Main: <main id="main"> with the page's content node.
The StudioUser passed to the shell carries user, role, and the raw session_token. The token is used only to derive the per-request CSRF token for embedded forms (user.csrf()) — it is NEVER rendered into the page.
The <head>: theme boot, then CSS#
The shared <head> is assembled in a specific order that matters for correctness. It carries charset and viewport meta, a robots=noindex meta (the admin app must not be indexed), the title, the favicon, the no-flicker theme boot script, and then a single inlined <style> block. The CSS order is: theme variable blocks (default_theme_set().to_css()), then base_css(), then — for the authenticated shell only — chrome_css(), then the page's component CSS (extra_css).
<head>
<meta charset=utf-8> <meta name=viewport …> <meta name=robots content=noindex>
<title>… — Qirava Studio</title>
<link rel=icon type=image/svg+xml href="data:image/svg+xml,…"> (inlined qbrand icon)
<script>…theme boot…</script> ← runs BEFORE the stylesheet (no flicker)
<style>theme-vars + base_css [+ chrome_css] + page CSS</style>
</head>- base_css()
- The shared, chrome-free layout: body defaults plus the login card and the form/input/button primitives reused on BOTH the authenticated shell and the login document. Carries NONE of the session-chrome rules.
- chrome_css()
- The authenticated-only session chrome: sticky header, sidebar nav, content grid, plus the dashboard stat grid and the table/card primitives. Inlined ONLY by document(); the login document omits it entirely.
- Favicon
- The qbrand ICON inlined as an image/svg+xml data URI (percent-encoded for an HTML attribute). Studio has no static file server, so the mark is embedded directly — same single source as the website's /favicon.svg.
Design components and the per-page CSS pattern#
Studio renders structured UI with qquill-design components and a handful of small zero-dependency helpers. The components follow a render-then-attach-CSS pattern: you build the component, call .render() to get a rendered handle, append its companion CSS (handle.style().to_css()) to the page's CSS string, and append its node (handle.into_node()) to the content. The shell then inlines that accumulated page CSS after its own.
// Dashboard stat cards: render each Stat, collect its CSS, then its node.
use qquill_design::Stat;
let stat = Stat::new("stat-users", "Users", counts.users.to_string()).render();
css.push_str(&stat.style().to_css()); // companion CSS → page CSS
grid = grid.child(stat.into_node()); // node → content
// Finally: page(&shell, &user, screen, content, &css) inlines `css` last.- Stat — the dashboard stat cards (Databases, Tables, Users, Workers, Active sessions).
- Alert (Severity: Info, Success, Danger) — flash/error banners, the 403 message, and the login form's messages.
- table(headers, rows) — a zero-dep <table class="qs-table"> helper; every cell is text(..) so row data (usernames, grants) is escaped.
- card(title, body) — a <section class="qs-card"> with an <h2> title.
- field / select_field / action_form — the form primitives; action_form always embeds the session CSRF hidden input and POSTs to a studio.action.* route.
The flash banner pattern threads through every page: page_head reads ?error= (rendered as a Danger alert) or ?flash= (rendered as a Success alert) from the query string and prepends it under the <h1>. This is what surfaces the Post/Redirect/Get outcome of a mutation.
The no-flicker theme island#
Dark/light theming is var-based: every color in base_css and chrome_css is a CSS custom property (e.g. var(--qq-color-bg), var(--qq-color-fg), var(--qq-color-brand)) defined by the qquill-theme variable blocks. The theme is chosen before first paint by a boot script emitted in the <head> ahead of the stylesheet, so there is no flash of the wrong theme. The boot is configured with BootConfig::new(ThemeMode::Light) as the default.
The header's Theme button is the one hydratable island on the page. SSR renders it as an accessible static <button>; at runtime the island (trigger "load") flips data-q-theme on <html>. The button carries the island attributes that wire it up.
<button type="button" class="qs-themetoggle"
data-q-island="studio-theme" data-q-kind="theme"
data-q-trigger="load" data-q-theme-toggle="true"
aria-label="Toggle dark / light theme">Theme</button>- Pre-paint boot
- qquill_theme::boot_script_tag(&boot) runs before the <style>, setting data-q-theme on <html> so the correct theme is applied on the first frame.
- Theme island
- The only client-side hydration in Studio. SSR is accessible without it; the island just enables the toggle to flip the theme at runtime.
- Reference shell
- The whole approach mirrors qquill-docs::DocShell (the dogfood reference): theme boot before critical CSS, all colors theme-var based.