feat(quotesdb): merge UI shell, API client, storage and base components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
commit 52e771e9c4

@ -340,9 +340,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -1106,8 +1108,10 @@ dependencies = [
"async-trait",
"axum",
"common",
"getrandom 0.3.4",
"getrandom 0.4.1",
"gloo",
"js-sys",
"rand",
"rusqlite",
"serde",

@ -44,11 +44,15 @@ async-trait = "0.1"
# WASM-only dependencies (Workers API binary + UI binary).
# workers-rs, getrandom/wasm_js, and UI libraries are wasm32-specific.
[target.'cfg(target_arch = "wasm32")'.dependencies]
# Add the `js` feature to uuid on wasm32 to enable Web Crypto entropy for v4 UUIDs.
uuid = { version = "1", features = ["js"] }
# Cloudflare Workers SDK — provides D1 bindings, fetch, KV, etc.
worker = { version = "0.5", features = ["d1"] }
# Entropy source for WASM targets: bridges uuid (v4) and rand (OsRng) to
# crypto.getRandomValues() via the Web Crypto API.
getrandom = { version = "0.4", features = ["wasm_js"] }
# getrandom 0.3 is a transitive dep of rand 0.9 → rand_core 0.9; enable wasm_js feature.
getrandom_03 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
# Yew — Rust/Wasm frontend framework for building the SPA UI.
yew = { version = "0.22", features = ["csr"] }
# Yew Router — client-side routing for Yew SPAs.
@ -59,8 +63,10 @@ gloo = "0.11"
wasm-bindgen = "0.2"
# wasm-bindgen-futures — bridges Rust futures to JS Promises for async fetch.
wasm-bindgen-futures = "0.4"
# web-sys — raw browser DOM/Web API bindings.
web-sys = { version = "0.3", features = ["Window", "Document", "HtmlElement"] }
# web-sys — raw browser DOM/Web API bindings. Storage and SessionStorage for auth code persistence.
web-sys = { version = "0.3", features = ["Window", "Document", "HtmlElement", "Storage"] }
# js-sys — JS standard library bindings, used for URL encoding in API client.
js-sys = "0.3"
# Build-time dependencies for compile-time YAML-to-JSON conversion of the
# OpenAPI spec. serde_yaml is only needed at build time — it never enters

@ -0,0 +1,178 @@
//! Typed API client for the quotesdb backend.
//!
//! Provides async functions that wrap all quotesdb API endpoints using
//! `gloo::net::http::Request`. All functions return `Result<T, ApiError>`.
//!
//! # Example
//!
//! ```ignore
//! let quote = api::get_random_quote().await?;
//! ```
use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput};
use serde::Deserialize;
/// Response type for `GET /api/quotes` (paginated list).
#[derive(Debug, Clone, Deserialize)]
pub struct ListResponse {
/// The quotes on the current page.
pub quotes: Vec<Quote>,
/// Current page number (1-indexed).
pub page: u32,
/// Total number of pages available.
pub total_pages: u32,
/// Total number of quotes matching the query.
pub total_count: u32,
}
/// Response type for `PUT /api/quotes` (create quote).
///
/// Includes the newly created quote and its auth code (only returned once).
#[derive(Debug, Clone, Deserialize)]
pub struct CreateResponse {
/// The created quote record.
pub quote: Quote,
/// The auth code required for future edits/deletes. Store this securely.
pub auth_code: String,
}
/// Errors that can occur during API calls.
#[derive(Debug, thiserror::Error, Clone)]
pub enum ApiError {
/// A network-level error (e.g., connection refused, timeout).
#[error("network error: {0}")]
Network(String),
/// The server responded with an error status code.
#[error("server error {status}: {message}")]
Server { status: u16, message: String },
/// Failed to parse the response body as JSON.
#[error("parse error: {0}")]
Parse(String),
}
/// Fetch a paginated list of quotes.
///
/// # Arguments
/// - `page` — 1-indexed page number.
/// - `author` — Optional author name filter (URL-encoded automatically).
/// - `tag` — Optional tag filter (URL-encoded automatically).
pub async fn list_quotes(
page: u32,
author: Option<&str>,
tag: Option<&str>,
) -> Result<ListResponse, ApiError> {
let mut url = format!("/api/quotes?page={page}");
if let Some(a) = author {
url.push_str(&format!("&author={}", js_sys::encode_uri_component(a)));
}
if let Some(t) = tag {
url.push_str(&format!("&tag={}", js_sys::encode_uri_component(t)));
}
fetch_json(&url).await
}
/// Fetch a single quote by its ID.
///
/// Returns `ApiError::Server { status: 404, .. }` if the quote does not exist.
pub async fn get_quote(id: &str) -> Result<Quote, ApiError> {
fetch_json(&format!("/api/quotes/{id}")).await
}
/// Fetch a random quote from the database.
pub async fn get_random_quote() -> Result<Quote, ApiError> {
fetch_json("/api/quotes/random").await
}
/// Create a new quote.
///
/// Returns the created quote and its auth code on success (HTTP 201).
pub async fn create_quote(input: &CreateQuoteInput) -> Result<CreateResponse, ApiError> {
let resp = gloo::net::http::Request::put("/api/quotes")
.json(input)
.map_err(|e| ApiError::Network(e.to_string()))?
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if resp.status() == 201 {
resp.json()
.await
.map_err(|e| ApiError::Parse(e.to_string()))
} else {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status: resp.status(),
message: msg,
})
}
}
/// Update an existing quote.
///
/// Requires the correct `auth_code` in the `X-Auth-Code` header.
/// Returns `ApiError::Server { status: 403, .. }` on wrong auth code.
pub async fn update_quote(
id: &str,
input: &UpdateQuoteInput,
auth_code: &str,
) -> Result<Quote, ApiError> {
let resp = gloo::net::http::Request::post(&format!("/api/quotes/{id}"))
.header("X-Auth-Code", auth_code)
.json(input)
.map_err(|e| ApiError::Network(e.to_string()))?
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if resp.status() == 200 {
resp.json()
.await
.map_err(|e| ApiError::Parse(e.to_string()))
} else {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status: resp.status(),
message: msg,
})
}
}
/// Delete a quote by ID.
///
/// Requires the correct `auth_code` in the `X-Auth-Code` header.
/// Returns `Ok(())` on HTTP 204. Returns `ApiError::Server { status: 403, .. }` on wrong auth code.
pub async fn delete_quote(id: &str, auth_code: &str) -> Result<(), ApiError> {
let resp = gloo::net::http::Request::delete(&format!("/api/quotes/{id}"))
.header("X-Auth-Code", auth_code)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if resp.status() == 204 {
Ok(())
} else {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status: resp.status(),
message: msg,
})
}
}
/// Internal helper: GET a URL and deserialise the response body as JSON.
///
/// Returns `ApiError` on non-2xx status or deserialisation failure.
async fn fetch_json<T: for<'de> serde::Deserialize<'de>>(url: &str) -> Result<T, ApiError> {
let resp = gloo::net::http::Request::get(url)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if resp.status() >= 200 && resp.status() < 300 {
resp.json()
.await
.map_err(|e| ApiError::Parse(e.to_string()))
} else {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status: resp.status(),
message: msg,
})
}
}

