//! The webview renderer for [`quasi_router`]. //! //! //! //! # Why this is not in `makeover-webview` //! //! A [`Screen`](quasi_router::Screen) renderer has to import `quasi-router`, //! and the makeover/quasi boundary is audience: a makeover crate is something //! another developer might use on its own, and quasi is what you use once you //! are committed to the whole stack. A `makeover-webview` that depended on //! quasi would stop passing its own test. //! //! So the split runs along that line and not along "who emits markup". //! `makeover-webview` owns the pieces whose description is settled and shared — //! fields, cell containers, the narrowing rules, the escaping — and this crate //! owns screen assembly, regions, and the transport. Every piece it can borrow //! from over there, it does: there is no second field emitter here. //! //! # What phase B is, once the pieces exist //! //! Assembly, mostly. The vocabulary is closed at two arrangements, seven region //! kinds and twelve node variants, and none of them is a widget: the admission //! test in [`quasi_router::screen`] is that a node composes something //! `makeover-layout` already names. So this crate has no opinions to hold, and //! the file to read for the interesting ones is [`node`], where htmx enters in //! a single function. //! //! # The transport is replaceable, and that is measurable here //! //! Nobody hand-writes `hx-post`. Decision 13 parks the fixi question on the //! grounds that a transport nothing authors by hand is a transport that can be //! swapped, and the check on that claim is that //! [`node::action_attrs`](node) is the only place in this crate naming htmx at //! all. A test asserts it. //! //! # Which htmx //! //! htmx 4, and never 2.x. What that decides here, all of it inside the //! seam above plus the shell: //! //! - The swap is `outerMorph`, a swap style 4 ships. Under 2.x morphing came //! from idiomorph, so the shell carried the extension and the emitter asked //! for a morph only when it was loaded. Both are gone. //! - The lock is `hx-disable`, which is 4's name for 2.x's `hx-disabled-elt`. //! 4 spells 2.x's `hx-disable` -- skip this subtree -- as `hx-ignore`, and //! nothing here emits that. //! - Inheritance is explicit: the headers a document sends go on the body as //! `hx-headers:inherited`. An emitter knows every attribute it writes, so //! this costs one attribute name and no design. //! - No `htmx-config` meta tag. 4 swaps every status but 204 and 304, which is //! what decision 9's classified errors needed and had to be configured for //! under 2.x. //! - The scripts this crate ships listen on `htmx:after:settle`, 4's spelling //! of the settle event. //! //! A host serving 2.x behind [`Shell::htmx_src`] gets a page whose controls //! fetch nothing and whose swaps destroy state, so the bundle and this crate //! move together. //! # The other language this crate emits //! //! _hyperscript, for what happens without a request. It has the same seam htmx //! does: every program comes out of [`hyperscript`], and a test asserts //! nothing else writes one. [`Shell::hyperscript_src`] is the runtime that //! reads them. pub mod chrome; mod clock; pub mod frame; mod hyperscript; mod node; mod shell; pub mod stage; pub mod vocabulary; #[cfg(test)] mod tests; pub use crate::shell::{Parts, Shell}; /// The selection script this renderer ships, as source. /// /// A host writes it out where [`Shell::selection_src`] says it is served from. /// Shipped as a constant rather than as a file each app copies, so the /// behaviour and the markup that feeds it move together: the script reads two /// hooks this crate emits (`.row-select` on a tick, `data-over` on a control /// that commits one), and a stale copy in an app's static directory is a /// silently dead bulk bar. /// /// It exists because two things a bulk bar does cannot be said in a /// description: how many rows are ticked, and that a control over none of them /// should not be pressable. The ticks are the browser's until something submits /// them. See [`Shell::selection_src`] for the whole reasoning and for what a /// host that does not serve it gets instead. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-selection.js", quasi_webview::SELECTION_JS)?; /// # Ok(()) /// # } /// ``` pub const SELECTION_JS: &str = include_str!("selection.js"); /// This crate's clock script, for a host to serve beside its other assets. /// /// The three time-derived [`Node`] members carry an instant and which way the /// readout runs against it; the words and the rate are the renderer's, and in /// this renderer the words after the first set are written by a browser. This /// is that script. /// /// Two rates, one per granularity, and both are in the file rather than here: /// a second for [`Clock::Since`](quasi_router::Clock::Since) and /// [`Clock::Until`](quasi_router::Clock::Until), which read `h:mm:ss`, and /// thirty seconds for [`Clock::Age`](quasi_router::Clock::Age), which reads /// "3h ago" and changes on the minute at its finest. They match the numbers /// `quasi-tui` and `quasi-immediate` picked, so a screen described once and /// drawn three times does not go stale at three different rates. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-clock.js", quasi_webview::CLOCK_JS)?; /// # Ok(()) /// # } /// ``` pub const CLOCK_JS: &str = include_str!("clock.js"); /// This crate's download script, for a host to serve beside its other assets. /// /// A route can answer [`Outcome::File`](quasi_router::Outcome::File) and /// `quasi-http` writes `Content-Disposition`, which is everything a plain link /// needs and nothing an htmx control can use: htmx makes an XHR, an XHR is not /// a navigation, and the browser saves nothing. This script cancels the swap /// and hands the body over as a download itself. /// /// Shipped as a constant for [`SELECTION_JS`]'s reason: it reads a header this /// stack writes, so the behaviour and the thing it reads move together, and a /// stale copy in an app's static directory is an export control that pastes a /// JSON file into the page. /// /// Its one limit is stated in the file and is real: the body arrives as a /// string, so a payload that is not valid UTF-8 is mangled. Every export in the /// tree is JSON, CSV or ICS. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-download.js", quasi_webview::DOWNLOAD_JS)?; /// # Ok(()) /// # } /// ``` pub const DOWNLOAD_JS: &str = include_str!("download.js"); /// This crate's fill script, for a host to serve beside its other assets. /// /// [`Act::fills`](quasi_router::Act::fills) names a field on the same screen /// that receives the act's value, and this is the browser's half of "the /// renderer decides where in it the value lands": at the selection, which is /// where every editor a reader has used puts what they insert. /// /// Shipped as a constant for [`SELECTION_JS`]'s reason, and a script rather /// than an emitted [`hyperscript`] program for a sharper one. The value is app /// text — a media file's name reaches the page inside it — and that file's one /// standing rule is that no program is built out of text a user typed. As two /// escaped attributes it is data, which is what it is. /// /// `None` drops it, and the loss is visible on the first press: the control /// runs its own action and writes nothing. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-fill.js", quasi_webview::FILL_JS)?; /// # Ok(()) /// # } /// ``` pub const FILL_JS: &str = include_str!("fill.js"); /// This crate's clipboard script, for a host to serve beside its other assets. /// /// [`Act::copies`](quasi_router::Act::copies) says a press puts a value on the /// clipboard, and this is the browser's half. It replaces seven /// `window.` globals across 14 MNW sites, six of which read the text /// back off the DOM at press time. /// /// Shipped as a constant for [`SELECTION_JS`]'s reason, and a script rather /// than an emitted [`hyperscript`] program for [`FILL_JS`]'s: the value is app /// text -- a licence key, an embed snippet -- and that file's one standing rule /// is that no program is built out of text a user typed. /// /// It does not say "Copied!". That is a temporary label, which is /// `makeover-timing`'s question rather than this one; see /// [`Act::copies`](quasi_router::Act::copies) for why the two were separated. /// /// `None` drops it, and the loss is a copy button that does nothing when /// pressed. That is worse than the other scripts degrade, and it is why the /// member emits no fallback markup: there is no copying without a script, on /// any browser. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-copy.js", quasi_webview::COPY_JS)?; /// # Ok(()) /// # } /// ``` pub const COPY_JS: &str = include_str!("copy.js"); /// This crate's reveal script, for a host to serve beside its other assets. /// /// A [`Slot::revealed_by`] names the control that brings a region out and the /// value that does it, and this is the browser's half: it reads what the /// control holds and shows or hides the region, with no request at all. The /// six MNW toggles it replaces are hand-written functions that each read /// `.checked` or `.value` and flip a class. /// /// Shipped as a constant for [`SELECTION_JS`]'s reason, and a script rather /// than an emitted [`hyperscript`] program for [`FILL_JS`]'s: the values a /// region watches for are app text, and that file's one standing rule is that /// no program is built out of text a user typed. As escaped attributes they are /// data, which is what they are. /// /// `None` drops it, and the loss is a page where every conditional region is /// out. More content rather than less, which is the direction the rest of this /// renderer degrades in. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-reveal.js", quasi_webview::REVEAL_JS)?; /// # Ok(()) /// # } /// ``` /// /// [`Slot::revealed_by`]: quasi_router::Slot::revealed_by pub const REVEAL_JS: &str = include_str!("reveal.js"); /// This crate's repeat script, for a host to serve beside its other assets. /// /// A [`Field::repeats`] says a question is answered zero or more times with /// the reader adding and removing the slots, and this is the browser's half of /// the adding and removing: the emitter writes a `template` holding one blank /// slot, and this clones it, renumbers the names, and keeps the two controls /// in step with the floor and the ceiling. /// /// Shipped as a constant for [`SELECTION_JS`]'s reason, and a script rather /// than an emitted [`hyperscript`] program for [`FILL_JS`]'s: what it reads is /// app text — the question's name and the label its slots are numbered from — /// and that file's one standing rule is that no program is built out of text a /// user typed. /// /// `None` drops it, and the loss is a form showing every slot the description /// offered with two controls that do nothing. More content rather than less, /// which is the direction the rest of this renderer degrades in. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-repeat.js", quasi_webview::REPEAT_JS)?; /// # Ok(()) /// # } /// ``` /// /// [`Field::repeats`]: quasi_router::Field::repeats pub const REPEAT_JS: &str = include_str!("repeat.js"); /// This crate's awaiting script, for a host to serve beside its other assets. /// /// Wiki `loading-and-progress-standard`. The node emitter has written `data- /// awaiting` since the vocabulary gained `Awaiting`, and `makeover-webview` /// 0.60.0 draws both of its values. Neither knows *when* a wait is running, /// and this is the half that does: it puts `aria-busy` on the element that /// made the request for as long as the request is in flight, which is what the /// design system keys the mark off. /// /// `aria-busy` rather than a class of ours, so the state is announced as well /// as drawn, and so a host driving the same markup without htmx can say the /// same thing the same way. /// /// For a control whose wait carries a measured size it also sets /// `--awaiting-share` from bytes the browser reports as delivered, and from /// nothing else. Rule 1: what is done over what there is, never a remaining /// time and never a rate extrapolated forward. A control with no readable /// progress keeps a share of zero and draws an empty trough, which is the /// honest picture rather than a bar walking forward on a stalled transfer. /// /// **Not independent of htmx**, unlike the reveal, repeat and fill scripts: /// what it listens to is the request lifecycle. [`Shell::without_htmx`] drops /// it with the rest. /// /// `None` drops it, and the loss is a page where nothing marks a wait and every /// control still locks itself through `hx-disable`. Less decoration, never less /// behaviour, which is the direction the rest of this renderer degrades in. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-awaiting.js", quasi_webview::AWAITING_JS)?; /// # Ok(()) /// # } /// ``` /// /// [`Shell::without_htmx`]: crate::Shell::without_htmx pub const AWAITING_JS: &str = include_str!("awaiting.js"); /// This crate's instant script, for a host to serve beside its other assets. /// /// [`makeover_layout::Field::as_instant`] is a description saying a /// wall-clock value should be submitted as the moment it names, and declining /// to say how, because how is the renderer's: the browser is the only party /// that knows what "your computer's time zone" means. This is that half. /// /// `makeover-webview` marks the input `data-instant`; this rewrites the value /// on the request htmx is about to make. Nothing the reader sees changes -- the /// box keeps showing the time they typed, and the wire carries the moment. /// /// **Not independent of htmx**: what it listens to is the request lifecycle. /// [`Shell::without_htmx`] drops it with the rest. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-instant.js", quasi_webview::INSTANT_JS)?; /// # Ok(()) /// # } /// ``` /// /// [`Shell::without_htmx`]: crate::Shell::without_htmx pub const INSTANT_JS: &str = include_str!("instant.js"); /// The focus script, to serve beside the renderer's other assets. /// /// htmx saves and restores focus only around the *main* swap target. A focused /// control inside an out-of-band region is destroyed outside that window, so /// focus falls to `body`, the caret goes with it, and the next Tab starts from /// the top of the document. This renderer emits out-of-band swaps -- see /// [`Serves::with_oob`] -- so it owns the gap. /// /// It records what was focused when a request went out and puts it back after /// everything settles, **only where focus was actually lost**. htmx restores /// the main target itself, correctly, and a second party moving focus after it /// would be two scripts fighting over one frame. /// /// An element is found again by `id`, or by `name` where it has no `id`. Both /// are addresses the description chose. Anything with neither is left alone, /// and so is a control that is genuinely gone: guessing at a neighbour by /// position lands focus on the wrong thing, which is worse than losing it. /// /// Not independent of htmx: it reads the request lifecycle, so /// [`Shell::without_htmx`] drops it. Nothing is lost there, since without htmx /// every response is a navigation and a navigation has no focus to preserve. /// /// `None` on [`Shell::focus_src`] drops it, and the loss falls entirely on /// readers navigating by keyboard. It is silent to everyone else, which is both /// why it is on by default and why it went unnoticed until it was looked for. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-focus.js", quasi_webview::FOCUS_JS)?; /// # Ok(()) /// # } /// ``` /// /// [`Serves::with_oob`]: quasi_http::Serves::with_oob /// [`Shell::without_htmx`]: crate::Shell::without_htmx /// [`Shell::focus_src`]: crate::Shell::focus_src pub const FOCUS_JS: &str = include_str!("focus.js"); /// This crate's menu script, for a host to serve beside its other assets. /// /// [`makeover_layout::Fallback::Menu`] says the members a /// [`Shed`](makeover_layout::Fallback::Shed) would drop move into one overflow /// control instead. Knowing that a run is out of room is a measurement, and CSS /// cannot make it: `@container` compares against a ``, so every honest /// spelling needs an authored breakpoint, which is the thing the room ruling /// forbids. `makeover-webview` says so in `run_rules` and leaves the mechanism /// here. /// /// It reads `.run-menu` and the two priority classes that crate already emits, /// and it builds its control out of that crate's `.run-overflow` classes, so the /// surface is the design system's and only the measuring is this file's. /// /// A tab strip is one member of its run and its tabs are what fold, which is /// the shape all five described MNW strips have. /// /// **Not independent of anything, and it degrades by default.** A page that does /// not run it keeps `.run-menu`'s own `flex-wrap: wrap`: every member stays /// reachable, which is the property, with only the folding missing. The /// `data-menu` mark this script writes is what turns wrapping off, so the /// fallback is what happens when nothing runs rather than what happens when /// something fails. /// /// Independent of htmx, so [`Shell::without_htmx`] keeps it: it listens for a /// settle when there is one and measures on resize and on load regardless. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-menu.js", quasi_webview::MENU_JS)?; /// # Ok(()) /// # } /// ``` /// /// [`Shell::without_htmx`]: crate::Shell::without_htmx pub const MENU_JS: &str = include_str!("menu.js"); /// The browser's half of an outline: folding a branch. /// /// [`Row::depth`] and [`Row::open`] describe a hierarchy as a flat list of /// rows each saying how deep it sits, and the emitter already read that once — /// every row is in the document, and the ones under a shut branch arrived /// `hidden`. What is left is the gesture, and a gesture that changes only what /// the reader is looking at belongs in the browser. See [`Row::open`] for why /// folding is not a write and asks the app nothing. /// /// It reads two hooks the emitter writes and nothing else: `[data-disclose]` on /// a branch's chevron and `data-depth` on the rows. /// /// Independent of htmx, so [`Shell::without_htmx`] keeps it: it delegates one /// listener on the document, which covers a list arriving in a swap. /// /// `None` drops it, and the loss is an outline in the shape the description /// gave it — every row still reachable, with the shut branches staying shut. /// More content than a reader asked for rather than less, which is the /// direction the rest of this renderer degrades in. /// /// ```no_run /// # fn main() -> std::io::Result<()> { /// std::fs::write("static/quasi-outline.js", quasi_webview::OUTLINE_JS)?; /// # Ok(()) /// # } /// ``` /// /// [`Row::depth`]: quasi_router::Row::depth /// [`Row::open`]: quasi_router::Row::open /// [`Shell::without_htmx`]: crate::Shell::without_htmx pub const OUTLINE_JS: &str = include_str!("outline.js"); /// How often this renderer looks again at a [`Slot::live`] region. /// /// The description says the contents move and says nothing about how often to /// look; the rate is picked here, once, so that every live region in every /// screen this crate draws moves at one speed. Two MNW templates hand-write /// `hx-trigger="every 10s"` today and nothing made them comparable, which is /// what one home for the number buys. /// /// The corpus' own number rather than a chosen one, on /// [`Consult::SETTLES`](quasi_router::Consult::SETTLES)' precedent. /// /// [`Slot::live`]: quasi_router::Slot::live pub const CADENCE: std::time::Duration = std::time::Duration::from_secs(10); /// This renderer's class, and what it therefore owes /// [`Destination::Local`](quasi_router::Destination::Local). /// /// This is the only hybrid renderer in the stack: it emits markup that some /// other runtime performs later, so it has to decide at emit time whether a /// keystroke becomes a request. The two client renderers get the answer for /// free and this one does not. /// /// What it emits for a local *action* is still the mark, `data-local`, and not /// the behaviour, because what happens locally is named by the member carrying /// the action rather than by the address, which has none. A local action whose /// act names no such member draws a control that performs nothing, which is a /// gap rather than a regression, and the attribute is what makes it findable. /// /// Two members name one today, and both are performed. A field that owns its /// suggestion list is the [`hyperscript`] program `combobox` writes. An act /// that deposits a value into a field is [`FILL_JS`], a script rather than a /// program, because its value is app text and the one rule the emitted /// programs keep is that none of them is built out of any. /// /// The renderer also performs the local interaction it emits chrome for: a /// region showing one child at a time carries the programs that move between /// its frames. pub const CLASS: quasi_router::Renderer = quasi_router::Renderer::Hybrid; pub use makeover_webview::Emit; use std::collections::HashMap; use std::fmt::Write as _; use makeover_layout::{Arrangement, Measure}; use quasi_http::Serves; use quasi_router::{Frame, Node, Screen}; /// A renderer that answers HTML. /// /// Holds the things a webview needs and a description never carries: where the /// host's assets live, what to prefix class names with, and what goes inside a /// bespoke region. All values rather than constants because they are the parts /// that genuinely differ between an axum route and a Tauri custom-protocol /// handler, and none of them is anything the router can know. /// /// # Why a renderer is cheap /// /// Three fields, two of them usually shared configuration. A host with /// something per-request to say builds one per request — that is what /// [`fills`](Self::fills) is for, and it is an allocation rather than a /// rebuild. `quasi-axum` takes a factory for exactly this. #[derive(Debug, Clone, Default)] pub struct Webview { /// The document around a screen. pub shell: Shell, /// Class naming, shared with `makeover-webview`'s stylesheet half so the /// emitted markup and the emitted CSS agree on every name. pub emit: Emit, /// What to put inside a bespoke region, by [`Slot::id`](quasi_router::Slot). /// /// Markup, inserted verbatim and unescaped, exactly as /// [`Shell::head`] is. It is host code's string: the description never sees /// it, never carries it and cannot be made to produce one. That is what /// keeps decision 4 intact while giving a server-rendered page something to /// serve, and it is why `Node::Html` is still refused. /// /// Keyed by slot id and not by the bespoke name, because a page of N rows /// each carrying a fill shares one name and has N ids. The emitted div /// carries both. /// /// A slot with no entry here renders empty, which is what every client host /// relies on. An entry naming an id the screen does not have is ignored /// rather than appended anywhere. pub fills: HashMap, /// The frame this mount puts around the screen. /// /// Held beside the shell rather than arriving with an answer, which is /// what makes it the mount's: one description put up in a modal and in a /// window is two `Webview`s carrying two frames, and the screen inside /// them says nothing about either. /// /// [`Frame::new`] is the frame that draws nothing, and a host that says pub frame: Frame, } impl Webview { /// A renderer with the default shell and class naming. #[must_use] pub fn new() -> Self { Self::default() } /// A renderer serving its assets from under this prefix. #[must_use] pub fn under(prefix: &str) -> Self { Self { shell: Shell::under(prefix), ..Self::default() } } /// Use this shell, chaining. #[must_use] pub fn with_shell(mut self, shell: Shell) -> Self { self.shell = shell; self } /// Put this frame around every screen this renderer serves, chaining. /// /// See [`frame`](Self::frame). A mount with two ways of showing one screen /// builds two renderers, which is what it already does for the shell. #[must_use] pub fn with_frame(mut self, frame: Frame) -> Self { self.frame = frame; self } /// Use this class naming, chaining. #[must_use] pub fn with_emit(mut self, emit: Emit) -> Self { self.emit = emit; self } /// The shell's half of a document the host assembles itself, in this /// renderer's currency. /// /// [`Shell::parts`] is the same document with the default class naming and /// no bespoke fills, which is all a shell holds. This is the one to reach /// for once either has been said: the chrome's panel is drawn here the way /// every other node is, so a prefix the stylesheet expects or a fill the /// panel contains would otherwise be missing from that one path. #[must_use] pub fn parts(&self) -> Parts { let mut parts = self.shell.parts(); parts.body_chrome.clear(); // No screen here, so no place to mark: `parts` is for a host // assembling its own body, and which place is showing is a fact about a // screen. The nav is drawn; nothing in it is current. // The nav is not here: `parts` is for a host assembling its own body, // and a navigation goes before the content that host is about to write. // `nav` is public on the chrome for exactly that; see `Serves::screen`. crate::chrome::chrome_html( &self.shell.chrome, &self.emit, &node::Doc::caretless(&self.fills), &mut parts.body_chrome, ); parts } /// Fill the bespoke region with this slot id, chaining. Not escaped. /// /// Repeated calls for one id replace, rather than appending the way /// [`Shell::with_head`] does: a head accumulates unrelated tags, and a fill /// is one region's whole contents. #[must_use] pub fn with_fill(mut self, slot_id: impl Into, markup: impl Into) -> Self { self.fills.insert(slot_id.into(), markup.into()); self } /// The class naming the arrangement of a screen's regions. /// /// Three, because our apps have three. A fourth arrives when an app has /// one, and not before: naming arrangements an app has not asked for is how /// a description becomes a framework. fn arrangement_class(arrangement: Arrangement) -> &'static str { match arrangement { Arrangement::ListDetail { tabbed: false, .. } => "list-detail", Arrangement::ListDetail { tabbed: true, .. } => "list-detail-tabbed", Arrangement::SidebarContent { .. } => "sidebar-content", Arrangement::Single => "single", } } /// The class naming how wide a screen runs. /// /// Spelled out per measure rather than assembled from `measure-` and /// [`Measure::as_str`], which is what this did until the emitter's /// allocations were counted: that form built a `String` on every render of /// every screen to reach a name from a set of three. /// /// These are this renderer's own names, the same way /// [`arrangement_class`](Self::arrangement_class)'s are. makeover has no /// word for how wide a screen runs, because the answer is a page-level /// arrangement rather than anything it styles. fn measure_class(measure: Measure) -> &'static str { match measure { Measure::Wide => "measure-wide", Measure::Contained => "measure-contained", Measure::Reading => "measure-reading", // `Measure` is `#[non_exhaustive]`, so a member added upstream // lands here rather than failing the build. `tone_attr`'s reading: // the widest is the default and the one 53 of the 69 screens want, // so an unlearned measure runs full width rather than carrying a // class no stylesheet defines. _ => "measure-wide", } } /// The share, as the grid that honours it. /// /// An inline style rather than a class, because the share is a number the /// description carries and a class can only name a number some stylesheet /// already fixed. Nothing in `makeover-webview` styled these classes at /// all before this, so no shipped rule is being overridden: the check the /// finding asked for was whether adopting the description changes what the /// webview draws, and there was nothing there to change. /// /// `fr` rather than a percentage, so the gap between the regions comes out /// of the whole rather than out of the second one. /// /// Nothing is emitted for [`Arrangement::Single`], which divides no width: /// a one-region screen carrying these variables makes the host spend a rule /// cancelling the grid they describe. fn share_style(arrangement: Arrangement, out: &mut String) { let Some(share) = arrangement.share() else { return; }; let first = u16::from(share.as_percent()); let _ = write!( out, " style=\"--region-share:{first}fr;--region-rest:{}fr\"", 100 - first ); } } impl Webview { /// The whole document, with the screen's regions rendered from what it /// says. fn document(&self, screen: &Screen, regions: Regions<'_>) -> String { let mut out = String::with_capacity(1024); self.shell.open( &screen.title, Some(&screen.discovery), Some(&screen.document), &mut out, ); // The one answer that is a whole document, so the one answer that may // move the caret. Every other path here builds a `Doc` with no caret in // it: a fragment, an overlay and an out-of-band swap all land inside a // document the reader is already working in. let doc = node::Doc::opening(&self.fills, screen.opens_at.as_deref()); // The chrome and the frame outlive the screen, so neither carries the // screen's caret. A search box in the band is not the question the // screen said to open in. let outside = node::Doc::caretless(&self.fills); // Before the content, which is where a navigation belongs in the // document whatever a stylesheet later does with it. See // `chrome::nav_html`. // // Inside the band when the app declared one, and on its own when it did // not. The band is what makes the header one element, which a // narrow-viewport menu written as a checkbox styling its siblings // requires and could not have across two emission points. match &self.shell.chrome.band { Some(band) => crate::chrome::band_html( band, &self.shell.chrome.nav, screen.place.as_deref(), &self.emit, &outside, &mut out, ), None => crate::chrome::nav_html( &self.shell.chrome.nav, screen.place.as_deref(), &self.emit, &mut out, ), } // The same two branches `chrome::header_html` writes for a host // assembling its own body. Not called through it here, because this // path already has the fills and the buffer it is writing into. out.push_str("
'); // Notices before the regions, because a notice belongs to the screen // rather than to a place in it, and the first thing in the document is // the one place that is true of. Where they visually land is the // stylesheet's answer. // // Unless this mount says it has a place for one to rest, which is what // a status line is: a banner then lands in the frame instead of at the // top of the screen, and a toast floats regardless. `Frame::holds` is // the rule and it is the router's, so the three renderers cannot each // decide it. let (resting, floating): (Vec<&Node>, Vec<&Node>) = screen .notices .iter() .partition(|notice| self.frame.holds(notice)); if !floating.is_empty() { out.push_str("
"); for notice in &floating { node::node_html(notice, &self.emit, &doc, &mut out); } out.push_str("
"); } match regions { Regions::Described => { for slot in &screen.slots { node::slot_html(slot, node::Asks::ForItself, &self.emit, &doc, &mut out); } } // Already this renderer's own output, so it is pushed rather than // produced. Nothing is re-escaped and nothing is re-decided: the // bytes were written by the code above on a build machine. Regions::Derived(markup) => out.push_str(markup), } // `ae8e8836`. The container for a menu anchored to the screen's ticked // set. Inside `
` and after every region, because the selection is // the screen's rather than any one region's; emitted only for a screen // that holds a selection, which is what keeps it off every other // document. if screen.selection.is_some() { node::anchor_container_html(node::SELECTION_ANCHOR_ID, &self.emit, &mut out); } out.push_str("
"); // After the main content, and outside it for the chrome's reason one // lifetime along: a fragment replaces what is inside `
`, and the // frame this mount put around the screen outlives every answer that // lands in it. crate::frame::frame_html(&self.frame, &resting, &self.emit, &outside, &mut out); // After the main content: the chrome belongs to the app rather than to // the screen, so it sits outside what a screen's markup is. crate::chrome::chrome_html(&self.shell.chrome, &self.emit, &outside, &mut out); self.shell.close(&mut out); out } /// The whole document, around regions this renderer already wrote. /// /// The serving half of a residual (quasicoherent `793d99dd`). A screen's /// regions are the same bytes for every request whenever nothing in them /// reads one, so they are derived on a build machine and compiled into the /// consumer; what is left per request is the document around them, which is /// not fixed because a shell carries whoever is looking. /// /// **The markup has to have come from this renderer**, and there is no way /// to check that here. Handing it something else is handing a browser a /// string, which is `Node::Canvas`'s bargain without `Node::Canvas`'s /// sanitiser. The caller that has the property is the one that derived the /// residual with `stage::derive` against a `Webview` configured the same /// way, and the check that it still holds is an equality between the two, /// which is what MNW's `quasi::residuals` tests are. #[must_use] pub fn served(&self, screen: &Screen, regions: &str) -> String { self.document(screen, Regions::Derived(regions)) } } /// Where a document's regions come from. /// /// Two, and no third. Either the description is rendered here, or it was /// rendered by this same code somewhere earlier and the answer was kept. #[derive(Clone, Copy)] enum Regions<'a> { /// Render the screen's slots. Described, /// Markup this renderer produced already. Derived(&'a str), } impl Serves for Webview { fn screen(&self, screen: &Screen) -> String { self.document(screen, Regions::Described) } fn suggestions(&self, field: &str, options: &[quasi_router::Candidate]) -> String { node::suggestions_html(field, options, &self.emit) } fn suggestions_target(&self, field: &str) -> Option { hyperscript::handle(field).map(node::suggestions_id) } fn overlay(&self, screen: &Screen) -> String { // No shell and no `
`: this is the inside of the overlay // container, which the document already has. Notices ride along, // because an answer that raises one while opening an overlay is // raising it about the overlay. let mut out = String::with_capacity(512); // No caret, for `screen`'s reason: an overlay is swapped into a // document the reader is already in, and a swap that moves the caret // takes it out of whatever they were typing. let doc = node::Doc::caretless(&self.fills); for notice in &screen.notices { node::node_html(notice, &self.emit, &doc, &mut out); } for slot in &screen.slots { node::slot_html(slot, node::Asks::ForItself, &self.emit, &doc, &mut out); } out } fn overlay_target(&self) -> Option<&str> { Some(crate::chrome::OVERLAY_ID) } fn anchored(&self, screen: &Screen) -> String { // The inside of a popover container, which is `overlay`'s job one // container along: no shell and no `
`, and notices ride with it // because an answer raising one while opening a menu is raising it about // the menu. Deliberately the same body rather than a second spelling of // it -- what differs between the two outcomes is where the markup lands, // and that is `anchored_target`'s answer, not this one's. self.overlay(screen) } fn anchored_target(&self, anchor: &quasi_router::Anchor) -> Option { match anchor { // A region and a control both name a handle the document already // carries, and the container beside it is minted from that name. // `hyperscript::handle` is the same gate a suggestion list passes: // a name that cannot be an id drew no container, so there is nothing // to aim at and the answer lands where it was sent. quasi_router::Anchor::Region(id) | quasi_router::Anchor::Control(id) => { crate::hyperscript::handle(id).map(node::anchored_id) } // The screen's set, whose container is the fixed one. A screen that // holds no selection emitted none, and the retarget then names an // element the document does not have -- which htmx treats as a miss // and leaves the page alone. That is a description bug and gets a // description bug's treatment: nothing happens, loudly enough to // find in the console, rather than the menu landing somewhere it // does not belong. quasi_router::Anchor::Selection => Some(node::SELECTION_ANCHOR_ID.to_owned()), } } fn fragment(&self, node: &Node) -> String { // No shell, by definition: a fragment is the inside of one element and // htmx puts it there. The router already said which element through // `HX-Retarget`, so nothing here needs to know. let mut out = String::with_capacity(256); node::node_html( node, &self.emit, &node::Doc::caretless(&self.fills), &mut out, ); out } fn invalidated(&self, region: &str, node: &Node) -> String { // The one thing this does that `fragment` does not is carry its own // address, because nothing aimed at it. See `node::oob_html`. let mut out = String::with_capacity(256); node::oob_html( region, node, &self.emit, &node::Doc::caretless(&self.fills), &mut out, ); out } }