Skip to main content

max / goingson

Describe a search screen The full-text backend survived the port and nothing reached it: the repository, the FTS index, the colon grammar and the command are all in the tree, and search.js went with the rest of the frontend. Max ruled (a) on 6b3aa22b, so it comes back as a description. /search is a query box and a result list; /search/results is what the box asks for as it is typed, through Field::consults, with no submit. The query is a param, so a search has an address. The filters the parser understood are drawn back from the labels the command already collects, which is what makes a colon grammar learnable. Rows span all five type: values and each carries a Tag naming its kind and activates to that kind's own screen. Reached by a nav entry, which serves pointer, touch and keyboard alike; the app binds no keys and cannot until 858be2a6 gives the overlay a container. commands::search loses its vestigial async, left over from sqlx, and its body moves to run_search so a synchronous route handler can call it without a second copy of the filter parsing.
Author: Max Johnson <me@maxj.phd> · 2026-08-28 20:17 UTC
Signed with PGP, not checked
Commit: 8ee6a9fd2a25a5b3d6c538c81580bb9041acef59
Parent: 77911ad
6 files changed, +523 insertions, -8 deletions
@@ -102,10 +102,23 @@
102 102 /// Returns `DATABASE_ERROR` if the search query fails.
103 103 #[tauri::command]
104 104 #[instrument(skip_all)]
105 - pub async fn search(
105 + #[allow(
106 + clippy::needless_pass_by_value,
107 + reason = "Tauri command handler: State and payload args are supplied by value per the #[tauri::command] contract"
108 + )]
109 + pub fn search(
106 110 state: State<'_, Arc<AppState>>,
107 111 input: SearchInput,
108 112 ) -> Result<SearchResultsResponse, ApiError> {
113 + run_search(&state, input)
114 + }
115 +
116 + /// The search itself, against state a described route can hold.
117 + ///
118 + /// Split from the command so the described screen can call it: a quasi handler
119 + /// sees `&AppState` and nothing else, and there is no second copy of the filter
120 + /// parsing for it to drift against.
121 + pub fn run_search(state: &AppState, input: SearchInput) -> Result<SearchResultsResponse, ApiError> {
109 122 // Parse the query to extract filters
110 123 let parsed = search_parser::parse_search_query(&input.query);
111 124
@@ -55,12 +55,11 @@
55 55 //! | Settings > Sharing | its reads are remote, so there is no local state to draw a section from. quasicoherent `82273265` |
56 56 //! | ~~Settings > About~~ | back 2026-08-22: `AppState` holds the version and the platform now, which is what `theme_dirs` did for the theme path. See [`settings::about`] |
57 57 //! | Create Backup | [`data`] finding 2: a described write cannot be long-running |
58 - //! | The search box | quasicoherent `d52884b0` settled the caret as the renderer's |
58 + //! | ~~The search box~~ | back 2026-08-28 as a screen of its own, on `Field::consults`. See [`search`] |
59 59 //! | The blocking graph | `524261ac` ruled it bespoke; it draws an SVG with computed coordinates |
60 60 //!
61 - //! The last two are not coming back as descriptions. They are refused for
62 - //! reasons that are correct, and if they return it is as something a host
63 - //! draws.
61 + //! The blocking graph is not coming back as a description. It is refused for a
62 + //! reason that is correct, and if it returns it is as something a host draws.
64 63 //!
65 64 //! **Two checks lost their subject in the same commit**, and one of them is a
66 65 //! real hole. `check_touch_density` is dropped on the instruction the check
@@ -101,6 +100,7 @@
101 100 pub mod monthly_review;
102 101 pub mod problems;
103 102 pub mod projects;
103 + pub mod search;
104 104 pub mod settings;
105 105 pub mod shell;
106 106 pub mod task_list;
@@ -333,6 +333,7 @@
333 333 let router = weekly_review::routes(router);
334 334 let router = monthly_review::routes(router);
335 335 let router = problems::routes(router);
336 + let router = search::routes(router);
336 337 let router = day_planning::routes(router);
337 338 let router = contexts::routes(router);
338 339 let router = board::routes(router);
@@ -98,6 +98,8 @@
98 98 pub const OUTBOX: &str = "outbox";
99 99 /// Contacts, and the contact dashboard behind it.
100 100 pub const CONTACTS: &str = "contacts";
101 + /// Search, across all five kinds the index holds.
102 + pub const SEARCH: &str = "search";
101 103 /// Settings, and Import & Export, which its sidebar navigates to.
102 104 pub const SETTINGS: &str = "settings";
103 105
@@ -106,7 +108,7 @@
106 108 /// A group's own action is the view its tab opens, which is `TAB_DEFAULTS` in
107 109 /// `navigation.js`. A group with nowhere to go would be a tab that does
108 110 /// nothing, and pressing Work in the shipped app opens Tasks.
109 - fn nav() -> [Place; 4] {
111 + fn nav() -> [Place; 5] {
110 112 [
111 113 Place::new("work", "Work", Action::get("/tasks")).within([
112 114 Place::new(TASKS, "Tasks", Action::get("/tasks")),
@@ -127,6 +129,13 @@
127 129 Place::new(OUTBOX, "Out box", Action::get("/outbox")),
128 130 Place::new(CONTACTS, "Contacts", Action::get("/contacts")),
129 131 ]),
132 + // Search reaches every kind, so it belongs to no group and is a place
133 + // of its own. It is a nav entry rather than a binding because the app
134 + // binds no keys and cannot until quasicoherent `858be2a6` gives the
135 + // overlay a container -- and because a nav entry is the one entry point
136 + // that serves pointer, touch and keyboard alike, which is what goingson
137 + // `6b3aa22b` was actually complaining about.
138 + Place::new(SEARCH, "Search", Action::get("/search")),
130 139 // No sub-places, so it draws as a place of its own. The shipped header
131 140 // puts it on the right; where it lands here is the stylesheet's.
132 141 Place::new(SETTINGS, "Settings", Action::get("/settings")),
@@ -52,14 +52,17 @@
52 52 async fn the_nav_offers_the_tabs_the_shipped_header_offers() {
53 53 // Transcribed from `navigation.js`'s TAB_GROUPS rather than invented. A nav
54 54 // that offered a different set would be a second answer to what the app
55 - // contains.
55 + // contains. Search is the one place that is not in TAB_GROUPS: the shipped
56 + // header reached it from a box rather than a tab, and goingson `6b3aa22b`
57 + // chose a nav entry because it reaches every kind and the app can bind no
58 + // keys.
56 59 let chrome = super::chrome();
57 60 let tabs: Vec<&str> = chrome
58 61 .nav
59 62 .iter()
60 63 .map(|place| place.label.as_str())
61 64 .collect();
62 - assert_eq!(tabs, ["Work", "Time", "Messages", "Settings"]);
65 + assert_eq!(tabs, ["Work", "Time", "Messages", "Search", "Settings"]);
63 66
64 67 let work = &chrome.nav[0];
65 68 let inside: Vec<&str> = work.within.iter().map(|p| p.label.as_str()).collect();
@@ -1,0 +1,234 @@
1 + //! The search screen, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! The backend was never the missing half. The repository, the FTS index
6 + //! (`migrations/sqlite/011_full_text_search.sql`), the colon grammar
7 + //! (`goingson_core::search_parser`) and the command are all in the tree and all
8 + //! still work; `search.js` went with the rest of the frontend in the port and
9 + //! nothing replaced it, so the whole of it was unreachable from the app. Max
10 + //! ruled (a) on goingson `6b3aa22b`: it comes back as a description rather than
11 + //! being deleted, because what is stranded here is a built feature with a
12 + //! schema migration behind it.
13 + //!
14 + //! # Why a synchronous handler can serve it
15 + //!
16 + //! [`crate::commands::run_search`] is synchronous and always was under the
17 + //! surface: the repository trait's `search` is a plain function, and the `async`
18 + //! on the command was a leftover from the sqlx era that the rusqlite migration
19 + //! of 2026-08-07 did not clean up. It is gone in the same pass. So this needs
20 + //! none of the offload machinery a long-running write needs (goingson
21 + //! `dc2f2b46`); it calls the command's own body and answers with the result.
22 + //!
23 + //! # The shape
24 + //!
25 + //! - `GET /search` — the document: the query box, and whatever the current
26 + //! query finds.
27 + //! - `GET /search/results` — the filters and the result list alone, which is
28 + //! what the box asks for as it is typed.
29 + //!
30 + //! The box is a [`Field`] with [`Field::consults`], which landed 2026-08-17 for
31 + //! exactly this shape: a field that asks a route about its value once the value
32 + //! has stood still, with the answer landing where [`Action::replacing`] points.
33 + //! Search-as-you-type is its first consumer here. The query is also a query
34 + //! param on `/search`, so a search has an address and is not a state the field
35 + //! holds privately.
36 + //!
37 + //! # The parsed filters are shown back
38 + //!
39 + //! `commands::search` already collects a label per filter it understood, for
40 + //! the frontend that is gone. Drawing them is what makes a colon grammar
41 + //! learnable rather than secret: typing `is:overdue` and seeing `is:overdue`
42 + //! come back is the parser saying it agreed. A filter the parser did not
43 + //! recognise stays in the free text and simply does not appear, which is the
44 + //! same answer, said by omission.
45 + //!
46 + //! # A row says which kind it is
47 + //!
48 + //! Results are heterogeneous across all five `type:` values, so a row carries a
49 + //! [`Tag`] naming its kind, the way status is carried elsewhere. Each row
50 + //! activates to that kind's own screen; every one of the five has a described
51 + //! detail route, which is why no kind is left inert.
52 +
53 + // Handlers take their request by value because `quasi_router::Handler` is a
54 + // plain `fn(&S, Request)` pointer, so the signature is the router's.
55 + #![allow(clippy::needless_pass_by_value)]
56 +
57 + use quasi_router::screen::{Field, Row, Tag};
58 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
59 +
60 + use crate::commands::{SearchInput, SearchResultResponse, SearchResultsResponse};
61 + use crate::state::AppState;
62 +
63 + #[cfg(test)]
64 + mod tests;
65 +
66 + /// The region the query box replaces as it is typed.
67 + const RESULTS: &str = "search-results";
68 +
69 + /// The kinds a result can be, and the routes they open.
70 + ///
71 + /// Spelled here rather than inferred from the string, because a row that
72 + /// activated a path built by interpolating an unrecognised kind is a 404 the
73 + /// first time somebody presses it.
74 + fn destination(result: &SearchResultResponse) -> Option<Action> {
75 + let path = match result.result_type.as_str() {
76 + "task" => format!("/tasks/{}", result.id),
77 + "project" => format!("/projects/{}", result.id),
78 + "event" => format!("/events/{}", result.id),
79 + "contact" => format!("/contacts/{}", result.id),
80 + "email" => format!("/emails/{}", result.id),
81 + _ => return None,
82 + };
83 + Some(Action::get(path))
84 + }
85 +
86 + /// What a row's tag calls its kind.
87 + fn kind_label(result_type: &str) -> &str {
88 + match result_type {
89 + "task" => "Task",
90 + "project" => "Project",
91 + "event" => "Event",
92 + "contact" => "Contact",
93 + "email" => "Email",
94 + other => other,
95 + }
96 + }
97 +
98 + /// One result as a row.
99 + ///
100 + /// The snippet is the FTS extract around the match, which is the sentence that
101 + /// says why this row is here, so it is the row's secondary text. The project is
102 + /// a plain fact and goes in `meta`.
103 + fn row_for(result: &SearchResultResponse) -> Row {
104 + let mut row = Row::new(&result.title);
105 +
106 + if let Some(snippet) = result.snippet.as_deref().filter(|s| !s.is_empty()) {
107 + row = row.secondary(snippet);
108 + }
109 + if let Some(project) = result.project_name.as_deref().filter(|p| !p.is_empty()) {
110 + row = row.meta(project);
111 + }
112 +
113 + row = row.token(Tag::badge(kind_label(&result.result_type)));
114 + row.activate = destination(result);
115 + row
116 + }
117 +
118 + /// A param that is present and not blank. Blank is absent, which is what an
119 + /// emptied query box means.
120 + fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> {
121 + params.get(name).map(str::trim).filter(|v| !v.is_empty())
122 + }
123 +
124 + /// Run the search behind the command, from state a route handler holds.
125 + fn look(state: &AppState, query: &str) -> Result<SearchResultsResponse, RouteError> {
126 + crate::commands::run_search(
127 + state,
128 + SearchInput {
129 + query: query.to_owned(),
130 + result_type: None,
131 + project_id: None,
132 + date_from: None,
133 + date_to: None,
134 + limit: None,
135 + offset: None,
136 + },
137 + )
138 + .map_err(|error| RouteError::internal(error.to_string()))
139 + }
140 +
141 + /// The grammar, said once on an empty screen.
142 + ///
143 + /// Not a permanent panel: it is what there is to show when there is no query
144 + /// and no results, and it is the only place the vocabulary is written down for
145 + /// somebody who has not read the parser.
146 + fn grammar() -> Vec<Node> {
147 + vec![
148 + Node::text(
149 + "Type to search across tasks, emails, events, projects and contacts. \
150 + Words narrow by text; a colon filter narrows by fact.",
151 + ),
152 + Node::text("is:overdue is:today is:tomorrow is:thisweek is:snoozed"),
153 + Node::text("is:pending is:started is:completed is:waiting"),
154 + Node::text("priority:high priority:medium priority:low"),
155 + Node::text("type:task type:email type:event type:project type:contact"),
156 + Node::text("in:ProjectName tag:name -tag:name"),
157 + Node::text("after:2026-08-01 before:2026-09-01"),
158 + ]
159 + }
160 +
161 + /// The filters and the results, which is what the query box replaces.
162 + fn results(state: &AppState, query: Option<&str>) -> Result<Slot, RouteError> {
163 + let mut slot = Slot::new(RESULTS, RegionKind::Pane);
164 +
165 + let Some(query) = query else {
166 + for node in grammar() {
167 + slot = slot.with(node);
168 + }
169 + return Ok(slot);
170 + };
171 +
172 + let found = look(state, query)?;
173 +
174 + // What the parser understood, said back. A badge rather than a chip: these
175 + // are the query's own words read aloud, and pressing one would have to mean
176 + // removing it from a string the reader is holding the caret in.
177 + for filter in &found.active_filters {
178 + slot = slot.with(Node::Token(Tag::badge(filter)));
179 + }
180 +
181 + if found.results.is_empty() {
182 + return Ok(slot.with(Node::empty("Nothing matches that.")));
183 + }
184 +
185 + slot = slot.with(Node::text(match found.total {
186 + 1 => "1 result".to_owned(),
187 + total => format!("{total} results"),
188 + }));
189 +
190 + Ok(slot.with(Node::list(found.results.iter().map(row_for))))
191 + }
192 +
193 + /// The query box.
194 + fn box_for(query: Option<&str>) -> Field {
195 + let mut field = Field::new(makeover_layout::FieldKind::Text, "q", "Search")
196 + // The answer replaces the results region, and the field asks for it
197 + // once the value has stood still. No submit: a search is a read, and
198 + // waiting for one would be the box pretending it writes something.
199 + .consults(Action::get("/search/results").replacing(RESULTS));
200 + field.placeholder = Some("invoice is:overdue in:Ledger".to_owned());
201 + if let Some(query) = query {
202 + field = field.value(query);
203 + }
204 + field
205 + }
206 +
207 + /// The whole screen.
208 + fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
209 + let query = text(&request.carried, "q");
210 +
211 + let band = Slot::new("search-band", RegionKind::Band)
212 + .with(Node::page("Search"))
213 + .with(Node::Field(Box::new(box_for(query))));
214 +
215 + Ok(Screen::list_detail("Search", false)
216 + .at_place(super::shell::SEARCH)
217 + .with(band)
218 + .with(results(state, query)?)
219 + .into())
220 + }
221 +
222 + /// The results alone, which is what the query box asks for as it is typed.
223 + fn results_only(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
224 + let slot = results(state, text(&request.carried, "q"))?;
225 + Ok(Response::fragment(RESULTS, Node::Region(slot)))
226 + }
227 +
228 + /// The search screen's routes.
229 + #[must_use]
230 + pub fn routes(router: Router<AppState>) -> Router<AppState> {
231 + router
232 + .get("/search", index)
233 + .get("/search/results", results_only)
234 + }
@@ -1,0 +1,255 @@
1 + //! The search screen, driven through the router against a real database.
2 + //!
3 + //! Same property as the contacts tests: no Tauri runtime and no window, because
4 + //! a route is a function from state and params to a description.
5 +
6 + use std::sync::Arc;
7 +
8 + use goingson_core::{
9 + NewContact, NewEmail, NewEvent, NewProject, NewTask, Priority, ProjectStatus, ProjectType,
10 + };
11 + use quasi_http::Serves as _;
12 + use quasi_router::Outcome;
13 + use quasi_router::{Params, Request, Response};
14 +
15 + use super::super::router;
16 + use crate::state::{AppState, DESKTOP_USER_ID};
17 +
18 + /// State with the desktop user in place, which is who the handlers read as.
19 + async fn state() -> Arc<AppState> {
20 + let (state, _) = crate::test_utils::setup_test_state().await;
21 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
22 + state
23 + .db
24 + .conn()
25 + .unwrap()
26 + .execute(
27 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
28 + VALUES (?, ?, ?, ?, ?)",
29 + rusqlite::params![
30 + DESKTOP_USER_ID.to_string(),
31 + "desktop@localhost",
32 + "x",
33 + "Desktop User",
34 + &now,
35 + ],
36 + )
37 + .unwrap();
38 + state
39 + }
40 +
41 + /// One of each of the five kinds the index holds, all matching "kestrel".
42 + fn seed_all_five(state: &AppState) {
43 + state
44 + .tasks
45 + .create(
46 + DESKTOP_USER_ID,
47 + NewTask::builder("Kestrel migration")
48 + .priority(Priority::High)
49 + .build(),
50 + )
51 + .unwrap();
52 + state
53 + .projects
54 + .create(
55 + DESKTOP_USER_ID,
56 + NewProject {
57 + name: "Kestrel".to_owned(),
58 + description: String::new(),
59 + project_type: ProjectType::SideProject,
60 + status: ProjectStatus::Active,
61 + },
62 + )
63 + .unwrap();
64 + state
65 + .events
66 + .create(
67 + DESKTOP_USER_ID,
68 + NewEvent::builder("Kestrel planning", chrono::Utc::now()).build(),
69 + )
70 + .unwrap();
71 + state
72 + .contacts
73 + .create(
74 + DESKTOP_USER_ID,
75 + NewContact {
76 + display_name: "Kestrel Vendor".to_owned(),
77 + nickname: None,
78 + company: None,
79 + title: None,
80 + notes: String::new(),
81 + tags: Vec::new(),
82 + birthday: None,
83 + timezone: None,
84 + is_implicit: false,
85 + },
86 + )
87 + .unwrap();
88 + state
89 + .emails
90 + .create(
91 + DESKTOP_USER_ID,
92 + NewEmail {
93 + project_id: None,
94 + from_address: "someone@example.com".to_owned(),
95 + to_address: "desktop@localhost".to_owned(),
96 + subject: "Kestrel delivery".to_owned(),
97 + body: "The kestrel ships Tuesday.".to_owned(),
98 + is_read: false,
99 + received_at: None,
100 + },
101 + )
102 + .unwrap();
103 + }
104 +
105 + fn get(state: &AppState, path: &str, params: Params) -> Response {
106 + router()
107 + .handle(state, Request::get(path).carrying(params))
108 + .expect("the route answers")
109 + }
110 +
111 + fn screen_html(response: Response) -> String {
112 + let Outcome::Screen(screen) = (response).outcome else {
113 + panic!("the route answers with a screen");
114 + };
115 + quasi_webview::Webview::new().screen(&screen)
116 + }
117 +
118 + fn fragment_html(response: Response) -> String {
119 + let Outcome::Fragment { node, .. } = (response).outcome else {
120 + panic!("the route answers with a fragment");
121 + };
122 + quasi_webview::Webview::new().fragment(&node)
123 + }
124 +
125 + #[tokio::test]
126 + async fn an_empty_query_teaches_the_grammar_rather_than_searching() {
127 + // The colon filters are the feature, and a screen that showed nothing until
128 + // a query arrived would keep them secret.
129 + let state = state().await;
130 + let html = screen_html(get(&state, "/search", Params::new()));
131 + assert!(html.contains("is:overdue"), "{html}");
132 + assert!(html.contains("type:contact"), "{html}");
133 + assert!(html.contains("-tag:name"), "{html}");
134 + }
135 +
136 + #[tokio::test]
137 + async fn a_blank_query_is_the_same_as_no_query() {
138 + // An emptied box sends the param with nothing in it. Searching for the empty
139 + // string instead would answer with the whole database.
140 + let state = state().await;
141 + seed_all_five(&state);
142 +
143 + let html = screen_html(get(&state, "/search", Params::new().with("q", " ")));
144 + assert!(html.contains("is:overdue"), "{html}");
145 + assert!(!html.contains("Kestrel migration"), "{html}");
146 + }
147 +
148 + #[tokio::test]
149 + async fn the_screen_searches_across_all_five_kinds() {
150 + let state = state().await;
151 + seed_all_five(&state);
152 +
153 + let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel")));
154 + for hit in [
155 + "Kestrel migration",
156 + "Kestrel",
157 + "Kestrel planning",
158 + "Kestrel Vendor",
159 + "Kestrel delivery",
160 + ] {
161 + assert!(html.contains(hit), "missing {hit}: {html}");
162 + }
163 + }
164 +
165 + #[tokio::test]
166 + async fn a_row_says_which_kind_it_is() {
167 + // The results are heterogeneous, so a row that only said its title would
168 + // leave the reader to guess whether "Kestrel" is a project or a contact.
169 + let state = state().await;
170 + seed_all_five(&state);
171 +
172 + let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel")));
173 + for kind in ["Task", "Project", "Event", "Contact", "Email"] {
174 + assert!(html.contains(kind), "missing the {kind} tag: {html}");
175 + }
176 + }
177 +
178 + #[tokio::test]
179 + async fn the_parsed_filters_are_shown_back() {
180 + // What makes a colon grammar learnable: the parser saying which words it
181 + // took as filters, from the labels the command already collects.
182 + let state = state().await;
183 + seed_all_five(&state);
184 +
185 + let html = fragment_html(get(
186 + &state,
187 + "/search/results",
188 + Params::new().with("q", "kestrel is:pending priority:high type:task"),
189 + ));
190 + assert!(html.contains("is:pending"), "{html}");
191 + assert!(html.contains("priority:high"), "{html}");
192 + assert!(html.contains("type:task"), "{html}");
193 + }
194 +
195 + #[tokio::test]
196 + async fn a_type_filter_narrows_to_that_kind() {
197 + let state = state().await;
198 + seed_all_five(&state);
199 +
200 + let html = fragment_html(get(
201 + &state,
202 + "/search/results",
203 + Params::new().with("q", "kestrel type:contact"),
204 + ));
205 + assert!(html.contains("Kestrel Vendor"), "{html}");
206 + assert!(!html.contains("Kestrel migration"), "{html}");
207 + }
208 +
209 + #[tokio::test]
210 + async fn a_query_that_matches_nothing_says_so() {
211 + let state = state().await;
212 + seed_all_five(&state);
213 +
214 + let html = fragment_html(get(
215 + &state,
216 + "/search/results",
217 + Params::new().with("q", "peregrine"),
218 + ));
219 + assert!(html.contains("Nothing matches that."), "{html}");
220 + }
221 +
222 + #[tokio::test]
223 + async fn the_box_asks_the_results_route_as_it_is_typed() {
224 + // `Field::consults` is what makes this a search screen rather than a form:
225 + // no submit, and the answer lands in the results region.
226 + let state = state().await;
227 + let html = screen_html(get(&state, "/search", Params::new()));
228 + assert!(html.contains("/search/results"), "{html}");
229 + assert!(html.contains("search-results"), "{html}");
230 + }
231 +
232 + #[tokio::test]
233 + async fn a_search_has_an_address() {
234 + // The query is a param rather than state the field holds privately, so the
235 + // view a reader is looking at can be linked to and reloaded.
236 + let state = state().await;
237 + seed_all_five(&state);
238 +
239 + let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel")));
240 + assert!(html.contains("Kestrel migration"), "{html}");
241 + assert!(
242 + html.contains("kestrel"),
243 + "the box holds the query back: {html}"
244 + );
245 + }
246 +
247 + #[tokio::test]
248 + async fn search_is_a_place_in_the_nav() {
249 + // The entry point, decided on goingson `6b3aa22b`: a nav entry serves
250 + // pointer, touch and keyboard alike, and the app can bind no keys.
251 + let state = state().await;
252 + let html = screen_html(get(&state, "/search", Params::new()));
253 + assert!(html.contains("/search"), "{html}");
254 + assert!(html.contains("Search"), "{html}");
255 + }