@ -0,0 +1,79 @@
//! AuthModal component — prompts the user to enter their auth code.
//!
//! Used on the Quote Detail page before edit or delete actions.
use yew::prelude::*;
/// Props for the [`AuthModal`] component.
#[derive(Properties, PartialEq)]
pub struct AuthModalProps {
/// Callback invoked with the entered auth code when the form is submitted.
pub on_submit: Callback<String>,
/// Callback invoked when the user cancels the modal.
pub on_cancel: Callback<()>,
/// Optional pre-filled value (e.g., from session storage).
pub prefill: Option<String>,
}
/// Modal dialog that prompts the user to enter an auth code.
///
/// Pre-fills the input with `prefill` if provided (e.g., from session storage).
/// Calls `on_submit` with the entered value or `on_cancel` on dismissal.
///
/// # Example
///
/// ```ignore
/// html! {
/// <AuthModal
/// on_submit={Callback::from(|code| log::info!("code: {code}"))}
/// on_cancel={Callback::from(|_| {})}
/// prefill={None}
/// />
/// }
/// ```
#[function_component(AuthModal)]
pub fn auth_modal(props: &AuthModalProps) -> Html {
let value = use_state(|| props.prefill.clone().unwrap_or_default());
let oninput = {
let value = value.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
value.set(input.value());
})
};
let onsubmit = {
let value = value.clone();
let on_submit = props.on_submit.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
on_submit.emit((*value).clone());
})
};
let on_cancel = props.on_cancel.clone();
html! {
<div class="auth-modal__overlay">
<div class="auth-modal">
<h2 class="auth-modal__title">{ "Enter Auth Code" }</h2>
<form onsubmit={onsubmit}>
<input
class="auth-modal__input"
type="text"
placeholder="word-word-word-word"
value={(*value).clone()}
oninput={oninput}
/>
<div class="auth-modal__actions">
<button type="submit" class="btn btn--primary">{ "Confirm" }</button>
<button
type="button"
class="btn"
onclick={Callback::from(move |_| on_cancel.emit(()))}
>
{ "Cancel" }
</button>
</div>
</form>
</div>
</div>
}
}

@ -0,0 +1,26 @@
//! Error display component for surfacing API and application errors to users.
use yew::prelude::*;
/// Props for the [`ErrorDisplay`] component.
#[derive(Properties, PartialEq)]
pub struct ErrorProps {
/// The error message to display.
pub message: String,
}
/// Displays a user-visible error message in a styled container.
///
/// # Example
///
/// ```ignore
/// html! { <ErrorDisplay message="Something went wrong." /> }
/// ```
#[function_component(ErrorDisplay)]
pub fn error_display(props: &ErrorProps) -> Html {
html! {
<div class="error-display">
<p class="error-display__message">{ &props.message }</p>
</div>
}
}

@ -0,0 +1,8 @@
//! Reusable UI components for the quotesdb frontend.
//!
//! Each component lives in its own submodule and is a Yew `#[function_component]`.
pub mod auth_modal;
pub mod error;
pub mod pagination;
pub mod quote_card;

@ -0,0 +1,54 @@
//! Pagination component — previous/next page controls.
use yew::prelude::*;
/// Props for the [`Pagination`] component.
#[derive(Properties, PartialEq)]
pub struct PaginationProps {
/// The current page number (1-indexed).
pub page: u32,
/// The total number of pages.
pub total_pages: u32,
/// Callback invoked with the new page number when the user navigates.
pub on_page: Callback<u32>,
}
/// Renders previous/next page controls with the current page indicator.
///
/// Disables the "Previous" button on page 1 and "Next" on the last page.
///
/// # Example
///
/// ```ignore
/// html! {
/// <Pagination page={1} total_pages={5} on_page={Callback::from(|p| log::info!("{p}"))} />
/// }
/// ```
#[function_component(Pagination)]
pub fn pagination(props: &PaginationProps) -> Html {
let prev = props.page > 1;
let next = props.page < props.total_pages;
let on_prev = {
let cb = props.on_page.clone();
let p = props.page;
Callback::from(move |_| cb.emit(p - 1))
};
let on_next = {
let cb = props.on_page.clone();
let p = props.page;
Callback::from(move |_| cb.emit(p + 1))
};
html! {
<div class="pagination">
<button class="pagination__btn" disabled={!prev} onclick={on_prev}>
{ "← Prev" }
</button>
<span class="pagination__info">
{ format!("Page {} of {}", props.page, props.total_pages) }
</span>
<button class="pagination__btn" disabled={!next} onclick={on_next}>
{ "Next →" }
</button>
</div>
}
}

@ -0,0 +1,47 @@
//! QuoteCard component — renders a single quote in a card layout.
use crate::Route;
use quotesdb::Quote;
use yew::prelude::*;
use yew_router::prelude::*;
/// Props for the [`QuoteCard`] component.
#[derive(Properties, PartialEq)]
pub struct QuoteCardProps {
/// The quote to display.
pub quote: Quote,
}
/// Renders a quote in a styled card with author, source, tags, and a "View" link.
///
/// # Example
///
/// ```ignore
/// html! { <QuoteCard quote={my_quote} /> }
/// ```
#[function_component(QuoteCard)]
pub fn quote_card(props: &QuoteCardProps) -> Html {
let q = &props.quote;
html! {
<div class="quote-card">
<blockquote class="quote-card__text">{ &q.text }</blockquote>
<footer class="quote-card__footer">
<Link<Route> to={Route::Author { name: q.author.clone() }} classes="quote-card__author">
{ format!("— {}", q.author) }
</Link<Route>>
if let Some(src) = &q.source {
<span class="quote-card__source">{ format!(", {src}") }</span>
}
</footer>
if !q.tags.is_empty() {
<div class="quote-card__tags">
{ for q.tags.iter().map(|t| html! {
<span class="quote-card__tag">{ t }</span>
}) }
</div>
}
<Link<Route> to={Route::QuoteDetail { id: q.id.clone() }} classes="quote-card__link">
{ "View" }
</Link<Route>>
</div>
}
}

@ -1,9 +1,82 @@
//! UI binary entrypoint.
//! UI binary entrypoint for quotesdb.
//!
//! Compiled to WebAssembly via Trunk targeting `wasm32-unknown-unknown`.
//! Runs the Yew frontend application.
//! Runs the Yew single-page application with client-side routing.
fn main() {}
use yew::prelude::*;
use yew_router::prelude::*;
#[cfg(test)]
mod tests {}
/// Application routes matching the frontend route definitions in the design spec.
///
/// Routes map to page components. The `NotFound` variant catches all unmatched paths.
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
/// Home page — displays a random quote.
#[at("/")]
Home,
/// Browse page — paginated list of quotes with filters.
#[at("/browse")]
Browse,
/// Single quote view/edit/delete page.
#[at("/quotes/:id")]
QuoteDetail { id: String },
/// All quotes by a specific author.
#[at("/author/:name")]
Author { name: String },
/// New quote submission form.
#[at("/submit")]
Submit,
/// Catch-all 404 page.
#[not_found]
#[at("/404")]
NotFound,
}
/// Route switch function — maps each `Route` variant to its page component.
fn switch(routes: Route) -> Html {
match routes {
Route::Home => html! { <pages::home::HomePage /> },
Route::Browse => html! { <pages::browse::BrowsePage /> },
Route::QuoteDetail { id } => html! { <pages::quote::QuotePage id={id} /> },
Route::Author { name } => html! { <pages::author::AuthorPage name={name} /> },
Route::Submit => html! { <pages::submit::SubmitPage /> },
Route::NotFound => html! {
<div class="page-not-found">
<h1>{ "404 — Page Not Found" }</h1>
<p>{ "The page you are looking for does not exist." }</p>
<a href="/">{ "Go home" }</a>
</div>
},
}
}
/// Root application component.
///
/// Wraps the entire application in a `BrowserRouter` and renders the
/// route-switched page components via `Switch`.
#[function_component(App)]
fn app() -> Html {
html! {
<BrowserRouter>
<nav class="nav">
<Link<Route> to={Route::Home} classes="nav__brand">{ "QuotesDB" }</Link<Route>>
<div class="nav__links">
<Link<Route> to={Route::Browse} classes="nav__link">{ "Browse" }</Link<Route>>
<Link<Route> to={Route::Submit} classes="nav__link">{ "Submit" }</Link<Route>>
</div>
</nav>
<main class="main-content">
<Switch<Route> render={switch} />
</main>
</BrowserRouter>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
mod api;
mod components;
mod pages;
mod storage;

@ -0,0 +1,15 @@
//! Author page — all quotes by a specific author.
use yew::prelude::*;
/// Props for the [`AuthorPage`] component.
#[derive(Properties, PartialEq)]
pub struct AuthorPageProps {
/// The author name from the route parameter.
pub name: String,
}
/// Author page component. Lists all quotes attributed to the given author.
#[function_component(AuthorPage)]
pub fn author_page(props: &AuthorPageProps) -> Html {
html! { <p>{ format!("Loading quotes by {}...", props.name) }</p> }
}

@ -0,0 +1,8 @@
//! Browse page — paginated quote list with author and tag filters.
use yew::prelude::*;
/// Browse page component. Displays paginated quotes with filter controls.
#[function_component(BrowsePage)]
pub fn browse_page() -> Html {
html! { <p>{ "Loading..." }</p> }
}

@ -0,0 +1,8 @@
//! Home page — displays a random quote.
use yew::prelude::*;
/// Home page component. Fetches and displays a random quote on mount.
#[function_component(HomePage)]
pub fn home_page() -> Html {
html! { <p>{ "Loading..." }</p> }
}

@ -0,0 +1,9 @@
//! Page components for the quotesdb frontend.
//!
//! Each page corresponds to a route in the [`crate::Route`] enum.
pub mod author;
pub mod browse;
pub mod home;
pub mod quote;
pub mod submit;

@ -0,0 +1,15 @@
//! Quote detail page — view, edit, and delete a single quote.
use yew::prelude::*;
/// Props for the [`QuotePage`] component.
#[derive(Properties, PartialEq)]
pub struct QuotePageProps {
/// The quote's unique ID from the route parameter.
pub id: String,
}
/// Quote detail page. Fetches a single quote by ID and renders view/edit/delete actions.
#[function_component(QuotePage)]
pub fn quote_page(props: &QuotePageProps) -> Html {
html! { <p>{ format!("Loading quote {}...", props.id) }</p> }
}

@ -0,0 +1,8 @@
//! Submit page — new quote submission form.
use yew::prelude::*;
/// Submit page component. Renders a form for creating a new quote.
#[function_component(SubmitPage)]
pub fn submit_page() -> Html {
html! { <p>{ "Quote submission form" }</p> }
}

@ -0,0 +1,54 @@
//! Session storage helpers for persisting quote auth codes within a browser tab.
//!
//! Auth codes are stored in `sessionStorage` under the key `auth_code_{quote_id}`.
//! Session storage is tab-scoped and cleared when the tab closes, making it
//! appropriate for short-lived auth code persistence.
//!
//! This module must only be used from `wasm32-unknown-unknown` targets (the UI binary).
/// Persist an auth code for a quote in `sessionStorage`.
///
/// The key format is `auth_code_{quote_id}`. Silently no-ops if the browser
/// `sessionStorage` API is unavailable.
///
/// # Arguments
/// - `quote_id` — The quote's unique ID.
/// - `auth_code` — The auth code to store.
pub fn set_auth_code(quote_id: &str, auth_code: &str) {
if let Some(storage) = web_sys::window()
.and_then(|w| w.session_storage().ok())
.flatten()
{
let _ = storage.set_item(&format!("auth_code_{quote_id}"), auth_code);
}
}
/// Retrieve the stored auth code for a quote from `sessionStorage`.
///
/// Returns `None` if no code is stored for this quote ID or if `sessionStorage`
/// is unavailable.
///
/// # Arguments
/// - `quote_id` — The quote's unique ID.
pub fn get_auth_code(quote_id: &str) -> Option<String> {
web_sys::window()
.and_then(|w| w.session_storage().ok())
.flatten()
.and_then(|s| s.get_item(&format!("auth_code_{quote_id}")).ok())
.flatten()
}
/// Remove the stored auth code for a quote from `sessionStorage`.
///
/// Silently no-ops if no code is stored or if `sessionStorage` is unavailable.
///
/// # Arguments
/// - `quote_id` — The quote's unique ID.
pub fn clear_auth_code(quote_id: &str) {
if let Some(storage) = web_sys::window()
.and_then(|w| w.session_storage().ok())
.flatten()
{
let _ = storage.remove_item(&format!("auth_code_{quote_id}"));
}
}

@ -13,77 +13,65 @@ use serde::{Deserialize, Serialize};
// Source: <https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt>
// Used to generate 4-word passphrases for auth_code.
const WORDS: &[&str] = &[
"acid", "aged", "also", "apex", "aqua", "arch", "army", "atom", "aunt", "avid",
"away", "baby", "back", "bail", "bait", "bale", "ball", "band", "bank", "barn",
"base", "bath", "bead", "bean", "bear", "beat", "beef", "beer", "bell", "best",
"bill", "bird", "bite", "blew", "blob", "bloc", "blog", "blow", "blue", "bold",
"bolt", "bond", "bone", "book", "boom", "boot", "bore", "born", "boss", "both",
"bowl", "bred", "brew", "bulb", "bulk", "bull", "bump", "burn", "burp", "buzz",
"cage", "cake", "calf", "call", "calm", "camp", "cane", "card", "care", "cart",
"case", "cash", "cast", "cave", "cell", "chef", "chin", "chip", "chop", "cite",
"city", "clam", "clap", "claw", "clay", "clip", "club", "clue", "coal", "coat",
"coil", "coin", "cold", "comet", "cook", "cool", "cope", "copy", "cord", "core",
"cork", "corn", "cost", "couch", "coup", "cove", "crab", "crew", "crop", "crow",
"cube", "cult", "cure", "curl", "cute", "dado", "dale", "dame", "dare", "dark",
"dart", "data", "date", "dawn", "dead", "deal", "dear", "debt", "deck", "deem",
"deer", "deft", "deny", "desk", "dial", "diet", "dirt", "disk", "dock", "doll",
"dome", "door", "dose", "dove", "down", "draw", "drew", "drop", "drum", "dual",
"duel", "dune", "dunk", "dusk", "dust", "duty", "each", "earl", "earn", "ease",
"east", "edge", "else", "emit", "epic", "even", "ever", "evil", "exam", "exit",
"face", "fact", "fade", "fail", "fair", "fake", "fall", "fame", "fare", "farm",
"fast", "fate", "fawn", "fear", "feat", "feel", "felt", "fern", "fest", "file",
"fill", "film", "find", "fine", "fire", "firm", "fish", "fist", "flag", "flat",
"flaw", "flea", "flew", "flex", "flip", "flock", "flow", "foam", "foil", "fold",
"folk", "fond", "font", "food", "fool", "foot", "ford", "fork", "form", "fort",
"foul", "four", "fowl", "free", "from", "fuel", "full", "fund", "fuse", "fuss",
"gale", "game", "gang", "gaze", "gear", "gene", "germ", "gift", "gill", "give",
"glad", "glow", "glue", "goal", "goat", "gold", "golf", "good", "grab", "grad",
"gram", "gray", "grew", "grey", "grid", "grin", "grip", "grow", "gulf", "gull",
"gust", "half", "hall", "halt", "hand", "hang", "hard", "hare", "harm", "harp",
"have", "hawk", "head", "heal", "heap", "heat", "heel", "held", "helm", "help",
"herb", "hero", "hill", "hive", "hock", "hold", "hole", "home", "hook", "hope",
"horn", "host", "hour", "huge", "hull", "hunt", "hurt", "icon", "idea", "idle",
"inch", "into", "iris", "iron", "item", "jail", "jerk", "jest", "join", "joke",
"jolt", "jump", "just", "keen", "keep", "kelp", "kick", "kind", "king", "knot",
"know", "lace", "lack", "lake", "lamb", "lamp", "land", "lane", "last", "lava",
"lawn", "lazy", "lead", "leaf", "lean", "leap", "left", "lend", "lens", "lift",
"lime", "limp", "line", "link", "lion", "list", "live", "load", "lock", "loft",
"loin", "lone", "long", "look", "loom", "loop", "lore", "loss", "loud", "love",
"luck", "lure", "lurk", "made", "mail", "main", "make", "male", "mall", "malt",
"mare", "mark", "Mars", "mast", "math", "maze", "meal", "meat", "meet", "melt",
"memo", "menu", "mere", "mesh", "mild", "mile", "mill", "mime", "mind", "mine",
"mink", "mint", "mist", "mode", "mole", "mood", "moon", "moor", "more", "moss",
"most", "move", "much", "muck", "muse", "must", "myth", "nail", "name", "navy",
"neat", "neck", "need", "news", "next", "nice", "node", "none", "norm", "nose",
"note", "noun", "null", "oath", "obey", "odds", "once", "only", "open", "oral",
"oval", "oven", "over", "pace", "pack", "page", "paid", "pair", "palm", "park",
"part", "past", "path", "pave", "peak", "pear", "peat", "peel", "peer", "perk",
"pest", "pick", "pier", "pile", "pine", "pipe", "plan", "play", "plot", "plow",
"plum", "plus", "poem", "poet", "pole", "poll", "pond", "pool", "pope", "pork",
"port", "pose", "post", "pour", "pray", "prey", "prod", "prop", "pull", "pump",
"punt", "pure", "push", "raid", "rail", "rain", "rake", "ramp", "rare", "rate",
"read", "real", "reed", "reef", "reel", "rely", "rent", "rest", "rice", "rich",
"ride", "rift", "ring", "riot", "rise", "risk", "roam", "roar", "rode", "role",
"roll", "root", "rope", "rose", "ruin", "rule", "ruse", "rush", "rust", "rye",
"safe", "saga", "sage", "sail", "sake", "sale", "salt", "same", "sand", "sane",
"seal", "seam", "seed", "seek", "self", "sell", "send", "shed", "shin", "ship",
"shoe", "shot", "show", "silk", "sill", "sing", "sink", "site", "size", "skin",
"skip", "sky", "slab", "slam", "slap", "slim", "slip", "slot", "slow", "slug",
"snap", "snow", "soak", "sock", "sofa", "soft", "soil", "sold", "sole", "some",
"song", "soot", "soul", "span", "spit", "spot", "spur", "stem", "step", "stew",
"stop", "stub", "such", "suit", "sung", "sunk", "sure", "swan", "swam", "swap",
"tale", "tank", "tape", "task", "team", "tear", "teel", "tell", "term", "test",
"text", "than", "that", "them", "then", "they", "thin", "tide", "tile", "till",
"tilt", "time", "tiny", "tire", "toil", "toll", "tone", "took", "tool", "tore",
"torn", "tour", "town", "trap", "tray", "tree", "trim", "trip", "true", "tube",
"tuck", "tune", "turn", "tusk", "tuft", "type", "undo", "unit", "upon", "urge",
"used", "user", "vain", "vale", "vane", "vary", "vase", "vast", "veil", "very",
"vest", "view", "vile", "vine", "visa", "void", "volt", "vote", "wade", "wake",
"walk", "wall", "wand", "warm", "warp", "wart", "wave", "weak", "weld", "well",
"wept", "were", "west", "whim", "wide", "wilt", "wind", "wine", "wing", "wire",
"wiry", "wish", "wolf", "wood", "wool", "word", "wore", "work", "worm", "worn",
"wrap", "wren", "writ", "yard", "yarn", "yoke", "yore", "your", "zero", "zinc",
"zone", "zoom",
"acid", "aged", "also", "apex", "aqua", "arch", "army", "atom", "aunt", "avid", "away", "baby",
"back", "bail", "bait", "bale", "ball", "band", "bank", "barn", "base", "bath", "bead", "bean",
"bear", "beat", "beef", "beer", "bell", "best", "bill", "bird", "bite", "blew", "blob", "bloc",
"blog", "blow", "blue", "bold", "bolt", "bond", "bone", "book", "boom", "boot", "bore", "born",
"boss", "both", "bowl", "bred", "brew", "bulb", "bulk", "bull", "bump", "burn", "burp", "buzz",
"cage", "cake", "calf", "call", "calm", "camp", "cane", "card", "care", "cart", "case", "cash",
"cast", "cave", "cell", "chef", "chin", "chip", "chop", "cite", "city", "clam", "clap", "claw",
"clay", "clip", "club", "clue", "coal", "coat", "coil", "coin", "cold", "comet", "cook",
"cool", "cope", "copy", "cord", "core", "cork", "corn", "cost", "couch", "coup", "cove",
"crab", "crew", "crop", "crow", "cube", "cult", "cure", "curl", "cute", "dado", "dale", "dame",
"dare", "dark", "dart", "data", "date", "dawn", "dead", "deal", "dear", "debt", "deck", "deem",
"deer", "deft", "deny", "desk", "dial", "diet", "dirt", "disk", "dock", "doll", "dome", "door",
"dose", "dove", "down", "draw", "drew", "drop", "drum", "dual", "duel", "dune", "dunk", "dusk",
"dust", "duty", "each", "earl", "earn", "ease", "east", "edge", "else", "emit", "epic", "even",
"ever", "evil", "exam", "exit", "face", "fact", "fade", "fail", "fair", "fake", "fall", "fame",
"fare", "farm", "fast", "fate", "fawn", "fear", "feat", "feel", "felt", "fern", "fest", "file",
"fill", "film", "find", "fine", "fire", "firm", "fish", "fist", "flag", "flat", "flaw", "flea",
"flew", "flex", "flip", "flock", "flow", "foam", "foil", "fold", "folk", "fond", "font",
"food", "fool", "foot", "ford", "fork", "form", "fort", "foul", "four", "fowl", "free", "from",
"fuel", "full", "fund", "fuse", "fuss", "gale", "game", "gang", "gaze", "gear", "gene", "germ",
"gift", "gill", "give", "glad", "glow", "glue", "goal", "goat", "gold", "golf", "good", "grab",
"grad", "gram", "gray", "grew", "grey", "grid", "grin", "grip", "grow", "gulf", "gull", "gust",
"half", "hall", "halt", "hand", "hang", "hard", "hare", "harm", "harp", "have", "hawk", "head",
"heal", "heap", "heat", "heel", "held", "helm", "help", "herb", "hero", "hill", "hive", "hock",
"hold", "hole", "home", "hook", "hope", "horn", "host", "hour", "huge", "hull", "hunt", "hurt",
"icon", "idea", "idle", "inch", "into", "iris", "iron", "item", "jail", "jerk", "jest", "join",
"joke", "jolt", "jump", "just", "keen", "keep", "kelp", "kick", "kind", "king", "knot", "know",
"lace", "lack", "lake", "lamb", "lamp", "land", "lane", "last", "lava", "lawn", "lazy", "lead",
"leaf", "lean", "leap", "left", "lend", "lens", "lift", "lime", "limp", "line", "link", "lion",
"list", "live", "load", "lock", "loft", "loin", "lone", "long", "look", "loom", "loop", "lore",
"loss", "loud", "love", "luck", "lure", "lurk", "made", "mail", "main", "make", "male", "mall",
"malt", "mare", "mark", "Mars", "mast", "math", "maze", "meal", "meat", "meet", "melt", "memo",
"menu", "mere", "mesh", "mild", "mile", "mill", "mime", "mind", "mine", "mink", "mint", "mist",
"mode", "mole", "mood", "moon", "moor", "more", "moss", "most", "move", "much", "muck", "muse",
"must", "myth", "nail", "name", "navy", "neat", "neck", "need", "news", "next", "nice", "node",
"none", "norm", "nose", "note", "noun", "null", "oath", "obey", "odds", "once", "only", "open",
"oral", "oval", "oven", "over", "pace", "pack", "page", "paid", "pair", "palm", "park", "part",
"past", "path", "pave", "peak", "pear", "peat", "peel", "peer", "perk", "pest", "pick", "pier",
"pile", "pine", "pipe", "plan", "play", "plot", "plow", "plum", "plus", "poem", "poet", "pole",
"poll", "pond", "pool", "pope", "pork", "port", "pose", "post", "pour", "pray", "prey", "prod",
"prop", "pull", "pump", "punt", "pure", "push", "raid", "rail", "rain", "rake", "ramp", "rare",
"rate", "read", "real", "reed", "reef", "reel", "rely", "rent", "rest", "rice", "rich", "ride",
"rift", "ring", "riot", "rise", "risk", "roam", "roar", "rode", "role", "roll", "root", "rope",
"rose", "ruin", "rule", "ruse", "rush", "rust", "rye", "safe", "saga", "sage", "sail", "sake",
"sale", "salt", "same", "sand", "sane", "seal", "seam", "seed", "seek", "self", "sell", "send",
"shed", "shin", "ship", "shoe", "shot", "show", "silk", "sill", "sing", "sink", "site", "size",
"skin", "skip", "sky", "slab", "slam", "slap", "slim", "slip", "slot", "slow", "slug", "snap",
"snow", "soak", "sock", "sofa", "soft", "soil", "sold", "sole", "some", "song", "soot", "soul",
"span", "spit", "spot", "spur", "stem", "step", "stew", "stop", "stub", "such", "suit", "sung",
"sunk", "sure", "swan", "swam", "swap", "tale", "tank", "tape", "task", "team", "tear", "teel",
"tell", "term", "test", "text", "than", "that", "them", "then", "they", "thin", "tide", "tile",
"till", "tilt", "time", "tiny", "tire", "toil", "toll", "tone", "took", "tool", "tore", "torn",
"tour", "town", "trap", "tray", "tree", "trim", "trip", "true", "tube", "tuck", "tune", "turn",
"tusk", "tuft", "type", "undo", "unit", "upon", "urge", "used", "user", "vain", "vale", "vane",
"vary", "vase", "vast", "veil", "very", "vest", "view", "vile", "vine", "visa", "void", "volt",
"vote", "wade", "wake", "walk", "wall", "wand", "warm", "warp", "wart", "wave", "weak", "weld",
"well", "wept", "were", "west", "whim", "wide", "wilt", "wind", "wine", "wing", "wire", "wiry",
"wish", "wolf", "wood", "wool", "word", "wore", "work", "worm", "worn", "wrap", "wren", "writ",
"yard", "yarn", "yoke", "yore", "your", "zero", "zinc", "zone", "zoom",
];
// ── Public types ──────────────────────────────────────────────────────────────

Loading…
Cancel
Save