diff --git a/.claude/memory/ferroui.md b/.claude/memory/ferroui.md index c644a18..733b576 100644 --- a/.claude/memory/ferroui.md +++ b/.claude/memory/ferroui.md @@ -1,6 +1,6 @@ --- name: ferroui -description: "Every console draws in FerroUI, pinned exactly; one uniform design, shared components, all four consoles move in lockstep with every FerroUI release, screenshots read and reviewed; owner 2026-10-18" +description: "Every console draws in FerroUI, pinned exactly; one uniform design, shared components, all four consoles move in lockstep with every FerroUI release, screenshots read and reviewed, no user-visible string outside the catalogs; owner 2026-10-18 and 2026-10-19" metadata: type: project --- @@ -33,3 +33,12 @@ submodule or a path dependency (owner, 2026-10-18). orchestrator reviews the screenshots before a merge or tag. - **Theme switch:** FerroEHR's moon and sun icon button in the top bar, the choice kept in the server cookie written as `data-theme`. +- **No user-visible component string outside the catalogs** (owner, + 2026-10-19). English is the one language that ships and the family is + ready for more: a new language is a catalog file and no code change. Every + word a FerroUI component draws is a message of its Fluent catalog + (`locales/en/ferrohealth-ui.ftl`), the locale is chosen once per render + through `i18n::provide_locale` and written as ``, and a + console catalogs its own page strings the same way, held by the + stray-literal check (`leptos-ui.md` §13). No Dutch or German catalog is + added. diff --git a/.claude/rules/leptos-ui.md b/.claude/rules/leptos-ui.md index deede2d..cd31ed3 100644 --- a/.claude/rules/leptos-ui.md +++ b/.claude/rules/leptos-ui.md @@ -30,6 +30,10 @@ the Authoring Practices (`spec-adherence.md`). - **No patient identifier in a URL.** A listing that can show patient data keeps its page, size and sort in the page (`StateSource::Session`, `widget::pager::session_pager`). +- **No user-visible string outside the catalogs** (owner, 2026-10-19). Every + word a component draws itself is a message of the library's Fluent + catalog, every word a console draws is a message of the console's, and a + new language is a catalog file with no code change (§13). ## 1. Two tiers @@ -237,3 +241,48 @@ bytes, 0.1.1 518566 and 0.1.0 325387; a console's own bundle carries only the components it uses. A change that needs more raises it here, in its own pull request, with the measured size and the reason. + +## 13. Strings and locales + +The owner's ruling (2026-10-19): English is the one language that ships, +and the family is ready for more, so adding a language later is a catalog +file and no code change. No specification governs the catalog layout or +the lookup order: our own design; the message syntax is Project Fluent's. + +- **One crate, one format.** `fluent-templates` (pinned in the workspace + table) embeds every catalog under a crate's `locales/` directory at build + time with `static_loader!`, one directory per BCP 47 tag + (`locales/en/ferrohealth-ui.ftl`), on both halves, with no script and no + runtime file reader. No hand-rolled catalog format, no second crate. +- **`en` is the reference.** It is complete, every lookup falls back to it, + and a test holds every catalog under `locales/` to the full message set + (`i18n::tests`), so a new catalog file is checked with no code change. + Only `en` ships; readiness is shown by a throwaway catalog in the tests + (`tests/it/locales/qaa/`), never by a shipped translation. +- **The locale is chosen once per render.** The console builds an + `i18n::Locale` from its configuration or the request + (`Locale::negotiate` reads `Accept-Language`), hands it to + `i18n::provide_locale` at the root of its page tree on the server and in + the hydrating browser alike (a value the server decided reaches the + browser serialized, `SharedValue`), and writes `Locale::tag` as + `` (WCAG 2.2 SC 3.1.1). A page that provides none draws + English. +- **A component reads its words while the page tree is in reach.** A view + function calls the crate's lookup as it builds its view; a widget reads + every word it may draw at setup and moves the strings into its handlers + and tasks, where no owner is in reach. +- **A console catalogs its own strings the same way**: its own + `static_loader!` over its own `locales/`, looked up through + `Locale::lookup` so its words follow the same choice, and the language + switch is `view::theme_switch::locale_switch`. +- **The stray-literal check fails a string outside the catalogs.** + `scripts/checks/stray-literals.sh` reads the components for literals that + read as words (an attribute a reader sees or hears, a text node, any + literal holding words) and fails on each; a literal no reader sees as text + (a key name, a product name, SVG markup) is allowed with its reason in + `scripts/checks/stray-literals.allow`. A console runs the same script over + its own crate with a baseline that only shrinks while its strings move. +- **The layout leaves room for longer words.** No text sits in a fixed + width; the gallery's pseudo-locale (`qps`) draws every library string + accented and two fifths longer, and a change that moves a component's + layout is captured in it at 1440 and 390 px, light and dark. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f79054..cdff921 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,20 @@ jobs: persist-credentials: false - run: bash scripts/checks/comment-style.sh --all + # No string a reader sees in a component outside the catalogs + # (.claude/rules/leptos-ui.md §13). + stray-literals: + name: stray-literals + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: bash scripts/checks/stray-literals.sh crates/ferrohealth-ui/src + # Every TODO(#N) names an open issue. todo-issues: name: todo-issues @@ -430,6 +444,7 @@ jobs: - workflow-lint - shellcheck - comment-style + - stray-literals - todo-issues - contribution-licence - detect diff --git a/CHANGELOG.md b/CHANGELOG.md index 731ba7b..24eed07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,50 @@ and this project adheres to ## [Unreleased] +## [0.1.7] - 2026-10-19 + +The family is ready for more languages, with English the one that ships: +every word a component draws itself comes from a Fluent catalog, a new +language is a catalog file with no code change, and the locale is chosen +once per render and reachable by a console for ``. The +English words and the markup they draw in are unchanged. + +### Added + +- `i18n`: the locale and the catalogs. `Locale` (from a BCP 47 tag, or + negotiated from `Accept-Language` among the languages the library ships + or, with `negotiate_with`, a console's catalog holds, falling back to the + console's configured locale), + `provide_locale` and `locale` to choose it once per render and read it, + `Locale::tag` for ``, `Locale::lookup` for a console's own + catalog, and `Locale::with_catalog` for a catalog the library does not + ship. The catalogs are embedded at build time from `locales/` by + `fluent-templates` 0.15.1, on both halves and with no script; `en` is the + reference catalog every lookup falls back to (FerroHEALTH/FerroUI#112). +- `view::theme_switch::locale_switch`: the language switch, a plain form + whose choices are each named in their own language and marked with it in + `lang` (FerroHEALTH/FerroUI#112). +- `view::layout::keyed_field_row`: a row of fields with an id, for a row a + console adds, removes or finds, such as one value of a list. +- `scripts/checks/stray-literals.sh`, in CI: fails on a string a reader + sees in a component outside the catalogs (FerroHEALTH/FerroUI#112). +- The gallery's language switch on the overview's settings, and its + pseudo-locale, which draws every library string accented and two fifths + longer to check the layout (FerroHEALTH/FerroUI#112). + +### Changed + +- Every string a component draws itself is a message of + `locales/en/ferrohealth-ui.ftl`, looked up in the page's locale. The + words that were `&'static str` return values are `String` now: + `theme::Theme::label`, `theme::Density::label`, + `table::view::Step::label`, `view::refusal::Severity::word` and + `view::button::Variant::name` (FerroHEALTH/FerroUI#112). +- `view::button::label` and `widget::copy::copy_button` take their label as + anything that converts into `Oco<'static, str>`, a `&'static str` as + before or a `String` from a catalog. +- The gallery writes the locale its cookie names as ``. + ## [0.1.6] - 2026-10-19 The shared form fields every console needs so no field is built by hand: a @@ -481,7 +525,8 @@ FerroHEALTH consoles. component in every product theme, light and dark, with browser journeys, a painted-colour check and the screenshots under `docs/screenshots/`. -[Unreleased]: https://github.com/FerroHEALTH/FerroUI/compare/v0.1.6...HEAD +[Unreleased]: https://github.com/FerroHEALTH/FerroUI/compare/v0.1.7...HEAD +[0.1.7]: https://github.com/FerroHEALTH/FerroUI/compare/v0.1.6...v0.1.7 [0.1.6]: https://github.com/FerroHEALTH/FerroUI/compare/v0.1.5...v0.1.6 [0.1.5]: https://github.com/FerroHEALTH/FerroUI/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/FerroHEALTH/FerroUI/compare/v0.1.3...v0.1.4 diff --git a/Cargo.lock b/Cargo.lock index 136afed..da89d59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,8 +457,9 @@ dependencies = [ [[package]] name = "ferrohealth-ui" -version = "0.1.6" +version = "0.1.7" dependencies = [ + "fluent-templates", "leptos", "leptos_router", "serde", @@ -468,11 +469,12 @@ dependencies = [ [[package]] name = "ferrohealth-ui-gallery" -version = "0.1.6" +version = "0.1.7" dependencies = [ "axum", "console_error_panic_hook", "ferrohealth-ui", + "fluent-templates", "http", "leptos", "leptos_axum", @@ -492,6 +494,71 @@ dependencies = [ "web-sys", ] +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.21", +] + +[[package]] +name = "fluent-template-macros" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f17b7ed5b7108d57ca049e1fa7583d4fadbb71ef0e47a72dc847c1e704f37fb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unic-langid", + "walkdir", +] + +[[package]] +name = "fluent-templates" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fc3bfc211d9a9210bd7ccba798da57148e5b02c62b98b6cef09d7228f2f232" +dependencies = [ + "fluent-bundle", + "fluent-langneg", + "fluent-syntax", + "fluent-template-macros", + "intl-memoizer", + "log", + "thiserror 2.0.21", + "unic-langid", + "walkdir", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -912,6 +979,25 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "inventory" version = "0.3.25" @@ -1433,6 +1519,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro-utils" version = "0.10.0" @@ -1719,6 +1811,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -2164,6 +2262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -2410,6 +2509,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typed-builder" version = "0.23.2" @@ -2436,6 +2544,49 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.119", + "unic-langid-impl", +] + [[package]] name = "unicase" version = "2.10.0" @@ -2762,6 +2913,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index d92efeb..324ea48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "3" members = ["crates/*", "app/*"] [workspace.package] -version = "0.1.6" +version = "0.1.7" edition = "2024" rust-version = "1.97" license = "BUSL-1.1" @@ -26,6 +26,11 @@ leptos = { version = "0.8.22", default-features = false } leptos_router = { version = "0.8.17", default-features = false } leptos_meta = { version = "0.8.7", default-features = false } serde = { version = "1.0.229", features = ["derive"] } +# The catalogs of the strings the components draw: Project Fluent's message +# format, the catalogs embedded at build time from the `locales/` directory +# (the `walkdir` reader of its macro), no runtime file reader. Verified +# against crates.io on 2026-10-19. +fluent-templates = { version = "0.15.1", default-features = false, features = ["macros", "walkdir"] } # The gallery app alone: its server half and its browser half. The # wasm-bindgen CLI of the gallery build equals the wasm-bindgen crate version # (scripts/release/gallery-tools.sh); the library itself never pins it, and diff --git a/README.md b/README.md index 6abee7a..5ab15a4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ FerroFED, FerroEHR and FerroTERM. It is published on crates.io as ```toml [dependencies] -ferrohealth-ui = "=0.1.6" +ferrohealth-ui = "=0.1.7" ``` Serve `ferrohealth_ui::style::STYLESHEET` and your brand's `Brand::theme()` diff --git a/app/ferrohealth-ui-gallery/Cargo.toml b/app/ferrohealth-ui-gallery/Cargo.toml index a9b99af..fea03d0 100644 --- a/app/ferrohealth-ui-gallery/Cargo.toml +++ b/app/ferrohealth-ui-gallery/Cargo.toml @@ -25,6 +25,8 @@ path = "src/main.rs" [dependencies] ferrohealth-ui = { path = "../../crates/ferrohealth-ui" } +# The pseudo-locale's catalog, a Fluent loader over the library's English. +fluent-templates.workspace = true leptos.workspace = true leptos_meta.workspace = true leptos_router.workspace = true diff --git a/app/ferrohealth-ui-gallery/src/app.rs b/app/ferrohealth-ui-gallery/src/app.rs index bf927a8..68b98e3 100644 --- a/app/ferrohealth-ui-gallery/src/app.rs +++ b/app/ferrohealth-ui-gallery/src/app.rs @@ -8,8 +8,10 @@ //! document links the library's stylesheet and the brand's theme file, which //! the server serves from the library's constants, and carries the theme the //! request's cookie names as `data-theme` and the density as -//! `data-density`, so a page arrives in its theme before any script runs. +//! `data-density`, so a page arrives in its theme before any script runs, +//! and its locale as `lang`, the locale every component draws its words in. +use ferrohealth_ui::i18n::{Locale, provide_locale}; use ferrohealth_ui::icon::Glyph; use ferrohealth_ui::style::Brand; use ferrohealth_ui::theme::{Density, Theme}; @@ -29,6 +31,8 @@ use leptos_router::components::{Outlet, ParentRoute, Route, Router, Routes}; use leptos_router::hooks::{use_location, use_params_map}; use leptos_router::path; +use crate::locale::gallery_locale; + /// The gallery's name, as its footer and titles name it. pub const PRODUCT: &str = "FerroHEALTH UI Gallery"; @@ -50,6 +54,12 @@ pub const DENSITY_ROUTE: &str = "/density"; /// The name of the cookie the density choice is kept in. pub const DENSITY_COOKIE: &str = "gallery-density"; +/// The path of the route a language choice posts to. +pub const LOCALE_ROUTE: &str = "/locale"; + +/// The name of the cookie the language choice is kept in. +pub const LOCALE_COOKIE: &str = "gallery-locale"; + /// The id of the navigation, which the drawer button opens. const SIDEBAR_ID: &str = "sidebar"; @@ -86,6 +96,12 @@ pub fn density_of(cookies: &str) -> Density { .unwrap_or_default() } +/// The locale the `Cookie` header value `cookies` names, English for none. +#[must_use] +pub fn locale_of(cookies: &str) -> Locale { + gallery_locale(cookie(cookies, LOCALE_COOKIE).unwrap_or_default()) +} + /// The value of the cookie `name` in the `Cookie` header value `cookies`. fn cookie<'a>(cookies: &'a str, name: &str) -> Option<&'a str> { // NOTE: RFC 6265 §5.4: cookies arrive as `name=value` pairs joined by `; `, and @@ -112,9 +128,10 @@ pub fn document(options: LeptosOptions) -> impl IntoView { .to_owned(); let theme = theme_of(&cookies).key(); let density = density_of(&cookies).key(); + let lang = locale_of(&cookies).tag(); view! { - + @@ -157,6 +174,18 @@ fn density_requested() -> Density { } } +/// The tag of the locale the request names, decided as the theme is. +fn locale_requested() -> String { + #[cfg(not(target_arch = "wasm32"))] + { + locale_of(&request_cookies()).tag() + } + #[cfg(target_arch = "wasm32")] + { + Locale::english().tag() + } +} + /// The request's `Cookie` header, empty for none. #[cfg(not(target_arch = "wasm32"))] fn request_cookies() -> String { @@ -189,6 +218,8 @@ pub fn App() -> impl IntoView { provide_context(InForce(theme)); let density = *SharedValue::new_str(density_requested); provide_context(DensityInForce(density)); + let tag = SharedValue::new_str(locale_requested); + provide_locale(gallery_locale(&tag)); let toasts = provide_toasts(); view! { @@ -429,7 +460,7 @@ fn brand_shell(brand: Brand, back: String) -> AnyView { let switch = brand_switch(brand, &back).into_any(); let end = view! { <span class="fui-wide-only">{switch}</span> - <span class="fui-roomy-only">{pill(Tone::Ok, "Library v0.1.6")}</span> + <span class="fui-roomy-only">{pill(Tone::Ok, "Library v0.1.7")}</span> {operator_menu( OperatorMenu { id: "operator-menu", diff --git a/app/ferrohealth-ui-gallery/src/lib.rs b/app/ferrohealth-ui-gallery/src/lib.rs index ecd4a57..e8727bf 100644 --- a/app/ferrohealth-ui-gallery/src/lib.rs +++ b/app/ferrohealth-ui-gallery/src/lib.rs @@ -8,13 +8,15 @@ //! It is the library's visual review surface and its proof in CI: the //! journeys drive it in a browser and its screenshots show every page in //! every brand. [`app`] is the page tree, [`pages`] the pages, [`data`] the -//! synthetic rows and texts they show, and [`server`] the HTTP service with +//! synthetic rows and texts they show, [`locale`] English and the +//! pseudo-locale, and [`server`] the HTTP service with //! the security headers a console sends. Nothing it shows is clinical data: //! every row is invented for the gallery. No specification governs the //! gallery: our own design. pub mod app; pub mod data; +pub mod locale; pub mod pages; #[cfg(not(target_arch = "wasm32"))] diff --git a/app/ferrohealth-ui-gallery/src/locale.rs b/app/ferrohealth-ui-gallery/src/locale.rs new file mode 100644 index 0000000..9836c02 --- /dev/null +++ b/app/ferrohealth-ui-gallery/src/locale.rs @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Cadasto B.V. +// SPDX-License-Identifier: BUSL-1.1 + +//! The gallery's locales: English and a pseudo-locale. +//! +//! English is the library's one catalog. The pseudo-locale draws every +//! library string accented and lengthened, so a page shows where a longer +//! translation would overflow or wrap. +//! +//! The pseudo-locale is the gallery's own and never ships: it is a catalog +//! the gallery hands the library with [`Locale::with_catalog`], drawing on +//! the library's English catalog. Its tag is `qps`, a private-use +//! language subtag (BCP 47, `qaa` to `qtz`), so no catalog the library ships +//! can serve it. No specification governs pseudo-localization: our own +//! design, after the common practice of accenting and padding each string. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::LazyLock; + +use ferrohealth_ui::i18n::{Locale, library_catalog}; +use fluent_templates::fluent_bundle::FluentValue; +use fluent_templates::{LanguageIdentifier, Loader}; + +/// The tag of the pseudo-locale. +pub const PSEUDO_TAG: &str = "qps"; + +/// The pseudo-locale's language. +static PSEUDO_LANGUAGE: LazyLock<LanguageIdentifier> = + LazyLock::new(|| fluent_templates::langid!("qps")); + +/// The pseudo-locale's catalog: the library's English, stretched. +#[derive(Debug, Clone, Copy)] +pub struct Pseudo; + +/// The one pseudo catalog the gallery hands the library. +static PSEUDO: Pseudo = Pseudo; + +impl Loader for Pseudo { + fn lookup_complete( + &self, + lang: &LanguageIdentifier, + text_id: &str, + args: Option<&HashMap<Cow<'static, str>, FluentValue>>, + ) -> String { + self.try_lookup_complete(lang, text_id, args) + .unwrap_or_else(|| text_id.to_owned()) + } + + fn try_lookup_complete( + &self, + lang: &LanguageIdentifier, + text_id: &str, + args: Option<&HashMap<Cow<'static, str>, FluentValue>>, + ) -> Option<String> { + if *lang != *PSEUDO_LANGUAGE { + return None; + } + library_catalog() + .try_lookup_complete(&fluent_templates::langid!("en"), text_id, args) + .map(|english| stretch(&english)) + } + + fn locales(&self) -> Box<dyn Iterator<Item = &LanguageIdentifier> + '_> { + Box::new(std::iter::once(&*PSEUDO_LANGUAGE)) + } +} + +/// `english` accented letter by letter and padded by two fifths of its +/// length (at least three), in brackets, so a cut end shows. +#[must_use] +pub fn stretch(english: &str) -> String { + let accented: String = english.chars().map(accent).collect(); + let length = english.chars().count(); + let pad = (length.saturating_mul(2).div_ceil(5)).max(3); + format!("[{accented} {}]", "~".repeat(pad)) +} + +/// The accented look-alike of `letter`, the letter itself when it has none. +const fn accent(letter: char) -> char { + match letter { + 'a' => 'á', + 'c' => 'ç', + 'e' => 'é', + 'i' => 'í', + 'n' => 'ñ', + 'o' => 'ö', + 'u' => 'ü', + 'y' => 'ý', + 'A' => 'Å', + 'C' => 'Ç', + 'E' => 'É', + 'I' => 'Î', + 'N' => 'Ñ', + 'O' => 'Ö', + 'U' => 'Ü', + 'Y' => 'Ý', + other => other, + } +} + +/// The locale the tag `tag` names among the gallery's: the pseudo-locale +/// for its tag, English for any other. +#[must_use] +pub fn gallery_locale(tag: &str) -> Locale { + if tag == PSEUDO_TAG { + Locale::parse(PSEUDO_TAG) + .map_or_else(|_| Locale::english(), |locale| locale.with_catalog(&PSEUDO)) + } else { + Locale::english() + } +} + +/// The locales the gallery's language switch offers. +#[must_use] +pub fn choices() -> Vec<Locale> { + vec![Locale::english(), gallery_locale(PSEUDO_TAG)] +} + +#[cfg(test)] +mod tests { + use super::{PSEUDO_TAG, gallery_locale, stretch}; + + #[test] + fn the_pseudo_locale_accents_and_lengthens_every_string() { + assert_eq!("[Çáñçél ~~~]", stretch("Cancel")); + assert_eq!("[Págé 1 öf 2 ~~~~~]", stretch("Page 1 of 2")); + assert_eq!(PSEUDO_TAG, gallery_locale(PSEUDO_TAG).tag()); + assert_eq!( + "en", + gallery_locale("sepia").tag(), + "an unknown tag is English" + ); + } +} diff --git a/app/ferrohealth-ui-gallery/src/pages/overview.rs b/app/ferrohealth-ui-gallery/src/pages/overview.rs index 2cf1e7e..5578537 100644 --- a/app/ferrohealth-ui-gallery/src/pages/overview.rs +++ b/app/ferrohealth-ui-gallery/src/pages/overview.rs @@ -19,7 +19,7 @@ use ferrohealth_ui::view::surface::{ stack, stat_card, stat_card_marked, stat_grid, well, }; use ferrohealth_ui::view::tabs::{KeyedTab, Tab, keyed_tabs, link_tabs}; -use ferrohealth_ui::view::theme_switch::{density_switch, theme_switch}; +use ferrohealth_ui::view::theme_switch::{density_switch, locale_switch, theme_switch}; use ferrohealth_ui::view::time::timestamp; use ferrohealth_ui::widget::segmented::segmented; use ferrohealth_ui::widget::tabs::{Panel, tabs}; @@ -216,7 +216,7 @@ fn facts() -> AnyView { "Definition grid", definitions(vec![ ("Product", "FerroHEALTH UI".into_any()), - ("Version", "0.1.6".into_any()), + ("Version", "0.1.7".into_any()), ("Commit", view! { <code>"0123456789ab"</code> }.into_any()), ("Profile", or_none(String::new())), ("Licence", "Business Source License 1.1".into_any()), @@ -530,7 +530,7 @@ fn states() -> AnyView { .into_any() } -/// The theme and density switches, each a plain form that posts. +/// The theme, density and language switches, each a plain form that posts. fn settings() -> AnyView { let theme = use_context::<crate::app::InForce>() .map_or(ferrohealth_ui::theme::Theme::System, |in_force| in_force.0); @@ -553,7 +553,13 @@ fn settings() -> AnyView { "settings-density", "Density", None, - density_switch(density, String::from(crate::app::DENSITY_ROUTE), back), + density_switch(density, String::from(crate::app::DENSITY_ROUTE), back.clone()), + )} + {field( + "settings-language", + "Language", + Some("The pseudo-locale draws every library string accented and longer, to check the layout."), + locale_switch(&crate::locale::choices(), String::from(crate::app::LOCALE_ROUTE), back), )} }), ) diff --git a/app/ferrohealth-ui-gallery/src/server.rs b/app/ferrohealth-ui-gallery/src/server.rs index c44f354..caf1b9c 100644 --- a/app/ferrohealth-ui-gallery/src/server.rs +++ b/app/ferrohealth-ui-gallery/src/server.rs @@ -31,7 +31,8 @@ use leptos_axum::LeptosRoutes as _; use tower_http::set_header::SetResponseHeaderLayer; use crate::app::{ - DENSITY_COOKIE, DENSITY_ROUTE, STYLESHEET as STYLESHEET_PATH, THEME_COOKIE, THEME_ROUTE, + DENSITY_COOKIE, DENSITY_ROUTE, LOCALE_COOKIE, LOCALE_ROUTE, STYLESHEET as STYLESHEET_PATH, + THEME_COOKIE, THEME_ROUTE, }; /// The name cargo-leptos gives the site bundle's files under `pkg/`. @@ -60,6 +61,7 @@ pub fn router(options: LeptosOptions) -> axum::Router { .route("/themes/{file}", get(theme_file)) .route(THEME_ROUTE, post(choose_theme)) .route(DENSITY_ROUTE, post(choose_density)) + .route(LOCALE_ROUTE, post(choose_locale)) .leptos_routes_with_context(&options, routes, provide_request_nonce, { let options = options.clone(); move || crate::app::document(options.clone()) @@ -170,6 +172,31 @@ async fn choose_density(Form(choice): Form<DensityChoice>) -> Response { kept(DENSITY_COOKIE, density.key(), &choice.back) } +/// What a language choice posts. +#[derive(Debug, serde::Deserialize)] +struct LocaleChoice { + /// The tag of the locale chosen. + locale: String, + /// The page to return to. + back: String, +} + +/// Keeps the locale a plain form post chose in its cookie and sends the +/// browser back to the page it came from. +async fn choose_locale(Form(choice): Form<LocaleChoice>) -> Response { + let known = crate::locale::choices() + .iter() + .any(|locale| locale.tag() == choice.locale); + if !known { + return ( + StatusCode::BAD_REQUEST, + "the locale is not one the gallery offers", + ) + .into_response(); + } + kept(LOCALE_COOKIE, &choice.locale, &choice.back) +} + /// The redirect to `back` that keeps `value` in the cookie `name`. fn kept(name: &str, value: &str, back: &str) -> Response { let cookie = format!("{name}={value}; Path=/; SameSite=Lax; HttpOnly; Max-Age=31536000"); diff --git a/app/ferrohealth-ui-gallery/tests/it/screenshots.rs b/app/ferrohealth-ui-gallery/tests/it/screenshots.rs index d610ac5..ff3ac93 100644 --- a/app/ferrohealth-ui-gallery/tests/it/screenshots.rs +++ b/app/ferrohealth-ui-gallery/tests/it/screenshots.rs @@ -28,6 +28,13 @@ use crate::stack::Gallery; /// The environment variable that opts in to the capture. const SCREENSHOTS_GATE: &str = "FERROUI_SCREENSHOTS"; +/// The environment variable that opts in to the pseudo-locale capture, +/// which checks the layout of longer words once and is never committed. +const PSEUDO_GATE: &str = "FERROUI_PSEUDO"; + +/// The folder of the pseudo-locale capture, from the workspace root. +const PSEUDO_IMAGES: &str = "target/pseudo-screenshots"; + /// The folder of the screenshots, from the workspace root. pub(crate) const IMAGES: &str = "docs/screenshots"; @@ -104,8 +111,13 @@ pub(crate) fn root() -> PathBuf { /// The file of `image` in the theme `theme`. fn file(image: &str, theme: &str) -> PathBuf { + file_in(IMAGES, image, theme) +} + +/// The file of `image` in the theme `theme` under `folder`. +fn file_in(folder: &str, image: &str, theme: &str) -> PathBuf { let suffix = if theme == "dark" { "-dark" } else { "" }; - root().join(IMAGES).join(format!("{image}{suffix}.png")) + root().join(folder).join(format!("{image}{suffix}.png")) } /// The part of `Page.getLayoutMetrics` the capture reads. @@ -138,13 +150,15 @@ async fn content_height(browser: &Browser) -> Result<u32, Box<dyn Error>> { .unwrap_or(TALLEST)) } -/// Loads `shot` in `theme` and writes it at the page's full height. +/// Loads `shot` in `theme` and writes it at the page's full height under +/// `folder`. async fn capture( browser: &Browser, gallery: &Gallery, shot: &Shot, theme: &str, (width, suffix): (u32, &str), + folder: &str, ) -> Result<(), Box<dyn Error>> { window(browser, width, HEIGHT).await?; browser.goto(&gallery.url(&shot.path)).await?; @@ -167,7 +181,7 @@ async fn capture( } let png = browser.driver().screenshot_as_png().await?; window(browser, WIDTH, HEIGHT).await?; - let path = file(&format!("{}{suffix}", shot.image), theme); + let path = file_in(folder, &format!("{}{suffix}", shot.image), theme); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -206,7 +220,48 @@ async fn every_page_is_captured_in_every_brand_light_and_dark() -> Result<(), Bo .await?; for shot in shots() { for width in WIDTHS { - capture(browser, &gallery, &shot, theme, width).await?; + capture(browser, &gallery, &shot, theme, width, IMAGES).await?; + } + } + } + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn one_brand_is_captured_in_the_pseudo_locale_light_and_dark() -> Result<(), Box<dyn Error>> { + if !std::env::var(PSEUDO_GATE).is_ok_and(|value| value == "1") { + return Ok(()); + } + let gallery = Gallery::start().await?; + with_browser(async |browser| { + window(browser, WIDTH, HEIGHT).await?; + for theme in ["light", "dark"] { + browser.goto(&gallery.url("/")).await?; + browser.driver().delete_all_cookies().await?; + for (name, value) in [ + ("gallery-theme", theme), + ("gallery-locale", ferrohealth_ui_gallery::locale::PSEUDO_TAG), + ] { + let mut cookie = Cookie::new(name, value); + cookie.set_path("/"); + browser.driver().add_cookie(cookie).await?; + } + browser + .driver() + .cdp() + .send_raw( + "Emulation.setEmulatedMedia", + json!({ "features": [{ "name": "prefers-color-scheme", "value": theme }] }), + ) + .await?; + for shot in shots() + .into_iter() + .filter(|shot| shot.image.starts_with(Brand::Bridge.key())) + { + for width in WIDTHS { + capture(browser, &gallery, &shot, theme, width, PSEUDO_IMAGES).await?; } } } diff --git a/crates/ferrohealth-ui/Cargo.toml b/crates/ferrohealth-ui/Cargo.toml index 2e4d4db..484e4c4 100644 --- a/crates/ferrohealth-ui/Cargo.toml +++ b/crates/ferrohealth-ui/Cargo.toml @@ -14,10 +14,10 @@ publish = true readme = "README.md" keywords = ["leptos", "ui", "components", "design-system", "accessibility"] categories = ["gui", "wasm", "web-programming"] -# The stylesheet, the brand themes and the vendored icon outlines travel with +# The stylesheet, the brand themes, the string catalogs and the vendored icon outlines travel with # the crate, so a console reads them from the registry checkout or from the # constants of `ferrohealth_ui::style`. -include = ["src/**", "css/**", "themes/**", "vendor/**", "tests/**", "README.md"] +include = ["src/**", "css/**", "themes/**", "locales/**", "vendor/**", "tests/**", "README.md"] # No render-mode feature: the library compiles under `csr`, `ssr` and # `hydrate` alike, whichever the console's own leptos dependency turns on @@ -26,6 +26,7 @@ include = ["src/**", "css/**", "themes/**", "vendor/**", "tests/**", "README.md" leptos.workspace = true leptos_router.workspace = true serde.workspace = true +fluent-templates.workspace = true # The widgets' browser code, compiled for the browser alone. Each asks for # the lowest release leptos 0.8.22 resolves with (web-sys 0.3.85 pins diff --git a/crates/ferrohealth-ui/README.md b/crates/ferrohealth-ui/README.md index bcb24b4..01fbfa5 100644 --- a/crates/ferrohealth-ui/README.md +++ b/crates/ferrohealth-ui/README.md @@ -11,7 +11,7 @@ Every pairing of every theme is held to WCAG 2.2 AA by the crate's tests. ```toml [dependencies] -ferrohealth-ui = "=0.1.6" +ferrohealth-ui = "=0.1.7" ``` Pin the version exactly: a console moves its pin after looking at its own @@ -78,6 +78,34 @@ the operator menu's `extra`. No component writes a `style` attribute or a `<style>` element, defines a server function, fetches, or touches browser storage. +## Locales + +Every word a component draws itself (a pager step, a dialog's Cancel, the +theme toggle's name, an empty listing's range) comes from a Fluent catalog +under `locales/`, one directory per language. English is the one catalog +that ships, and every lookup falls back to it; a language joins as one +catalog file, with no code change. Choose the locale once per render, at the +root of the page tree, on the server and in the hydrating browser alike, and +write it as `<html lang>`: + +```rust +use ferrohealth_ui::i18n::{Locale, locale, provide_locale}; +use leptos::prelude::*; + +let lang = Owner::new().with(|| { + // From the configuration, or `Locale::negotiate` over `Accept-Language`. + provide_locale(Locale::parse("en-GB").unwrap_or_default()); + locale().tag() +}); +assert_eq!(lang, "en-GB"); +``` + +A page that provides no locale draws English. Catalog the console's own +strings the same way: a `fluent_templates::static_loader!` over the +console's own `locales/`, looked up with `Locale::lookup`, so the console's +words follow the same choice. `view::theme_switch::locale_switch` is the +language switch, each choice named in its own language. + ## Licence Business Source License 1.1, Licensor Cadasto B.V., converting to Apache 2.0 diff --git a/crates/ferrohealth-ui/css/ferrohealth-ui.css b/crates/ferrohealth-ui/css/ferrohealth-ui.css index 4dba998..6461564 100644 --- a/crates/ferrohealth-ui/css/ferrohealth-ui.css +++ b/crates/ferrohealth-ui/css/ferrohealth-ui.css @@ -725,6 +725,12 @@ flex: 1 1 auto; } + /* The button keeps its word on one line; the input gives way. */ + .fui-search .fui-button { + flex: none; + white-space: nowrap; + } + .fui-topbar-end { display: flex; min-width: 0; @@ -2378,6 +2384,14 @@ gap: var(--space-tight); } + /* The size choices wrap together, never one alone under the others. */ + .fui-pager-size-choices { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--space-tight); + } + .fui-pager > [role="status"] { justify-self: start; } diff --git a/crates/ferrohealth-ui/locales/en/ferrohealth-ui.ftl b/crates/ferrohealth-ui/locales/en/ferrohealth-ui.ftl new file mode 100644 index 0000000..67d59b3 --- /dev/null +++ b/crates/ferrohealth-ui/locales/en/ferrohealth-ui.ftl @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Cadasto B.V. +# SPDX-License-Identifier: BUSL-1.1 + +# The English catalog of the strings the FerroHEALTH UI components draw +# themselves: the reference catalog, complete, and the one every lookup +# falls back to. Each message is one `Text` of the crate's `i18n` module; +# a catalog of another language holds the same messages in a directory +# named by its BCP 47 tag beside this one. + +## The locale + +language-name = English +language-switch = Language + +## The shell + +skip-to-page = Skip to the page +open-menu = Menu +close = Close +footer-version = v{ $version } +footer-about = About +sign-out = Sign out +find = Find +breadcrumb = Breadcrumb +none-value = none + +## Themes and densities + +theme-light = Light +theme-dark = Dark +theme-system = System +theme-switch = Theme +theme-to-dark = Switch to the dark theme +theme-to-light = Switch to the light theme +density-comfortable = Comfortable +density-compact = Compact +density-switch = Density + +## Buttons and dialogs + +variant-primary = Primary +variant-secondary = Secondary +variant-danger = Danger +variant-destructive = Destructive +variant-ghost = Ghost +cancel = Cancel +toast-region = Notices +toast-dismiss = Dismiss +help-show = Explain these parameters +help-hide = Hide help +combobox-no-match = No match + +## Sign-in + +sign-in-username = Username +sign-in-password = Password +sign-in-submit = Sign in +sign-in-or = or +sign-in-none = No sign-in method is available: the console has none configured. + +## Tables and paging + +step-first = First +step-previous = Previous +step-next = Next +step-last = Last +step-unavailable = (unavailable) +rows-per-page = Rows per page +page-of = Page { $page } of { $pages } +range = { $first }-{ $last } of { $total } +range-empty = No rows +range-of = { $first }-{ $last } of { $total } { $noun } +range-of-empty = No { $nouns } +open-range-of = { $nouns } { $first } to { $last } +partial-note = The server counted { $stated } { $noun }, and this listing holds { $held }. +range-with-note = { $range }. { $note } +rows = + { $count -> + [one] row + *[other] rows + } +rows-opening = Rows +change-setting = Setting +change-before = Before +change-after = After +change-removed = removed +change-added = added +change-not-set = not set +change-none = Nothing changes. + +## Refusals + +severity-fatal = fatal +severity-error = error +severity-warning = warning +severity-information = information +issue-no-text = The issue carries no text. +issue-place = at +issues-left-out = + { $count -> + [one] { $count } information issue with no text left out. + *[other] { $count } information issues with no text left out. + } +refusal-status = { $status }: { $summary } +refusal-no-answer = No answer: { $summary } +refusal-evidence = What the server sent +timestamp-utc = { $date } { $time } UTC + +## Charts + +chart-no-value = No value +chart-no-value-cell = no value +chart-as-table = { $chart }, as a table +chart-x = X +chart-series = Series + +## Documents and uploads + +document-highlighted = Highlighted +document-raw = Raw +document-rendered = Rendered +document-format = Format +document-view = View +document-copy = Copy +document-highlighted-region = { $document }, highlighted +document-raw-region = { $document }, raw +copy-copied = Copied +copy-said-copied = Copied to the clipboard. +copy-said-refused = Copy failed: the browser refused the clipboard. +copy-refused = The browser refused the clipboard. +upload-drop-invite = Drop a file here, or +upload-drop-over = Drop to read the file +upload-rule = { $kinds } files, up to { $limit }. +upload-read = { $file } read; check the text below before you send it. +upload-busy = Uploading… +upload-refused-kind = { $file } was not read: the field takes { $kinds } files. +upload-refused-size = { $file } was not read: it is { $size } and the field takes up to { $limit }. +upload-unreadable = { $file } was not read: the browser could not read it. +size-bytes = + { $count -> + [one] { $count } byte + *[other] { $count } bytes + } +size-kib = { $count } KiB +size-mib = { $count } MiB diff --git a/crates/ferrohealth-ui/src/i18n.rs b/crates/ferrohealth-ui/src/i18n.rs new file mode 100644 index 0000000..34f1b5e --- /dev/null +++ b/crates/ferrohealth-ui/src/i18n.rs @@ -0,0 +1,789 @@ +// SPDX-FileCopyrightText: Cadasto B.V. +// SPDX-License-Identifier: BUSL-1.1 + +//! The locale a page renders in, and the catalogs of the strings the +//! components draw themselves. +//! +//! Every word a component draws on its own (a pager step, a dialog's +//! cancel button, the theme toggle's name, an empty table's range) is a +//! message of a Fluent catalog under the crate's `locales/` directory, one +//! directory per language (`locales/en/ferrohealth-ui.ftl`). The catalogs +//! are embedded at build time by `fluent_templates::static_loader!`, which +//! reads the directory, so a language joins as one catalog file and no code +//! changes. English (`en`) is the reference catalog: complete, and the one +//! every lookup falls back to. +//! +//! A console chooses the locale once per render: it builds a [`Locale`] from +//! its configuration or the request and hands it to [`provide_locale`] at +//! the root of its page tree, on the server and in the hydrating browser +//! alike, and writes [`Locale::tag`] as `<html lang>` (WCAG 2.2 SC 3.1.1). +//! A component reads it with [`locale`], and a page that provides none +//! renders in English. A console catalogs its own page strings the same +//! way: its own `static_loader!` over its own `locales/`, looked up through +//! [`Locale::lookup`] so its words follow the same choice. +//! +//! No specification governs the catalog layout or the lookup order: our own +//! design. The message syntax is Project Fluent's +//! (<https://projectfluent.org/fluent/guide/>). + +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt; + +use fluent_templates::fluent_bundle::FluentValue; +use fluent_templates::{LanguageIdentifier, Loader, StaticLoader}; +use leptos::prelude::*; + +fluent_templates::static_loader! { + static CATALOG = { + locales: "./locales", + fallback_language: "en", + // NOTE: Fluent's guide, "Bidirectionality": the isolation marks around a + // placeable are for mixed-direction text; the English catalog draws none. + customise: |bundle| bundle.set_use_isolating(false), + }; +} + +/// The library's catalogs, every language it ships, so a catalog of a +/// console's can draw from them (a pseudo-locale over the English one). +#[must_use] +pub fn library_catalog() -> &'static StaticLoader { + &CATALOG +} + +/// The language every lookup falls back to, and the one a page that +/// provides no locale renders in. +fn english() -> LanguageIdentifier { + fluent_templates::langid!("en") +} + +/// A value a message's placeable takes: a word or a number, which a +/// catalog may select a plural form by. +#[derive(Debug, Clone, PartialEq)] +pub enum Arg { + /// Text, written as it stands. + Text(String), + /// A number, written without grouping and selectable by plural rule. + Number(f64), +} + +impl From<&str> for Arg { + fn from(text: &str) -> Self { + Self::Text(text.to_owned()) + } +} + +impl From<String> for Arg { + fn from(text: String) -> Self { + Self::Text(text) + } +} + +impl From<u32> for Arg { + fn from(number: u32) -> Self { + Self::Number(f64::from(number)) + } +} + +impl From<i32> for Arg { + fn from(number: i32) -> Self { + Self::Number(f64::from(number)) + } +} + +impl From<usize> for Arg { + fn from(number: usize) -> Self { + // NOTE: no specification governs this: our own design; a count past u32 is + // written as text, which a plural selector reads as its default form. + u32::try_from(number).map_or_else(|_| Self::Text(number.to_string()), Self::from) + } +} + +impl From<u64> for Arg { + fn from(number: u64) -> Self { + u32::try_from(number).map_or_else(|_| Self::Text(number.to_string()), Self::from) + } +} + +impl From<f64> for Arg { + fn from(number: f64) -> Self { + Self::Number(number) + } +} + +impl Arg { + /// The value as Fluent formats it. + fn fluent(&self) -> FluentValue<'static> { + match self { + Self::Text(text) => FluentValue::from(text.clone()), + Self::Number(number) => FluentValue::from(*number), + } + } +} + +/// The arguments of a lookup as the Fluent loader takes them. +fn fluent_args(args: &[(&'static str, Arg)]) -> HashMap<Cow<'static, str>, FluentValue<'static>> { + args.iter() + .map(|(name, value)| (Cow::Borrowed(*name), value.fluent())) + .collect() +} + +/// The error a tag that is no language identifier parses to. +type TagError = <LanguageIdentifier as std::str::FromStr>::Err; + +/// A tag that names no language (BCP 47). +#[derive(Debug)] +pub struct UnknownLanguage { + tag: String, + source: TagError, +} + +impl fmt::Display for UnknownLanguage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "the tag {:?} is no language identifier", self.tag) + } +} + +impl std::error::Error for UnknownLanguage { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +/// The locale a page renders in: a language, the library catalog that +/// serves it, and the console's catalogs for a language the library does +/// not ship. +#[derive(Clone)] +pub struct Locale { + language: LanguageIdentifier, + served: Option<LanguageIdentifier>, + supplement: Option<&'static (dyn Loader + Send + Sync)>, +} + +impl fmt::Debug for Locale { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Locale") + .field("language", &self.language.to_string()) + .field("served", &self.served.as_ref().map(ToString::to_string)) + .field("supplement", &self.supplement.is_some()) + .finish() + } +} + +impl PartialEq for Locale { + fn eq(&self, other: &Self) -> bool { + self.language == other.language + } +} + +impl Default for Locale { + fn default() -> Self { + Self::of(english()) + } +} + +impl Locale { + /// English, the reference locale. + #[must_use] + pub fn english() -> Self { + Self::default() + } + + /// The locale of the language `language`, served by the library's + /// catalog that matches it best: its own, or the catalog of its bare + /// language (`en` serves `en-GB`). + fn of(language: LanguageIdentifier) -> Self { + let mut shipped: Vec<&LanguageIdentifier> = CATALOG.locales().collect(); + shipped.sort_by_key(ToString::to_string); + let served = shipped + .iter() + .find(|each| **each == &language) + .or_else(|| { + shipped + .iter() + .find(|each| each.matches(&language, true, false)) + }) + .map(|each| (*each).clone()); + Self { + language, + served, + supplement: None, + } + } + + /// The locale the BCP 47 tag `tag` names (`en`, `en-GB`). + /// + /// # Errors + /// + /// [`UnknownLanguage`] when `tag` is no language identifier. + pub fn parse(tag: &str) -> Result<Self, UnknownLanguage> { + tag.parse::<LanguageIdentifier>() + .map(Self::of) + .map_err(|source| UnknownLanguage { + tag: tag.to_owned(), + source, + }) + } + + /// The best locale for the `Accept-Language` header value `header` + /// among the languages the library ships, English when none matches + /// (RFC 9110 §12.5.4). + #[must_use] + pub fn negotiate(header: &str) -> Self { + Self::ranked(header) + .into_iter() + .map(Self::of) + .find(|locale| locale.served.is_some()) + .unwrap_or_default() + } + + /// The best locale for the `Accept-Language` header value `header` + /// among the languages the console's `catalog` holds, and `fallback`, + /// the console's configured locale, when none matches. + /// + /// The console's catalog names the languages the console ships; the + /// library draws its own words in the same language, or in English + /// where it has no catalog for it. + #[must_use] + pub fn negotiate_with(header: &str, catalog: &dyn Loader, fallback: Self) -> Self { + let held: Vec<&LanguageIdentifier> = catalog.locales().collect(); + Self::ranked(header) + .into_iter() + .find(|language| { + held.iter() + .any(|each| *each == language || each.matches(language, true, false)) + }) + .map_or(fallback, Self::of) + } + + /// The language ranges of the `Accept-Language` value `header` that are + /// language identifiers with a weight above zero, best first and in + /// their order within a weight (RFC 9110 §12.5.4). + fn ranked(header: &str) -> Vec<LanguageIdentifier> { + let mut ranked: Vec<(u16, usize, LanguageIdentifier)> = header + .split(',') + .enumerate() + .filter_map(|(order, range)| { + let mut parts = range.split(';'); + let tag = parts.next()?.trim(); + let weight = parts + .find_map(|part| part.trim().strip_prefix("q=")) + .map_or(Some(1000), quality)?; + // NOTE: RFC 9110 §12.5.4: a range that is no language identifier, + // or the wildcard, names no catalog to choose and is passed over. + let language = tag.parse::<LanguageIdentifier>().ok()?; + (weight > 0).then_some((weight, order, language)) + }) + .collect(); + ranked.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + ranked + .into_iter() + .map(|(_, _, language)| language) + .collect() + } + + /// This locale, with the console's `catalog` consulted for a message + /// the library has no catalog for in this language, before English. + /// + /// A language reaches the consoles as a library catalog first; this is + /// for a catalog the library does not ship, such as a pseudo-locale the + /// gallery draws to check the layout, or a test's throwaway language. + #[must_use] + pub fn with_catalog(mut self, catalog: &'static (dyn Loader + Send + Sync)) -> Self { + self.supplement = Some(catalog); + self + } + + /// The BCP 47 tag of the locale, as `<html lang>` carries it. + #[must_use] + pub fn tag(&self) -> String { + self.language.to_string() + } + + /// The languages the library ships a catalog for, sorted by tag. + #[must_use] + pub fn shipped() -> Vec<String> { + let mut tags: Vec<String> = CATALOG.locales().map(ToString::to_string).collect(); + tags.sort(); + tags + } + + /// The message `id` of the console's `catalog` in this locale, falling + /// back as the catalog's own fallback language says, and the id itself + /// when no catalog holds it, which the console's tests keep from + /// happening. + #[must_use] + pub fn lookup(&self, catalog: &dyn Loader, id: &str) -> String { + self.lookup_with(catalog, id, &[]) + } + + /// [`Locale::lookup`] with the placeables `args` filled in. + #[must_use] + pub fn lookup_with( + &self, + catalog: &dyn Loader, + id: &str, + args: &[(&'static str, Arg)], + ) -> String { + let args = fluent_args(args); + catalog + .try_lookup_complete(&self.language, id, Some(&args)) + .unwrap_or_else(|| missing(id)) + } + + /// The library's message `id` in this locale, with `args` filled in. + // NOTE: the Rust reference, `inline` attribute: one copy serves every component, + // so the lookup is not repeated in each view that draws a word. + #[inline(never)] + fn library(&self, id: &str, args: &[(&'static str, Arg)]) -> String { + let args = fluent_args(args); + // NOTE: no specification governs this: our own design; a message a catalog + // lacks, or cannot format, is looked for in the next one, ending in English. + self.served + .as_ref() + .and_then(|served| CATALOG.lookup_single_language(served, id, Some(&args)).ok()) + .or_else(|| { + self.supplement.and_then(|catalog| { + catalog.try_lookup_complete(&self.language, id, Some(&args)) + }) + }) + .or_else(|| { + CATALOG + .lookup_single_language(&english(), id, Some(&args)) + .ok() + }) + .unwrap_or_else(|| missing(id)) + } +} + +/// The text drawn for the message `id` no catalog holds: the id itself, +/// never nothing, with a warning in the log. +fn missing(id: &str) -> String { + leptos::logging::warn!("no catalog holds the message {id}"); + id.to_owned() +} + +/// The weight of an `Accept-Language` quality value in thousandths, or +/// `None` for one that is no quality value (RFC 9110 §12.4.2). +fn quality(value: &str) -> Option<u16> { + let value = value.trim(); + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let thousandths = format!("{fraction:0<3}").parse::<u16>().ok()?; + match whole { + "0" => Some(thousandths), + "1" if thousandths == 0 => Some(1000), + _ => None, + } +} + +/// Makes `locale` the locale of the page tree under the calling component: +/// call it once, at the root, with the same locale on the server and in the +/// hydrating browser, so both halves draw the same words. +pub fn provide_locale(locale: Locale) { + provide_context(locale); +} + +/// The locale of the page being drawn, English when the console provided +/// none. +#[must_use] +pub fn locale() -> Locale { + // NOTE: no specification governs this: our own design; a page that names no + // locale, or a string rendered outside a page tree, draws the reference one. + use_context::<Locale>().unwrap_or_default() +} + +/// Declares the library's messages: each variant names one message of the +/// catalog by its id. +macro_rules! texts { + ($($(#[$meta:meta])+ $name:ident = $id:literal,)+) => { + /// A string a component draws itself, by its message in the catalog. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum Text { + $($(#[$meta])+ $name,)+ + } + + impl Text { + /// Every message, in declaration order. + #[cfg(test)] + pub(crate) const ALL: &[Self] = &[$(Self::$name,)+]; + + /// The message's id in the catalog. + pub(crate) const fn id(self) -> &'static str { + match self { + $(Self::$name => $id,)+ + } + } + } + }; +} + +texts! { + /// The name of a catalog's language in that language. + LanguageName = "language-name", + /// The accessible name of the language switch. + LanguageSwitch = "language-switch", + /// The link that skips the chrome to the page. + SkipToPage = "skip-to-page", + /// The name of the button that opens the navigation drawer. + OpenMenu = "open-menu", + /// A button that closes the drawer or a side panel. + Close = "close", + /// The product's version in the footer: `$version`. + FooterVersion = "footer-version", + /// The footer's link to the About page. + FooterAbout = "footer-about", + /// The operator menu's sign-out button. + SignOut = "sign-out", + /// The search box's button. + Find = "find", + /// The accessible name of the breadcrumb trail. + Breadcrumb = "breadcrumb", + /// A definition whose value is absent. + NoneValue = "none-value", + /// The light theme. + ThemeLight = "theme-light", + /// The dark theme. + ThemeDark = "theme-dark", + /// The theme the operating system asks for. + ThemeSystem = "theme-system", + /// The accessible name of the theme switch. + ThemeSwitch = "theme-switch", + /// The toggle's button to the dark theme. + ThemeToDark = "theme-to-dark", + /// The toggle's button to the light theme. + ThemeToLight = "theme-to-light", + /// The default density. + DensityComfortable = "density-comfortable", + /// The compact density. + DensityCompact = "density-compact", + /// The accessible name of the density switch. + DensitySwitch = "density-switch", + /// The primary button variant, as the gallery names it. + VariantPrimary = "variant-primary", + /// The secondary button variant. + VariantSecondary = "variant-secondary", + /// The danger button variant. + VariantDanger = "variant-danger", + /// The destructive button variant. + VariantDestructive = "variant-destructive", + /// The ghost button variant. + VariantGhost = "variant-ghost", + /// A dialog's button that leaves without acting. + Cancel = "cancel", + /// The accessible name of the toast region. + ToastRegion = "toast-region", + /// A toast's dismiss button. + ToastDismiss = "toast-dismiss", + /// The help switch while the help lines are hidden. + HelpShow = "help-show", + /// The help switch while the help lines show. + HelpHide = "help-hide", + /// The combobox list when the filter keeps no option. + ComboboxNoMatch = "combobox-no-match", + /// The basic form's user name field. + SignInUsername = "sign-in-username", + /// The basic form's password field. + SignInPassword = "sign-in-password", + /// The basic form's button. + SignInSubmit = "sign-in-submit", + /// The divider between the basic form and the provider's button. + SignInOr = "sign-in-or", + /// The card of a console with no sign-in method. + SignInNone = "sign-in-none", + /// The pager's step to the first page. + StepFirst = "step-first", + /// The pager's step to the page before. + StepPrevious = "step-previous", + /// The pager's step to the page after. + StepNext = "step-next", + /// The pager's step to the last page. + StepLast = "step-last", + /// The hidden words of a step that cannot be taken. + StepUnavailable = "step-unavailable", + /// The pager's size choices. + RowsPerPage = "rows-per-page", + /// The pager's position: `$page` of `$pages`. + PageOf = "page-of", + /// The range of a page: `$first` to `$last` of `$total` rows. + Range = "range", + /// The range of an empty listing. + RangeEmpty = "range-empty", + /// The range naming the rows: `$noun` is the word for `$total` rows. + RangeOf = "range-of", + /// The range of an empty listing: `$nouns` is the word for none. + RangeOfEmpty = "range-of-empty", + /// The range of a listing with no total: `$nouns` opens it. + OpenRangeOf = "open-range-of", + /// The note when the server counted `$stated` rows and the listing holds `$held`. + PartialNote = "partial-note", + /// The range followed by its partial note. + RangeWithNote = "range-with-note", + /// The library's word for `$count` rows. + Rows = "rows", + /// The library's word for rows, opening a sentence. + RowsOpening = "rows-opening", + /// The change table's setting column. + ChangeSetting = "change-setting", + /// The change table's value before. + ChangeBefore = "change-before", + /// The change table's value after. + ChangeAfter = "change-after", + /// The hidden word before a removed value. + ChangeRemoved = "change-removed", + /// The hidden word before an added value. + ChangeAdded = "change-added", + /// A side of a change that holds no value. + ChangeNotSet = "change-not-set", + /// A change table with no change. + ChangeNone = "change-none", + /// An issue's severity: fatal. + SeverityFatal = "severity-fatal", + /// An issue's severity: error. + SeverityError = "severity-error", + /// An issue's severity: warning. + SeverityWarning = "severity-warning", + /// An issue's severity: information. + SeverityInformation = "severity-information", + /// An issue that carries no text. + IssueBlank = "issue-no-text", + /// The word between an issue's code and where it points. + IssuePlace = "issue-place", + /// The information issues with no text that are counted, `$count` of them. + IssuesLeftOut = "issues-left-out", + /// A refusal's title: the upstream's `$status` and the `$summary`. + RefusalStatus = "refusal-status", + /// A refusal's title when no answer came. + RefusalNoAnswer = "refusal-no-answer", + /// The accessible name of the body a refusal keeps as evidence. + RefusalEvidence = "refusal-evidence", + /// An instant read in UTC: its `$date` and `$time`. + TimestampUtc = "timestamp-utc", + /// The legend's note for a gap in a series. + ChartNoValue = "chart-no-value", + /// A cell of a chart's table with no value. + ChartNoValueCell = "chart-no-value-cell", + /// The caption of a chart's table: `$chart` is the chart's name. + ChartAsTable = "chart-as-table", + /// The header of a chart table's X column. + ChartX = "chart-x", + /// The accessible name of a chart's legend. + ChartSeries = "chart-series", + /// The document pane's highlighted view. + DocumentHighlighted = "document-highlighted", + /// The document pane's raw view. + DocumentRaw = "document-raw", + /// The document pane's rendered view. + DocumentRendered = "document-rendered", + /// The accessible name of the format choice. + DocumentFormat = "document-format", + /// The accessible name of the view choice. + DocumentView = "document-view", + /// The document pane's copy button. + DocumentCopy = "document-copy", + /// The highlighted view of the `$document`. + DocumentHighlightedRegion = "document-highlighted-region", + /// The raw view of the `$document`. + DocumentRawRegion = "document-raw-region", + /// The copy button once the text is on the clipboard. + CopyCopied = "copy-copied", + /// The announcement of a copy. + CopySaidCopied = "copy-said-copied", + /// The announcement of a refused copy. + CopySaidRefused = "copy-said-refused", + /// The words beside a refused copy. + CopyRefused = "copy-refused", + /// The drop zone's words. + UploadDropInvite = "upload-drop-invite", + /// The drop zone's words while a file is over it. + UploadDropOver = "upload-drop-over", + /// The kinds of file and the limit the field takes. + UploadRule = "upload-rule", + /// The line once the `$file` is read. + UploadRead = "upload-read", + /// The line while the console sends. + UploadBusy = "upload-busy", + /// A `$file` of a kind the field does not take. + UploadRefusedKind = "upload-refused-kind", + /// A `$file` of `$size` over the `$limit`. + UploadRefusedSize = "upload-refused-size", + /// A `$file` the browser could not read. + #[cfg_attr( + all(not(target_arch = "wasm32"), not(test)), + expect(dead_code, reason = "the browser half alone reads a file") + )] + UploadUnreadable = "upload-unreadable", + /// A size in bytes. + SizeBytes = "size-bytes", + /// A size in kibibytes. + SizeKib = "size-kib", + /// A size in mebibytes. + SizeMib = "size-mib", +} + +/// The library's message `text` in the page's locale. +#[inline(never)] +pub(crate) fn tr(text: Text) -> String { + locale().library(text.id(), &[]) +} + +/// The library's message `text` in the page's locale, with `args` filled in. +#[inline(never)] +pub(crate) fn tr_with(text: Text, args: &[(&'static str, Arg)]) -> String { + locale().library(text.id(), args) +} + +impl Locale { + /// The library's message `text` in this locale. + pub(crate) fn text(&self, text: Text) -> String { + self.library(text.id(), &[]) + } + + /// The library's message `text` in this locale, with `args` filled in. + pub(crate) fn text_with(&self, text: Text, args: &[(&'static str, Arg)]) -> String { + self.library(text.id(), args) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{Arg, CATALOG, Locale, Text, fluent_args, quality}; + + /// The names of the placeables the library's messages take. + const PLACEABLES: &[&str] = &[ + "chart", "count", "date", "document", "file", "first", "held", "kinds", "last", "limit", + "note", "noun", "nouns", "page", "pages", "range", "size", "stated", "status", "summary", + "time", "total", "version", + ]; + + /// The ids of the messages the catalog file at `path` defines. + fn ids(path: &Path) -> Vec<String> { + let source = std::fs::read_to_string(path).expect("the catalog should be readable"); + let mut ids: Vec<String> = source + .lines() + .filter_map(|line| line.split_once(" =")) + .map(|(id, _)| id) + .filter(|id| { + id.starts_with(|first: char| first.is_ascii_lowercase()) + && id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + }) + .map(str::to_owned) + .collect(); + ids.sort(); + ids + } + + #[test] + fn the_shipped_languages_are_the_catalog_directories() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + let mut found: Vec<String> = std::fs::read_dir(&root) + .expect("the locales directory should be readable") + .map(|entry| { + entry + .expect("an entry should be readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + found.sort(); + assert_eq!( + found, + Locale::shipped(), + "a catalog directory is a language" + ); + assert!( + found.contains(&String::from("en")), + "English is the reference" + ); + } + + #[test] + fn every_catalog_holds_every_message_and_no_other() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + let mut declared: Vec<String> = Text::ALL.iter().map(|text| text.id().to_owned()).collect(); + declared.sort(); + for tag in Locale::shipped() { + let catalog = ids(&root.join(&tag).join("ferrohealth-ui.ftl")); + assert_eq!( + declared, catalog, + "the catalog {tag} is complete and holds nothing else" + ); + let language = tag.parse().expect("a catalog directory is a language"); + // Every placeable a message names, so each formats whole. + let args: Vec<(&'static str, Arg)> = PLACEABLES + .iter() + .map(|name| (*name, Arg::from(3))) + .collect(); + let args = fluent_args(&args); + for text in Text::ALL { + let words = CATALOG + .lookup_single_language(&language, text.id(), Some(&args)) + .unwrap_or_else(|error| panic!("{tag} formats {}: {error}", text.id())); + assert!( + !words.trim().is_empty(), + "{tag} draws {} as words", + text.id() + ); + } + } + } + + #[test] + fn a_language_is_served_by_its_catalog_or_its_bare_language() { + let british = Locale::parse("en-GB").expect("a tag"); + assert_eq!( + "en-GB", + british.tag(), + "the page keeps the tag it asked for" + ); + assert_eq!("English", british.text(Text::LanguageName)); + assert!( + Locale::parse("not a tag").is_err(), + "no language identifier" + ); + let unknown = Locale::parse("qaa").expect("a private-use language"); + assert_eq!( + "English", + unknown.text(Text::LanguageName), + "falls back to English" + ); + } + + #[test] + fn accept_language_picks_the_best_shipped_language() { + assert_eq!("en-US", Locale::negotiate("en-US,en;q=0.9").tag()); + assert_eq!( + "en", + Locale::negotiate("qaa, en;q=0.5").tag(), + "a language the library lacks is passed over" + ); + assert_eq!("en", Locale::negotiate("").tag(), "no header is English"); + assert_eq!( + "en", + Locale::negotiate("en;q=0, *").tag(), + "a zero weight is refused" + ); + let configured = Locale::parse("en-GB").expect("a tag"); + assert_eq!( + "en-US", + Locale::negotiate_with("qaa, en-US;q=0.8", &*CATALOG, configured.clone()).tag(), + "the best language the console's catalog holds" + ); + assert_eq!( + "en-GB", + Locale::negotiate_with("qaa", &*CATALOG, configured).tag(), + "none held is the configured locale" + ); + assert_eq!(Some(500), quality("0.5")); + assert_eq!(Some(1000), quality("1.000")); + assert_eq!(None, quality("1.5")); + assert_eq!(None, quality("0.1234")); + } +} diff --git a/crates/ferrohealth-ui/src/lib.rs b/crates/ferrohealth-ui/src/lib.rs index 5fcc4e8..bf45a9b 100644 --- a/crates/ferrohealth-ui/src/lib.rs +++ b/crates/ferrohealth-ui/src/lib.rs @@ -13,7 +13,8 @@ //! filter and the highlighted editor. [`table`] is the one paging and //! sorting model every listing shares, [`highlight`] the tokenizers of YAML, //! JSON, XML and AQL, [`icon`] the vendored Lucide outlines, [`tone`] and -//! [`theme`] the status tones and the theme choice, and [`style`] the +//! [`theme`] the status tones and the theme choice, [`i18n`] the locale and +//! the catalogs of every word the components draw, and [`style`] the //! stylesheet and the brand themes as text a console serves. //! //! Every component works in both halves of a console and under client-side @@ -26,6 +27,7 @@ #![doc(test(attr(deny(warnings))))] pub mod highlight; +pub mod i18n; pub mod icon; pub mod style; pub mod table; diff --git a/crates/ferrohealth-ui/src/table/model.rs b/crates/ferrohealth-ui/src/table/model.rs index 99e9997..24f473e 100644 --- a/crates/ferrohealth-ui/src/table/model.rs +++ b/crates/ferrohealth-ui/src/table/model.rs @@ -7,6 +7,8 @@ use leptos_router::params::ParamsMap; +use crate::i18n::{Text, tr, tr_with}; + /// The rows a page shows when the request names no size. pub const DEFAULT_SIZE: u32 = 25; @@ -31,6 +33,10 @@ pub const ORDER_KEY: &str = "order"; /// The words a listing names its rows by, one and many: "value set" and /// "value sets". +/// +/// A console names its rows in its own words, from its own catalog; the +/// library's own noun, [`Noun::ROWS`], is drawn from the library's catalog +/// in the page's locale (`crate::i18n`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Noun { /// The word for one row. @@ -64,6 +70,26 @@ impl Noun { first.to_uppercase().chain(letters).collect() }) } + + /// The word for `count` rows as a range draws it: the catalog's for the + /// rows noun, the console's own for any other. + fn word(self, count: u32) -> String { + if self == Self::ROWS { + tr_with(Text::Rows, &[("count", count.into())]) + } else { + self.of(count).to_owned() + } + } + + /// [`Noun::opening`] as a range draws it, the catalog's for the rows + /// noun. + fn opening_word(self) -> String { + if self == Self::ROWS { + tr(Text::RowsOpening) + } else { + self.opening() + } + } } impl Default for Noun { @@ -272,13 +298,15 @@ impl Window { #[must_use] pub fn range_label(self) -> String { if self.total == 0 { - return String::from("No rows"); + return tr(Text::RangeEmpty); } - format!( - "{}-{} of {}", - self.start.saturating_add(1), - self.end, - self.total + tr_with( + Text::Range, + &[ + ("first", self.start.saturating_add(1).into()), + ("last", self.end.into()), + ("total", self.total.into()), + ], ) } @@ -287,14 +315,16 @@ impl Window { #[must_use] pub fn range_of(self, noun: Noun) -> String { if self.total == 0 { - return format!("No {}", noun.many); + return tr_with(Text::RangeOfEmpty, &[("nouns", noun.word(0).into())]); } - format!( - "{}-{} of {} {}", - self.start.saturating_add(1), - self.end, - self.total, - noun.of(self.total) + tr_with( + Text::RangeOf, + &[ + ("first", self.start.saturating_add(1).into()), + ("last", self.end.into()), + ("total", self.total.into()), + ("noun", noun.word(self.total).into()), + ], ) } @@ -317,9 +347,13 @@ impl Window { #[must_use] pub fn partial_note(held: u32, stated: Option<u32>, noun: Noun) -> Option<String> { let stated = stated.filter(|stated| *stated != held)?; - Some(format!( - "The server counted {stated} {}, and this listing holds {held}.", - noun.of(stated) + Some(tr_with( + Text::PartialNote, + &[ + ("stated", stated.into()), + ("noun", noun.word(stated).into()), + ("held", held.into()), + ], )) } @@ -381,13 +415,15 @@ impl OpenWindow { #[must_use] pub fn range_of(self, noun: Noun) -> String { if self.held == 0 { - return format!("No {}", noun.many); + return tr_with(Text::RangeOfEmpty, &[("nouns", noun.word(0).into())]); } - format!( - "{} {} to {}", - noun.opening(), - self.start.saturating_add(1), - self.start.saturating_add(self.held) + tr_with( + Text::OpenRangeOf, + &[ + ("nouns", noun.opening_word().into()), + ("first", self.start.saturating_add(1).into()), + ("last", self.start.saturating_add(self.held).into()), + ], ) } } diff --git a/crates/ferrohealth-ui/src/table/view.rs b/crates/ferrohealth-ui/src/table/view.rs index 22ee17d..075e8cc 100644 --- a/crates/ferrohealth-ui/src/table/view.rs +++ b/crates/ferrohealth-ui/src/table/view.rs @@ -19,6 +19,7 @@ use std::borrow::Cow; use leptos::prelude::*; +use crate::i18n::{Text, tr, tr_with}; use crate::icon::{Glyph, icon}; use crate::table::model::{Noun, OpenWindow, Paging, SIZES, Sort, Window, partial_note}; use crate::view::button::Variant; @@ -289,15 +290,15 @@ impl Step { } } - /// The step's words. + /// The step's words in the page's locale. #[must_use] - pub const fn label(self) -> &'static str { - match self { - Self::First => "First", - Self::Previous => "Previous", - Self::Next => "Next", - Self::Last => "Last", - } + pub fn label(self) -> String { + tr(match self { + Self::First => Text::StepFirst, + Self::Previous => Text::StepPrevious, + Self::Next => Text::StepNext, + Self::Last => Text::StepLast, + }) } /// The step's icon. @@ -337,7 +338,8 @@ pub fn step_label(step: Step, available: bool) -> impl IntoView + use<> { view! { {(!forward).then(|| icon(step.glyph(), "14"))} <span class="fui-step-words">{step.label()}</span> - {(!available).then(|| view! { <span class="fui-sr-only">" (unavailable)"</span> })} + {(!available) + .then(|| view! { <span class="fui-sr-only">{format!(" {}", tr(Text::StepUnavailable))}</span> })} {forward.then(|| icon(step.glyph(), "14"))} } } @@ -407,8 +409,8 @@ fn size_choices(listing: Listing, current: u32, size: &dyn Fn(u32, bool) -> AnyV .collect_view(); view! { <div class="fui-pager-sizes"> - <span class="fui-muted">"Rows per page"</span> - {sizes} + <span class="fui-muted">{tr(Text::RowsPerPage)}</span> + <span class="fui-pager-size-choices">{sizes}</span> </div> } .into_any() @@ -429,7 +431,13 @@ pub fn pager( let listing = listing.into(); let [first, previous, next, last] = Step::ALL.map(|each| step(each, each.target(window))); let range = match partial_note(window.total, listing.stated, listing.noun) { - Some(note) => format!("{}. {note}", window.range_of(listing.noun)), + Some(note) => tr_with( + Text::RangeWithNote, + &[ + ("range", window.range_of(listing.noun).into()), + ("note", note.into()), + ], + ), None => window.range_of(listing.noun), }; let sizes = size_choices(listing, window.size, size); @@ -440,7 +448,10 @@ pub fn pager( <div class="fui-pager-steps"> {first} {previous} <span class="fui-pager-position"> - {format!("Page {} of {}", window.page, window.pages)} + {tr_with( + Text::PageOf, + &[("page", window.page.into()), ("pages", window.pages.into())], + )} </span> {next} {last} </div> } diff --git a/crates/ferrohealth-ui/src/theme.rs b/crates/ferrohealth-ui/src/theme.rs index 99df0f0..f6fe1e5 100644 --- a/crates/ferrohealth-ui/src/theme.rs +++ b/crates/ferrohealth-ui/src/theme.rs @@ -12,6 +12,8 @@ //! the consoles that forbid it), so the library holds the vocabulary alone. //! No specification governs this: our own design. +use crate::i18n::{Text, tr}; + /// A theme the operator can choose. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Theme { @@ -38,14 +40,14 @@ impl Theme { } } - /// The theme's name, as the switch shows it. + /// The theme's name in the page's locale, as the switch shows it. #[must_use] - pub const fn label(self) -> &'static str { - match self { - Self::Light => "Light", - Self::Dark => "Dark", - Self::System => "System", - } + pub fn label(self) -> String { + tr(match self { + Self::Light => Text::ThemeLight, + Self::Dark => Text::ThemeDark, + Self::System => Text::ThemeSystem, + }) } /// The theme `key` names, or `None` for a key that names none. @@ -95,13 +97,13 @@ impl Density { /// Both densities, in the order a switch offers them. pub const ALL: [Self; 2] = [Self::Comfortable, Self::Compact]; - /// The density's name as a reader sees it. + /// The density's name in the page's locale, as a reader sees it. #[must_use] - pub const fn label(self) -> &'static str { - match self { - Self::Comfortable => "Comfortable", - Self::Compact => "Compact", - } + pub fn label(self) -> String { + tr(match self { + Self::Comfortable => Text::DensityComfortable, + Self::Compact => Text::DensityCompact, + }) } /// The density's key, as `data-density` carries it. diff --git a/crates/ferrohealth-ui/src/view/button.rs b/crates/ferrohealth-ui/src/view/button.rs index 5af9a9a..d766846 100644 --- a/crates/ferrohealth-ui/src/view/button.rs +++ b/crates/ferrohealth-ui/src/view/button.rs @@ -10,6 +10,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; /// What a button is for, which sets how much it stands out. @@ -65,23 +66,24 @@ impl Variant { } } - /// The variant's name, as the gallery labels it. + /// The variant's name in the page's locale, as the gallery labels it. #[must_use] - pub const fn name(self) -> &'static str { - match self { - Self::Primary => "Primary", - Self::Secondary => "Secondary", - Self::Danger => "Danger", - Self::Destructive => "Destructive", - Self::Ghost => "Ghost", - } + pub fn name(self) -> String { + tr(match self { + Self::Primary => Text::VariantPrimary, + Self::Secondary => Text::VariantSecondary, + Self::Danger => Text::VariantDanger, + Self::Destructive => Text::VariantDestructive, + Self::Ghost => Text::VariantGhost, + }) } } /// Returns the inside of a button: the icon `glyph`, when there is one, /// beside the word `label`. #[must_use] -pub fn label(glyph: Option<Glyph>, label: &'static str) -> impl IntoView + use<> { +pub fn label<L: Into<Oco<'static, str>>>(glyph: Option<Glyph>, label: L) -> impl IntoView + use<L> { + let label = label.into(); view! { {glyph.map(|glyph| icon(glyph, "16"))} {label} diff --git a/crates/ferrohealth-ui/src/view/change.rs b/crates/ferrohealth-ui/src/view/change.rs index 6bcff05..488a436 100644 --- a/crates/ferrohealth-ui/src/view/change.rs +++ b/crates/ferrohealth-ui/src/view/change.rs @@ -11,6 +11,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::table::view::{Body, Column, DataTable, data_table}; /// One setting's change. @@ -24,12 +25,14 @@ pub struct Change { pub to: Option<String>, } -/// The columns of a change table. -const COLUMNS: [Column; 3] = [ - Column::text("key", "Setting"), - Column::text("from", "Before"), - Column::text("to", "After"), -]; +/// The columns of a change table, named in the page's locale. +fn columns() -> [Column; 3] { + [ + Column::owned("key", tr(Text::ChangeSetting)), + Column::owned("from", tr(Text::ChangeBefore)), + Column::owned("to", tr(Text::ChangeAfter)), + ] +} /// Returns one side of a change: `value` marked with `sign` in the removed /// or the added tone, or "not set". @@ -45,12 +48,14 @@ fn side(value: Option<&String>, sign: &'static str, removed: bool) -> AnyView { <span class="fui-change-sign" aria-hidden="true"> {sign} </span> - <span class="fui-sr-only">{if removed { "removed " } else { "added " }}</span> + <span class="fui-sr-only"> + {format!("{} ", tr(if removed { Text::ChangeRemoved } else { Text::ChangeAdded }))} + </span> <code>{value.clone()}</code> </span> } .into_any(), - None => view! { <span class="fui-muted">"not set"</span> }.into_any(), + None => view! { <span class="fui-muted">{tr(Text::ChangeNotSet)}</span> }.into_any(), } } @@ -59,8 +64,10 @@ fn side(value: Option<&String>, sign: &'static str, removed: bool) -> AnyView { #[must_use] pub fn change_table(caption: &'static str, changes: &[Change]) -> impl IntoView + use<> { if changes.is_empty() { - return view! { <p class="fui-muted">"Nothing changes."</p> }.into_any(); + return view! { <p class="fui-muted">{tr(Text::ChangeNone)}</p> }.into_any(); } + let before = tr(Text::ChangeBefore); + let after = tr(Text::ChangeAfter); let rows = changes .iter() .map(|change| { @@ -69,8 +76,8 @@ pub fn change_table(caption: &'static str, changes: &[Change]) -> impl IntoView <th scope="row"> <code>{change.key.clone()}</code> </th> - <td data-label="Before">{side(change.from.as_ref(), "−", true)}</td> - <td data-label="After">{side(change.to.as_ref(), "+", false)}</td> + <td data-label=before.clone()>{side(change.from.as_ref(), "−", true)}</td> + <td data-label=after.clone()>{side(change.to.as_ref(), "+", false)}</td> </tr> } }) @@ -78,7 +85,7 @@ pub fn change_table(caption: &'static str, changes: &[Change]) -> impl IntoView .into_any(); data_table(DataTable { caption, - columns: &COLUMNS, + columns: &columns(), sort: None, sort_href: &|_| String::new(), body: Body::Rows(rows), diff --git a/crates/ferrohealth-ui/src/view/chart.rs b/crates/ferrohealth-ui/src/view/chart.rs index 2ff8a2a..e1b69ad 100644 --- a/crates/ferrohealth-ui/src/view/chart.rs +++ b/crates/ferrohealth-ui/src/view/chart.rs @@ -22,6 +22,7 @@ use leptos::prelude::*; use crate::highlight::escape; +use crate::i18n::{Text, tr, tr_with}; /// The number of colours a series can take: the role tones below, so a /// colour means what it means elsewhere and follows the brand's accent. @@ -447,6 +448,7 @@ pub fn figures(chart: &LineChart) -> impl IntoView + use<> { .collect(); xs.sort_by(f64::total_cmp); xs.dedup(); + let no_value = tr(Text::ChartNoValueCell); let heads = chart .series .iter() @@ -468,7 +470,7 @@ pub fn figures(chart: &LineChart) -> impl IntoView + use<> { .points .iter() .find(|point| (point.0 - at).abs() < 1e-9 && point.1.is_finite()) - .map_or_else(|| String::from("no value"), |point| tick_label(point.1)); + .map_or_else(|| no_value.clone(), |point| tick_label(point.1)); view! { <td>{value}</td> } }) .collect_view(); @@ -480,13 +482,13 @@ pub fn figures(chart: &LineChart) -> impl IntoView + use<> { } }) .collect_view(); - let caption = format!("{}, as a table", chart.label); + let caption = tr_with(Text::ChartAsTable, &[("chart", chart.label.clone().into())]); view! { <table class="fui-sr-only"> <caption>{caption}</caption> <thead> <tr> - <th scope="col">"X"</th> + <th scope="col">{tr(Text::ChartX)}</th> {heads} </tr> </thead> diff --git a/crates/ferrohealth-ui/src/view/dialog.rs b/crates/ferrohealth-ui/src/view/dialog.rs index 3149597..0e1c436 100644 --- a/crates/ferrohealth-ui/src/view/dialog.rs +++ b/crates/ferrohealth-ui/src/view/dialog.rs @@ -12,6 +12,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::view::button::Variant; @@ -91,7 +92,7 @@ fn frame(confirmation: Confirmation, danger: bool, confirm: impl IntoView) -> im popovertargetaction="hide" > {icon(Glyph::X, "16")} - "Cancel" + {tr(Text::Cancel)} </button> {confirm} </div> diff --git a/crates/ferrohealth-ui/src/view/layout.rs b/crates/ferrohealth-ui/src/view/layout.rs index ff15d90..b24c22a 100644 --- a/crates/ferrohealth-ui/src/view/layout.rs +++ b/crates/ferrohealth-ui/src/view/layout.rs @@ -169,6 +169,19 @@ pub fn field_row(row: FieldRow, body: impl IntoView + 'static) -> AnyView { view! { <div class=row.class()>{body}</div> }.into_any() } +/// Returns [`field_row`] with the id `id`, for a row a console adds, +/// removes or finds by its id, such as one value of a list. +#[must_use] +pub fn keyed_field_row(id: String, row: FieldRow, body: impl IntoView + 'static) -> AnyView { + let body = body.into_any(); + view! { + <div id=id class=row.class()> + {body} + </div> + } + .into_any() +} + /// Returns the actions `body` of a form in a bar at the foot of its card. /// /// On a window tall enough the bar stays in view at the bottom while the diff --git a/crates/ferrohealth-ui/src/view/menu.rs b/crates/ferrohealth-ui/src/view/menu.rs index 669fe28..7c3ba4a 100644 --- a/crates/ferrohealth-ui/src/view/menu.rs +++ b/crates/ferrohealth-ui/src/view/menu.rs @@ -7,6 +7,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::view::button::Variant; @@ -34,7 +35,7 @@ pub fn operator_menu(menu: OperatorMenu, extra: impl IntoView + 'static) -> AnyV <form method="post" action=action> <button type="submit" class=class> {icon(Glyph::LOG_OUT, "16")} - "Sign out" + {tr(Text::SignOut)} </button> </form> } diff --git a/crates/ferrohealth-ui/src/view/refusal.rs b/crates/ferrohealth-ui/src/view/refusal.rs index a44c6c2..d98432c 100644 --- a/crates/ferrohealth-ui/src/view/refusal.rs +++ b/crates/ferrohealth-ui/src/view/refusal.rs @@ -11,6 +11,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr, tr_with}; use crate::icon::{Glyph, icon}; use crate::tone::Tone; @@ -29,15 +30,15 @@ pub enum Severity { } impl Severity { - /// The word a reader sees. + /// The word a reader sees, in the page's locale. #[must_use] - pub const fn word(self) -> &'static str { - match self { - Self::Fatal => "fatal", - Self::Error => "error", - Self::Warning => "warning", - Self::Information => "information", - } + pub fn word(self) -> String { + tr(match self { + Self::Fatal => Text::SeverityFatal, + Self::Error => Text::SeverityError, + Self::Warning => Text::SeverityWarning, + Self::Information => Text::SeverityInformation, + }) } /// The tone the word is drawn in. @@ -153,13 +154,13 @@ pub fn issue_list(issues: &[Issue]) -> impl IntoView + use<> { let text = match &issue.text { Some(text) => text.clone().into_any(), None => view! { - <span class="fui-muted">"The issue carries no text."</span> + <span class="fui-muted">{tr(Text::IssueBlank)}</span> } .into_any(), }; let place = (!issue.place.is_empty()).then(|| { view! { - " at " + {format!(" {} ", tr(Text::IssuePlace))} <code>{issue.place.clone()}</code> } }); @@ -181,11 +182,10 @@ pub fn issue_list(issues: &[Issue]) -> impl IntoView + use<> { } }) .collect_view(); - let noun = if left_out == 1 { "issue" } else { "issues" }; let left = (left_out > 0).then(|| { view! { <p class="fui-muted"> - {format!("{left_out} information {noun} with no text left out.")} + {tr_with(Text::IssuesLeftOut, &[("count", left_out.into())])} </p> } }); @@ -200,8 +200,17 @@ pub fn issue_list(issues: &[Issue]) -> impl IntoView + use<> { #[must_use] pub fn refusal_title(refusal: &Refusal) -> String { match refusal.status { - Some(status) => format!("{status}: {}", refusal.summary), - None => format!("No answer: {}", refusal.summary), + Some(status) => tr_with( + Text::RefusalStatus, + &[ + ("status", status.to_string().into()), + ("summary", refusal.summary.clone().into()), + ], + ), + None => tr_with( + Text::RefusalNoAnswer, + &[("summary", refusal.summary.clone().into())], + ), } } @@ -219,7 +228,12 @@ pub fn refusal(refusal: &Refusal) -> impl IntoView + use<> { let issues = (!refusal.issues.is_empty()).then(|| issue_list(&refusal.issues)); let evidence = refusal.evidence.clone().map(|evidence| { view! { - <div class="fui-code-panel fui-alert-evidence" role="region" aria-label="What the server sent" tabindex="0"> + <div + class="fui-code-panel fui-alert-evidence" + role="region" + aria-label=tr(Text::RefusalEvidence) + tabindex="0" + > <pre>{evidence}</pre> </div> } diff --git a/crates/ferrohealth-ui/src/view/search.rs b/crates/ferrohealth-ui/src/view/search.rs index ebb5759..700bd01 100644 --- a/crates/ferrohealth-ui/src/view/search.rs +++ b/crates/ferrohealth-ui/src/view/search.rs @@ -11,6 +11,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::view::button::Variant; use crate::view::form::INPUT; @@ -53,7 +54,7 @@ pub fn search_box(search: SearchBox) -> AnyView { /> <button type="submit" class=Variant::Secondary.class()> {icon(Glyph::SEARCH, "16")} - "Find" + {tr(Text::Find)} </button> </form> } diff --git a/crates/ferrohealth-ui/src/view/shell.rs b/crates/ferrohealth-ui/src/view/shell.rs index 74e09af..a4cc683 100644 --- a/crates/ferrohealth-ui/src/view/shell.rs +++ b/crates/ferrohealth-ui/src/view/shell.rs @@ -10,6 +10,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr, tr_with}; use crate::icon::{Glyph, icon}; /// The maker every FerroHEALTH console's footer names. @@ -50,7 +51,7 @@ pub struct Shell { pub fn skip_link() -> impl IntoView { view! { <a href=format!("#{MAIN_ID}") class="fui-skip"> - "Skip to the page" + {tr(Text::SkipToPage)} </a> } } @@ -107,7 +108,7 @@ pub fn drawer_button(sidebar_id: &'static str) -> impl IntoView { class="fui-icon-button fui-drawer-only" popovertarget=sidebar_id aria-controls=sidebar_id - aria-label="Menu" + aria-label=tr(Text::OpenMenu) > {icon(Glyph::MENU, "18")} </button> @@ -182,8 +183,13 @@ pub fn footer_about(footer: Footer, about: About) -> impl IntoView { view! { <footer class="fui-footer"> <p> - {footer.product} " v" {footer.version} " · " {MAKER} " · " - <a href=footer.about rel=rel>"About"</a> + {format!( + "{} {} · {MAKER} · ", + footer.product, + tr_with(Text::FooterVersion, &[("version", footer.version.into())]), + )} <a href=footer.about rel=rel> + {tr(Text::FooterAbout)} + </a> </p> </footer> } diff --git a/crates/ferrohealth-ui/src/view/sign_in.rs b/crates/ferrohealth-ui/src/view/sign_in.rs index 41357aa..ee99e32 100644 --- a/crates/ferrohealth-ui/src/view/sign_in.rs +++ b/crates/ferrohealth-ui/src/view/sign_in.rs @@ -19,6 +19,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::tone::Tone; use crate::view::button::Variant; @@ -102,17 +103,19 @@ pub struct SignIn { /// Returns the basic form posting to `basic.action`. fn basic_form(basic: Basic) -> impl IntoView { + let username = tr(Text::SignInUsername); + let password = tr(Text::SignInPassword); view! { <form method="post" action=basic.action class="fui-form"> <div class="fui-field"> <label for="sign-in-username" class="fui-label"> - "Username" + {username.clone()} </label> <input id="sign-in-username" name="username" class=INPUT - placeholder="Username" + placeholder=username autocomplete="username" autocapitalize="off" spellcheck="false" @@ -120,21 +123,21 @@ fn basic_form(basic: Basic) -> impl IntoView { </div> <div class="fui-field"> <label for="sign-in-password" class="fui-label"> - "Password" + {password.clone()} </label> <input id="sign-in-password" name="password" type="password" class=INPUT - placeholder="Password" + placeholder=password autocomplete="current-password" /> </div> <input type="hidden" name="next" value=basic.next /> <button type="submit" class=Variant::Primary.class()> {icon(Glyph::LOG_IN, "16")} - "Sign in" + {tr(Text::SignInSubmit)} </button> </form> } @@ -165,7 +168,7 @@ pub fn sign_in(sign_in: SignIn) -> impl IntoView { let both = sign_in.basic.is_some() && sign_in.provider.is_some(); let none = sign_in.basic.is_none() && sign_in.provider.is_none(); let basic = sign_in.basic.map(basic_form); - let divider = both.then(|| view! { <div class="fui-divider">"or"</div> }); + let divider = both.then(|| view! { <div class="fui-divider">{tr(Text::SignInOr)}</div> }); let provider = sign_in.provider.map(|provider| { let variant = if both { Variant::Secondary @@ -182,7 +185,7 @@ pub fn sign_in(sign_in: SignIn) -> impl IntoView { let unavailable = none.then(|| { view! { <div role="alert" class="fui-alert"> - <p>"No sign-in method is available: the console has none configured."</p> + <p>{tr(Text::SignInNone)}</p> </div> } }); diff --git a/crates/ferrohealth-ui/src/view/surface.rs b/crates/ferrohealth-ui/src/view/surface.rs index 865181a..17b48d8 100644 --- a/crates/ferrohealth-ui/src/view/surface.rs +++ b/crates/ferrohealth-ui/src/view/surface.rs @@ -11,6 +11,8 @@ use std::borrow::Cow; use leptos::prelude::*; +use crate::i18n::{Text, tr}; + use crate::icon::{Glyph, icon}; /// One ancestor of a page in its breadcrumb trail. @@ -96,7 +98,7 @@ fn heading(header: Header, title: Signal<String>, actions: impl IntoView) -> imp }) .collect_view(); view! { - <nav class="fui-crumbs" aria-label="Breadcrumb"> + <nav class="fui-crumbs" aria-label=tr(Text::Breadcrumb)> <ol>{links} <li aria-current="page">{move || title.get()}</li></ol> </nav> } @@ -329,7 +331,7 @@ pub fn facts_row(rows: Vec<(&'static str, AnyView)>) -> impl IntoView { #[must_use] pub fn or_none(value: String) -> AnyView { if value.is_empty() { - view! { <span class="fui-muted">"none"</span> }.into_any() + view! { <span class="fui-muted">{tr(Text::NoneValue)}</span> }.into_any() } else { value.into_any() } diff --git a/crates/ferrohealth-ui/src/view/theme_switch.rs b/crates/ferrohealth-ui/src/view/theme_switch.rs index 3d74b81..61ce002 100644 --- a/crates/ferrohealth-ui/src/view/theme_switch.rs +++ b/crates/ferrohealth-ui/src/view/theme_switch.rs @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Cadasto B.V. // SPDX-License-Identifier: BUSL-1.1 -//! The theme switch: light, dark and system, the one in force pressed. +//! The theme switch: light, dark and system, the one in force pressed; and +//! the density and language switches of the same shape. //! //! Each button posts its theme as a plain form to the console's own route, //! before the page has hydrated and after, so it needs no script; the @@ -11,6 +12,7 @@ use leptos::prelude::*; +use crate::i18n::{Locale, Text, locale, tr}; use crate::icon::{Glyph, icon}; use crate::theme::{Density, Theme}; @@ -41,6 +43,8 @@ pub const TO_LIGHT: &str = "theme-to-light"; /// over the same markup. #[must_use] pub fn theme_toggle(action: String, back: String) -> AnyView { + let to_dark = tr(Text::ThemeToDark); + let to_light = tr(Text::ThemeToLight); view! { <form method="post" action=action class="fui-theme-toggle"> <input type="hidden" name="back" value=back /> @@ -50,8 +54,8 @@ pub fn theme_toggle(action: String, back: String) -> AnyView { name="theme" value=Theme::Dark.key() class="fui-icon-button fui-theme-to-dark" - aria-label="Switch to the dark theme" - title="Switch to the dark theme" + aria-label=to_dark.clone() + title=to_dark > {icon(Glyph::MOON, "16")} </button> @@ -61,8 +65,8 @@ pub fn theme_toggle(action: String, back: String) -> AnyView { name="theme" value=Theme::Light.key() class="fui-icon-button fui-theme-to-light" - aria-label="Switch to the light theme" - title="Switch to the light theme" + aria-label=to_light.clone() + title=to_light > {icon(Glyph::SUN, "16")} </button> @@ -97,7 +101,7 @@ pub fn theme_switch(current: Theme, action: String, back: String) -> impl IntoVi view! { <form method="post" action=action> <input type="hidden" name="back" value=back /> - <div role="group" aria-label="Theme" class="fui-segmented"> + <div role="group" aria-label=tr(Text::ThemeSwitch) class="fui-segmented"> {buttons} </div> </form> @@ -136,7 +140,49 @@ pub fn density_switch(current: Density, action: String, back: String) -> AnyView view! { <form method="post" action=action> <input type="hidden" name="back" value=back /> - <div role="group" aria-label="Density" class="fui-segmented"> + <div role="group" aria-label=tr(Text::DensitySwitch) class="fui-segmented"> + {buttons} + </div> + </form> + } + .into_any() +} + +/// Returns the language switch over `choices`, the page's locale pressed, +/// the shape of [`theme_switch`]. +/// +/// Each choice is a submit button of one plain form posting `locale`, the +/// choice's BCP 47 tag, to `action` with the page `back` to return to. Each +/// is named in its own language, from its own catalog's `language-name`, +/// and marked with that language in `lang` (WCAG 2.2 SC 3.1.2). The console +/// keeps the choice, provides the locale and writes `<html lang>` as it +/// renders (`crate::i18n`). +#[must_use] +pub fn locale_switch(choices: &[Locale], action: String, back: String) -> AnyView { + let current = locale(); + let buttons = choices + .iter() + .map(|choice| { + let pressed = *choice == current; + view! { + <button + type="submit" + name="locale" + value=choice.tag() + lang=choice.tag() + class="fui-segment" + aria-pressed=pressed.to_string() + > + {choice.text(Text::LanguageName)} + {pressed.then(|| icon(Glyph::CHECK, "12"))} + </button> + } + }) + .collect_view(); + view! { + <form method="post" action=action> + <input type="hidden" name="back" value=back /> + <div role="group" aria-label=tr(Text::LanguageSwitch) class="fui-segmented"> {buttons} </div> </form> diff --git a/crates/ferrohealth-ui/src/view/time.rs b/crates/ferrohealth-ui/src/view/time.rs index fe3f595..b342fe6 100644 --- a/crates/ferrohealth-ui/src/view/time.rs +++ b/crates/ferrohealth-ui/src/view/time.rs @@ -14,6 +14,8 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr_with}; + /// A date, `YYYY-MM-DD`, its parts checked against the calendar. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Date { @@ -271,7 +273,13 @@ impl Parsed { match self { Self::Date(date) => day(*date), Self::Local(date, clock) => format!("{} {}", day(*date), time(clock, false)), - Self::Instant(date, clock) => format!("{} {} UTC", day(*date), time(clock, false)), + Self::Instant(date, clock) => tr_with( + Text::TimestampUtc, + &[ + ("date", day(*date).into()), + ("time", time(clock, false).into()), + ], + ), } } diff --git a/crates/ferrohealth-ui/src/widget/chart.rs b/crates/ferrohealth-ui/src/widget/chart.rs index f73fbd6..b54a56d 100644 --- a/crates/ferrohealth-ui/src/widget/chart.rs +++ b/crates/ferrohealth-ui/src/widget/chart.rs @@ -11,6 +11,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::view::chart::{LineChart, PALETTE, figures, has_gaps, line_svg}; /// How many of `count` series `shown` keeps, a missing flag counting as @@ -71,7 +72,7 @@ pub fn line_chart(chart: LineChart, shown: RwSignal<Vec<bool>>) -> AnyView { view! { <span class="fui-legend-note"> <span class="fui-swatch-gap" aria-hidden="true"></span> - "No value" + {tr(Text::ChartNoValue)} </span> } }); @@ -79,7 +80,7 @@ pub fn line_chart(chart: LineChart, shown: RwSignal<Vec<bool>>) -> AnyView { view! { <figure class="fui-chart"> <div class="fui-chart-plot" inner_html=drawn></div> - <figcaption class="fui-legend" aria-label="Series"> + <figcaption class="fui-legend" aria-label=tr(Text::ChartSeries)> {chips} {note} </figcaption> diff --git a/crates/ferrohealth-ui/src/widget/combobox.rs b/crates/ferrohealth-ui/src/widget/combobox.rs index a9c6253..ed0d2d7 100644 --- a/crates/ferrohealth-ui/src/widget/combobox.rs +++ b/crates/ferrohealth-ui/src/widget/combobox.rs @@ -17,6 +17,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::view::form::SELECT; @@ -358,12 +359,12 @@ impl State { } /// The rows of the list: the options kept, or "No match". -fn rows(state: State) -> AnyView { +fn rows(state: State, no_match: String) -> AnyView { let list = state.shown.get(); if list.is_empty() { return view! { <li class="fui-combobox-empty" role="option" aria-disabled="true" aria-selected="false"> - "No match" + {no_match} </li> } .into_any(); @@ -426,6 +427,7 @@ fn enhanced_view(combo: KeyedCombobox) -> AnyView { } = combo; let list_id = format!("{id}-list"); let state = State::new(id.clone(), options, selected); + let no_match = tr(Text::ComboboxNoMatch); let revealed = list_id.clone(); Effect::new(move |_| { if let Some(option) = state.active_id() { @@ -472,7 +474,7 @@ fn enhanced_view(combo: KeyedCombobox) -> AnyView { class="fui-combobox-list" hidden=move || !state.open.get() > - {move || rows(state)} + {move || rows(state, no_match.clone())} </ul> </div> } diff --git a/crates/ferrohealth-ui/src/widget/copy.rs b/crates/ferrohealth-ui/src/widget/copy.rs index c329cc5..4953294 100644 --- a/crates/ferrohealth-ui/src/widget/copy.rs +++ b/crates/ferrohealth-ui/src/widget/copy.rs @@ -12,6 +12,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::view::button::Variant; @@ -38,10 +39,11 @@ enum State { #[must_use] pub fn copy_button( id: impl Into<String>, - label: &'static str, + label: impl Into<Oco<'static, str>>, text: Signal<String>, ) -> impl IntoView { let id = id.into(); + let label = label.into(); let state = RwSignal::new(State::Ready); let hydrated = RwSignal::new(false); Effect::new(move |_| hydrated.set(true)); @@ -49,10 +51,16 @@ pub fn copy_button( let value = text.get_untracked(); write(&value, state); }; + // The words are read once, while the page's locale is in reach, and + // drawn as the state moves. + let said_copied = tr(Text::CopySaidCopied); + let said_refused = tr(Text::CopySaidRefused); + let copied = tr(Text::CopyCopied); + let refused = tr(Text::CopyRefused); let said = move || match state.get() { State::Ready => String::new(), - State::Copied => String::from("Copied to the clipboard."), - State::Refused => String::from("Copy failed: the browser refused the clipboard."), + State::Copied => said_copied.clone(), + State::Refused => said_refused.clone(), }; view! { <span class="fui-copy"> @@ -67,14 +75,14 @@ pub fn copy_button( State::Copied => { view! { {icon(Glyph::CHECK, "14")} - "Copied" + {copied.clone()} } .into_any() } State::Ready | State::Refused => { view! { {icon(Glyph::COPY, "14")} - {label} + {label.clone()} } .into_any() } @@ -88,7 +96,7 @@ pub fn copy_button( .then(|| { view! { <span class="fui-copy-refused" aria-hidden="true"> - "The browser refused the clipboard." + {refused.clone()} </span> } }) diff --git a/crates/ferrohealth-ui/src/widget/dialog.rs b/crates/ferrohealth-ui/src/widget/dialog.rs index 08e4892..602d909 100644 --- a/crates/ferrohealth-ui/src/widget/dialog.rs +++ b/crates/ferrohealth-ui/src/widget/dialog.rs @@ -15,6 +15,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; /// How wide a modal dialog is. @@ -96,7 +97,7 @@ pub fn modal_dialog(modal: Modal, open: Signal<bool>, on_dismiss: Callback<()>) on:click=move |_| on_dismiss.run(()) > {icon(Glyph::X, "16")} - "Cancel" + {tr(Text::Cancel)} </button> {actions} </div> diff --git a/crates/ferrohealth-ui/src/widget/document.rs b/crates/ferrohealth-ui/src/widget/document.rs index 04a5283..cbc0187 100644 --- a/crates/ferrohealth-ui/src/widget/document.rs +++ b/crates/ferrohealth-ui/src/widget/document.rs @@ -15,16 +15,22 @@ use leptos::prelude::*; use crate::highlight::{Language, Lines, html}; +use crate::i18n::{Text, tr, tr_with}; use crate::widget::copy::copy_button; -use crate::widget::segmented::segmented; +use crate::widget::segmented::segmented_words; -/// The views a pane offers, with and without a rendered one. -const VIEWS: &[(&str, &str)] = &[("highlighted", "Highlighted"), ("raw", "Raw")]; -const VIEWS_RENDERED: &[(&str, &str)] = &[ - ("highlighted", "Highlighted"), - ("raw", "Raw"), - ("rendered", "Rendered"), -]; +/// The views a pane offers, each its key and its words in the page's +/// locale; the rendered view when `rendered`. +fn views(rendered: bool) -> Vec<(&'static str, String)> { + let mut views = vec![ + ("highlighted", tr(Text::DocumentHighlighted)), + ("raw", tr(Text::DocumentRaw)), + ]; + if rendered { + views.push(("rendered", tr(Text::DocumentRendered))); + } + views +} /// What a document pane shows. #[derive(Debug)] @@ -76,7 +82,7 @@ pub fn document_pane_opening( label, formats, format, - text, + text: source, language, rendered, } = pane; @@ -94,20 +100,34 @@ pub fn document_pane_opening( "rendered" if !offers_rendered() => "highlighted", other => other, }); - let selector = (formats.len() > 1).then(|| segmented("Format", formats, format, on_format)); + let format_label = tr(Text::DocumentFormat); + let selector = (formats.len() > 1).then(|| { + let formats = formats + .iter() + .map(|(key, words)| (*key, String::from(*words))) + .collect(); + segmented_words(format_label, formats, format, on_format) + }); + let view_label = tr(Text::DocumentView); + let (plain, with_rendered) = (views(false), views(true)); let switch = move || { let views = if offers_rendered() { - VIEWS_RENDERED + with_rendered.clone() } else { - VIEWS + plain.clone() }; - segmented("View", views, view_in_force.into(), move |key| { - chosen.set(key); - }) + segmented_words( + view_label.clone(), + views, + view_in_force.into(), + move |key| { + chosen.set(key); + }, + ) }; let highlighted = move || { let language = language.get(); - text.with(|text| html(language, text, Lines::default(), |_| None)) + source.with(|text| html(language, text, Lines::default(), |_| None)) }; let copy_id = format!("{id}-copy"); let rendered = rendered.map(|body| { @@ -125,14 +145,14 @@ pub fn document_pane_opening( <section class="fui-document" id=id aria-label=label> <div class="fui-document-tools"> {selector} {switch} <span class="fui-document-copy"> - {copy_button(copy_id, "Copy", text)} + {copy_button(copy_id, tr(Text::DocumentCopy), source)} </span> </div> <div id=format!("{id}-highlighted") class="fui-code-panel fui-document-body" role="region" - aria-label=format!("{label}, highlighted") + aria-label=tr_with(Text::DocumentHighlightedRegion, &[("document", label.into())]) tabindex="0" hidden=move || view_in_force.get() != "highlighted" > @@ -142,11 +162,11 @@ pub fn document_pane_opening( id=format!("{id}-raw") class="fui-code-panel fui-document-body" role="region" - aria-label=format!("{label}, raw") + aria-label=tr_with(Text::DocumentRawRegion, &[("document", label.into())]) tabindex="0" hidden=move || view_in_force.get() != "raw" > - <pre>{move || text.get()}</pre> + <pre>{move || source.get()}</pre> </div> {rendered} </section> diff --git a/crates/ferrohealth-ui/src/widget/help.rs b/crates/ferrohealth-ui/src/widget/help.rs index 473fc0b..cdc03f9 100644 --- a/crates/ferrohealth-ui/src/widget/help.rs +++ b/crates/ferrohealth-ui/src/widget/help.rs @@ -12,6 +12,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::view::button::Variant; use crate::view::form::help_id; @@ -40,6 +41,8 @@ fn help_shown() -> HelpShown { #[must_use] pub fn help_switch() -> AnyView { let HelpShown(shown) = help_shown(); + let hide = tr(Text::HelpHide); + let explain = tr(Text::HelpShow); view! { <button type="button" @@ -48,7 +51,7 @@ pub fn help_switch() -> AnyView { on:click=move |_| shown.update(|shown| *shown = !*shown) > {icon(Glyph::INFO, "14")} - {move || if shown.get() { "Hide help" } else { "Explain these parameters" }} + {move || if shown.get() { hide.clone() } else { explain.clone() }} </button> } .into_any() diff --git a/crates/ferrohealth-ui/src/widget/nav.rs b/crates/ferrohealth-ui/src/widget/nav.rs index 454513a..4bd4d99 100644 --- a/crates/ferrohealth-ui/src/widget/nav.rs +++ b/crates/ferrohealth-ui/src/widget/nav.rs @@ -16,6 +16,7 @@ use leptos::prelude::*; use leptos_router::components::A; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::view::button::Variant; @@ -94,7 +95,7 @@ pub fn sidebar(id: &'static str, label: &'static str, groups: &[Group]) -> AnyVi > <button type="button" class=close popovertarget=id popovertargetaction="hide"> {icon(Glyph::X, "16")} - "Close" + {tr(Text::Close)} </button> {groups} </nav> diff --git a/crates/ferrohealth-ui/src/widget/segmented.rs b/crates/ferrohealth-ui/src/widget/segmented.rs index 95af91c..b4203c2 100644 --- a/crates/ferrohealth-ui/src/widget/segmented.rs +++ b/crates/ferrohealth-ui/src/widget/segmented.rs @@ -22,10 +22,24 @@ pub fn segmented( current: Signal<&'static str>, on_select: impl Fn(&'static str) + Clone + Send + Sync + 'static, ) -> impl IntoView { - let buttons = choices + let choices = choices .iter() + .map(|(key, words)| (*key, String::from(*words))) + .collect(); + segmented_words(String::from(label), choices, current, on_select) +} + +/// Returns [`segmented`] over words made at run time, such as the words of +/// the page's locale. +pub(crate) fn segmented_words( + label: String, + choices: Vec<(&'static str, String)>, + current: Signal<&'static str>, + on_select: impl Fn(&'static str) + Clone + Send + Sync + 'static, +) -> impl IntoView { + let buttons = choices + .into_iter() .map(|(key, words)| { - let key = *key; let on_select = on_select.clone(); view! { <button @@ -35,7 +49,7 @@ pub fn segmented( aria-pressed=move || (current.get() == key).to_string() on:click=move |_| on_select(key) > - {*words} + {words} </button> } }) diff --git a/crates/ferrohealth-ui/src/widget/side_panel.rs b/crates/ferrohealth-ui/src/widget/side_panel.rs index 0225b82..59467e6 100644 --- a/crates/ferrohealth-ui/src/widget/side_panel.rs +++ b/crates/ferrohealth-ui/src/widget/side_panel.rs @@ -13,6 +13,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; /// What a side panel shows. @@ -77,7 +78,7 @@ pub fn side_panel(panel: SidePanel) -> AnyView { class="fui-icon-button fui-side-panel-close" popovertarget=id popovertargetaction="hide" - aria-label="Close" + aria-label=tr(Text::Close) > {icon(Glyph::X, "16")} </button> diff --git a/crates/ferrohealth-ui/src/widget/theme.rs b/crates/ferrohealth-ui/src/widget/theme.rs index 18efa0f..d657812 100644 --- a/crates/ferrohealth-ui/src/widget/theme.rs +++ b/crates/ferrohealth-ui/src/widget/theme.rs @@ -12,6 +12,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::theme::Theme; use crate::view::theme_switch::{TO_DARK, TO_LIGHT}; @@ -45,6 +46,8 @@ fn swap(theme: Theme, then: &str) { /// `on_choose`. #[must_use] pub fn theme_toggle(action: String, back: String, on_choose: Callback<Theme>) -> AnyView { + let to_dark = tr(Text::ThemeToDark); + let to_light = tr(Text::ThemeToLight); let choose = move |theme: Theme, then: &'static str| { move |event: leptos::ev::MouseEvent| { event.prevent_default(); @@ -61,8 +64,8 @@ pub fn theme_toggle(action: String, back: String, on_choose: Callback<Theme>) -> name="theme" value=Theme::Dark.key() class="fui-icon-button fui-theme-to-dark" - aria-label="Switch to the dark theme" - title="Switch to the dark theme" + aria-label=to_dark.clone() + title=to_dark on:click=choose(Theme::Dark, TO_LIGHT) > {icon(Glyph::MOON, "16")} @@ -73,8 +76,8 @@ pub fn theme_toggle(action: String, back: String, on_choose: Callback<Theme>) -> name="theme" value=Theme::Light.key() class="fui-icon-button fui-theme-to-light" - aria-label="Switch to the light theme" - title="Switch to the light theme" + aria-label=to_light.clone() + title=to_light on:click=choose(Theme::Light, TO_DARK) > {icon(Glyph::SUN, "16")} diff --git a/crates/ferrohealth-ui/src/widget/toast.rs b/crates/ferrohealth-ui/src/widget/toast.rs index 4626f67..b79373e 100644 --- a/crates/ferrohealth-ui/src/widget/toast.rs +++ b/crates/ferrohealth-ui/src/widget/toast.rs @@ -15,6 +15,7 @@ use leptos::prelude::*; +use crate::i18n::{Text, tr}; use crate::icon::{Glyph, icon}; use crate::tone::Tone; @@ -104,8 +105,9 @@ const fn glyph(tone: Tone) -> Glyph { /// Returns the region that draws `toasts`. #[must_use] pub fn toast_region(toasts: Toasts) -> impl IntoView { + let dismiss = tr(Text::ToastDismiss); view! { - <div class="fui-toasts" role="status" aria-live="polite" aria-label="Notices"> + <div class="fui-toasts" role="status" aria-live="polite" aria-label=tr(Text::ToastRegion)> <For each=move || toasts.list.get() key=|toast| toast.id let:toast> { let class = format!("fui-toast {}", toast.tone.class()); @@ -120,7 +122,7 @@ pub fn toast_region(toasts: Toasts) -> impl IntoView { on:click=move |_| toasts.dismiss(id) > {icon(Glyph::X, "14")} - <span class="fui-sr-only">"Dismiss"</span> + <span class="fui-sr-only">{dismiss.clone()}</span> </button> </div> } diff --git a/crates/ferrohealth-ui/src/widget/upload.rs b/crates/ferrohealth-ui/src/widget/upload.rs index 8eb587c..92f45b6 100644 --- a/crates/ferrohealth-ui/src/widget/upload.rs +++ b/crates/ferrohealth-ui/src/widget/upload.rs @@ -18,6 +18,7 @@ use leptos::prelude::*; use crate::highlight::Language; +use crate::i18n::{Locale, Text, locale}; use crate::icon::{Glyph, icon}; use crate::widget::editor::{Drawn, Grammar, drawn}; @@ -57,36 +58,54 @@ pub enum Refused { } impl Refused { - /// The sentence the field shows for the refusal, naming `accept` and the - /// limit `max_bytes`. + /// The sentence the field shows for the refusal in the page's locale, + /// naming `accept` and the limit `max_bytes`. #[must_use] pub fn sentence(&self, accept: &[&str], max_bytes: u64) -> String { + self.sentence_in(&locale(), accept, max_bytes) + } + + /// [`Refused::sentence`] in `locale`. + fn sentence_in(&self, locale: &Locale, accept: &[&str], max_bytes: u64) -> String { match self { - Self::Kind { name } => format!( - "{name} was not read: the field takes {} files.", - accept.join(", ") + Self::Kind { name } => locale.text_with( + Text::UploadRefusedKind, + &[ + ("file", name.clone().into()), + ("kinds", accept.join(", ").into()), + ], ), - Self::Size { name, bytes } => format!( - "{name} was not read: it is {} and the field takes up to {}.", - size(*bytes), - size(max_bytes) + Self::Size { name, bytes } => locale.text_with( + Text::UploadRefusedSize, + &[ + ("file", name.clone().into()), + ("size", size_in(locale, *bytes).into()), + ("limit", size_in(locale, max_bytes).into()), + ], ), } } } -/// `bytes` in the largest unit that keeps it above one, in words. +/// `bytes` in the largest unit that keeps it above one, in words of the +/// page's locale. #[must_use] pub fn size(bytes: u64) -> String { + size_in(&locale(), bytes) +} + +/// [`size`] in `locale`. +fn size_in(locale: &Locale, bytes: u64) -> String { const KIB: u64 = 1024; const MIB: u64 = 1024 * 1024; - if bytes >= MIB { - format!("{} MiB", bytes.div_ceil(MIB)) + let (unit, count) = if bytes >= MIB { + (Text::SizeMib, bytes.div_ceil(MIB)) } else if bytes >= KIB { - format!("{} KiB", bytes.div_ceil(KIB)) + (Text::SizeKib, bytes.div_ceil(KIB)) } else { - format!("{bytes} bytes") - } + (Text::SizeBytes, bytes) + }; + locale.text_with(unit, &[("count", count.into())]) } /// Whether a file `name` of `bytes` may be read under `upload`, or why not. @@ -140,6 +159,11 @@ pub fn upload_field( refusal: Signal<Option<String>>, ) -> AnyView { let picked = RwSignal::new(Picked::None); + // The page's locale is read once here: the file is read in a handler + // and a task, where no page tree is in reach. + let locale = locale(); + let uploading = locale.text(Text::UploadBusy); + let read_locale = locale.clone(); let text_id = format!("{}-text", upload.id); let label_for = text_id.clone(); let state = move || match picked.get() { @@ -148,7 +172,7 @@ pub fn upload_field( view! { <p class="fui-drop-state" role="status"> {icon(Glyph::FILE_TEXT, "14")} - {format!("{name} read; check the text below before you send it.")} + {read_locale.text_with(Text::UploadRead, &[("file", name.into())])} </p> } .into_any(), @@ -163,7 +187,7 @@ pub fn upload_field( .into_any(), ), }; - let zone = drop_zone(upload, source, picked); + let zone = drop_zone(upload, &locale, source, picked); // The text area is the highlighted editor, its grammar read from the // text: a pasted or read OPT shows as XML, a JSON document as JSON. let editor = drawn( @@ -193,7 +217,7 @@ pub fn upload_field( view! { <p class="fui-spinner" role="status"> <span class="fui-spinner-ring" aria-hidden="true"></span> - "Uploading…" + {uploading.clone()} </p> } }) @@ -217,8 +241,23 @@ pub fn upload_field( /// Returns the drop zone of `upload`: the words, the chooser and the rule, /// reading a file dropped or chosen into `source` and saying so in /// `picked`. -fn drop_zone(upload: Upload, source: RwSignal<String>, picked: RwSignal<Picked>) -> impl IntoView { +fn drop_zone( + upload: Upload, + locale: &Locale, + source: RwSignal<String>, + picked: RwSignal<Picked>, +) -> impl IntoView { let over = RwSignal::new(false); + let release = locale.text(Text::UploadDropOver); + let invite = locale.text(Text::UploadDropInvite); + let rule = locale.text_with( + Text::UploadRule, + &[ + ("kinds", upload.accept.join(", ").into()), + ("limit", size_in(locale, upload.max_bytes).into()), + ], + ); + let (on_drop, on_choose) = (locale.clone(), locale.clone()); let file_id = format!("{}-file", upload.id); let drop_id = format!("{}-drop", upload.id); let accept = upload.accept.join(","); @@ -245,18 +284,12 @@ fn drop_zone(upload: Upload, source: RwSignal<String>, picked: RwSignal<Picked>) on:drop=move |event| { event.prevent_default(); over.set(false); - dropped(upload, &event, source, picked); + dropped(upload, &on_drop, &event, source, picked); } > <span class="fui-drop-glyph">{icon(Glyph::UPLOAD, "20")}</span> <p class="fui-drop-words"> - {move || { - if over.get() { - "Drop to read the file" - } else { - "Drop a file here, or" - } - }} + {move || if over.get() { release.clone() } else { invite.clone() }} </p> <label for=input_for class="fui-button fui-button-secondary fui-button-small"> {icon(Glyph::FOLDER_OPEN, "14")} @@ -267,15 +300,9 @@ fn drop_zone(upload: Upload, source: RwSignal<String>, picked: RwSignal<Picked>) type="file" class="fui-sr-only" accept=accept - on:change=move |event| chosen(upload, &event, source, picked) + on:change=move |event| chosen(upload, &on_choose, &event, source, picked) /> - <p class="fui-small"> - {format!( - "{} files, up to {}.", - upload.accept.join(", "), - size(upload.max_bytes), - )} - </p> + <p class="fui-small">{rule}</p> </div> } } @@ -283,6 +310,7 @@ fn drop_zone(upload: Upload, source: RwSignal<String>, picked: RwSignal<Picked>) /// Reads the file `event` chose into `source`, or refuses it. fn chosen( upload: Upload, + locale: &Locale, event: &leptos::ev::Event, source: RwSignal<String>, picked: RwSignal<Picked>, @@ -296,21 +324,23 @@ fn chosen( .and_then(|input| input.files()) .and_then(|files| files.get(0)); if let Some(file) = file { - read(upload, &file, source, picked); + read(upload, locale, &file, source, picked); } } #[cfg(not(target_arch = "wasm32"))] let _: ( Upload, + &Locale, &leptos::ev::Event, RwSignal<String>, RwSignal<Picked>, - ) = (upload, event, source, picked); + ) = (upload, locale, event, source, picked); } /// Reads the file `event` dropped into `source`, or refuses it. fn dropped( upload: Upload, + locale: &Locale, event: &leptos::ev::DragEvent, source: RwSignal<String>, picked: RwSignal<Picked>, @@ -322,29 +352,39 @@ fn dropped( .and_then(|transfer| transfer.files()) .and_then(|files| files.get(0)); if let Some(file) = file { - read(upload, &file, source, picked); + read(upload, locale, &file, source, picked); } } #[cfg(not(target_arch = "wasm32"))] let _: ( Upload, + &Locale, &leptos::ev::DragEvent, RwSignal<String>, RwSignal<Picked>, - ) = (upload, event, source, picked); + ) = (upload, locale, event, source, picked); } /// Reads `file` into `source` when [`admit`] lets it, and says so in /// `picked`. #[cfg(target_arch = "wasm32")] -fn read(upload: Upload, file: &web_sys::File, source: RwSignal<String>, picked: RwSignal<Picked>) { +fn read( + upload: Upload, + locale: &Locale, + file: &web_sys::File, + source: RwSignal<String>, + picked: RwSignal<Picked>, +) { let name = file.name(); if let Err(refused) = admit(&upload, &name, whole_bytes(file.size())) { - picked.set(Picked::Refused( - refused.sentence(upload.accept, upload.max_bytes), - )); + picked.set(Picked::Refused(refused.sentence_in( + locale, + upload.accept, + upload.max_bytes, + ))); return; } + let unreadable = locale.text_with(Text::UploadUnreadable, &[("file", name.clone().into())]); let promise = file.text(); wasm_bindgen_futures::spawn_local(async move { let read = wasm_bindgen_futures::JsFuture::from(promise) @@ -356,9 +396,7 @@ fn read(upload: Upload, file: &web_sys::File, source: RwSignal<String>, picked: source.set(text); picked.set(Picked::Read(name)); } - None => picked.set(Picked::Refused(format!( - "{name} was not read: the browser could not read it." - ))), + None => picked.set(Picked::Refused(unreadable)), } }); } diff --git a/crates/ferrohealth-ui/tests/it/locale.rs b/crates/ferrohealth-ui/tests/it/locale.rs new file mode 100644 index 0000000..014f5eb --- /dev/null +++ b/crates/ferrohealth-ui/tests/it/locale.rs @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Cadasto B.V. +// SPDX-License-Identifier: BUSL-1.1 + +//! The locale: a component draws its words from the catalog of the locale +//! the page provides, a throwaway catalog changes them with no code change, +//! and a message a catalog lacks falls back to English. + +use ferrohealth_ui::i18n::{Locale, locale, provide_locale}; +use ferrohealth_ui::table::model::{Paging, Window}; +use ferrohealth_ui::table::view::link_pager; +use ferrohealth_ui::view::dialog::{Confirmation, confirm_dialog}; +use ferrohealth_ui::view::theme_switch::locale_switch; +use leptos::prelude::*; + +fluent_templates::static_loader! { + static THROWAWAY = { + locales: "./tests/it/locales", + fallback_language: "qaa", + customise: |bundle| bundle.set_use_isolating(false), + }; +} + +/// The throwaway locale, its catalog the test's own. +fn throwaway() -> Locale { + Locale::parse("qaa").unwrap().with_catalog(&*THROWAWAY) +} + +/// `view` rendered to HTML in a page that provides `locale`, or none. +fn render(locale: Option<Locale>, view: impl FnOnce() -> AnyView) -> String { + Owner::new().with(|| { + if let Some(locale) = locale { + provide_locale(locale); + } + view().to_html() + }) +} + +/// A pager of 30 rows on its first page, every step drawn. +fn pager() -> AnyView { + link_pager( + "Pages", + Window::over(30, Paging::default()), + &|paging: Paging| format!("/files?page={}", paging.page), + ) +} + +#[test] +fn a_throwaway_catalog_changes_a_components_words_with_no_code_change() { + let english = render(Some(Locale::english()), pager); + for words in [ + "First", + "Next", + "Rows per page", + "Page 1 of 2", + "1-25 of 30 rows", + ] { + assert!(english.contains(words), "{words}: {english}"); + } + let throwaway = render(Some(throwaway()), pager); + for words in [ + "Qirst", + "Qext", + "Qows per qage", + "Qage 1 qof 2", + "1-25 qof 30 qows", + ] { + assert!(throwaway.contains(words), "{words}: {throwaway}"); + } + for words in [">First<", ">Next<", "Rows per page", "Page 1 of 2"] { + assert!(!throwaway.contains(words), "{words}: {throwaway}"); + } + assert!( + throwaway.contains("(unavailable)"), + "a message the catalog lacks falls back to English: {throwaway}" + ); +} + +#[test] +fn a_page_that_provides_no_locale_draws_english() { + let none = render(None, pager); + assert_eq!(render(Some(Locale::english()), pager), none); + let dialog = || { + confirm_dialog( + Confirmation { + id: "drop", + title: "Drop the query?", + message: "The stored query is removed.", + }, + (), + ) + .into_any() + }; + assert!(render(None, dialog).contains("Cancel")); + assert!(render(Some(throwaway()), dialog).contains("Qancel")); +} + +#[test] +fn the_page_reads_the_locale_it_provided() { + let tag = Owner::new().with(|| { + provide_locale(throwaway()); + locale().tag() + }); + assert_eq!("qaa", tag, "a console writes it as <html lang>"); + assert_eq!("en", Owner::new().with(|| locale().tag())); +} + +#[test] +fn the_language_switch_names_each_choice_in_its_own_language() { + let switch = render(Some(throwaway()), || { + locale_switch( + &[Locale::english(), throwaway()], + String::from("/locale"), + String::from("/"), + ) + }); + assert!(switch.contains(r#"aria-label="Language""#), "{switch}"); + assert!( + switch.contains(r#"value="en" lang="en" aria-pressed="false""#), + "{switch}" + ); + assert!(switch.contains(">English<"), "{switch}"); + assert!( + switch.contains(r#"value="qaa" lang="qaa" aria-pressed="true""#), + "the page's locale is pressed: {switch}" + ); + assert!(switch.contains(">Qaa<"), "{switch}"); +} diff --git a/crates/ferrohealth-ui/tests/it/locales/qaa/ferrohealth-ui.ftl b/crates/ferrohealth-ui/tests/it/locales/qaa/ferrohealth-ui.ftl new file mode 100644 index 0000000..6c03da2 --- /dev/null +++ b/crates/ferrohealth-ui/tests/it/locales/qaa/ferrohealth-ui.ftl @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Cadasto B.V. +# SPDX-License-Identifier: BUSL-1.1 + +# A throwaway catalog for the tests alone, in the private-use language `qaa` +# (ISO 639-2, reserved for local use), with invented words. It holds a few +# of the library's messages, so the rest fall back to English. It never +# ships: the library's catalogs are the directories under `locales/`. + +language-name = Qaa +step-first = Qirst +step-previous = Qrevious +step-next = Qext +step-last = Qast +rows-per-page = Qows per qage +page-of = Qage { $page } qof { $pages } +range-of = { $first }-{ $last } qof { $total } { $noun } +rows = + { $count -> + [one] qow + *[other] qows + } +cancel = Qancel diff --git a/crates/ferrohealth-ui/tests/it/main.rs b/crates/ferrohealth-ui/tests/it/main.rs index 9256d6b..39a3ce6 100644 --- a/crates/ferrohealth-ui/tests/it/main.rs +++ b/crates/ferrohealth-ui/tests/it/main.rs @@ -17,6 +17,7 @@ )] mod contrast; +mod locale; mod markup; mod render; mod stylesheet; diff --git a/crates/ferrohealth-ui/tests/it/render.rs b/crates/ferrohealth-ui/tests/it/render.rs index eee323f..66a1a6a 100644 --- a/crates/ferrohealth-ui/tests/it/render.rs +++ b/crates/ferrohealth-ui/tests/it/render.rs @@ -987,3 +987,20 @@ fn an_explained_checkbox_keeps_its_help_named_and_hidden_from_sight_until_the_sw ); assert!(has(&drawn, "div", &[("class", "fui-check")]), "{drawn}"); } + +#[test] +fn a_keyed_field_row_carries_its_id_and_its_columns() { + use ferrohealth_ui::view::layout::{FieldRow, field_row, keyed_field_row}; + let keyed = html(|| keyed_field_row(String::from("values-2-row"), FieldRow::WithActions, "x")); + assert!( + keyed.contains( + r#"<div id="values-2-row" class="fui-field-row fui-field-row-actions">x</div>"# + ), + "{keyed}" + ); + let plain = html(|| field_row(FieldRow::Halves, "y")); + assert!( + plain.contains(r#"<div class="fui-field-row fui-field-row-halves">y</div>"#), + "{plain}" + ); +} diff --git a/docs/screenshots/ferrobridge/charts-dark.png b/docs/screenshots/ferrobridge/charts-dark.png index 74a61a9..7b94065 100644 Binary files a/docs/screenshots/ferrobridge/charts-dark.png and b/docs/screenshots/ferrobridge/charts-dark.png differ diff --git a/docs/screenshots/ferrobridge/charts-narrow-dark.png b/docs/screenshots/ferrobridge/charts-narrow-dark.png index 1137a32..3491475 100644 Binary files a/docs/screenshots/ferrobridge/charts-narrow-dark.png and b/docs/screenshots/ferrobridge/charts-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/charts-narrow.png b/docs/screenshots/ferrobridge/charts-narrow.png index 065d75f..4473b16 100644 Binary files a/docs/screenshots/ferrobridge/charts-narrow.png and b/docs/screenshots/ferrobridge/charts-narrow.png differ diff --git a/docs/screenshots/ferrobridge/charts.png b/docs/screenshots/ferrobridge/charts.png index 3da50b1..1df242d 100644 Binary files a/docs/screenshots/ferrobridge/charts.png and b/docs/screenshots/ferrobridge/charts.png differ diff --git a/docs/screenshots/ferrobridge/code-dark.png b/docs/screenshots/ferrobridge/code-dark.png index 2b51993..4eaec6b 100644 Binary files a/docs/screenshots/ferrobridge/code-dark.png and b/docs/screenshots/ferrobridge/code-dark.png differ diff --git a/docs/screenshots/ferrobridge/code-narrow-dark.png b/docs/screenshots/ferrobridge/code-narrow-dark.png index 6d95843..de5ed4a 100644 Binary files a/docs/screenshots/ferrobridge/code-narrow-dark.png and b/docs/screenshots/ferrobridge/code-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/code-narrow.png b/docs/screenshots/ferrobridge/code-narrow.png index d7ca17e..6388a39 100644 Binary files a/docs/screenshots/ferrobridge/code-narrow.png and b/docs/screenshots/ferrobridge/code-narrow.png differ diff --git a/docs/screenshots/ferrobridge/code.png b/docs/screenshots/ferrobridge/code.png index d7bd7ba..11ec710 100644 Binary files a/docs/screenshots/ferrobridge/code.png and b/docs/screenshots/ferrobridge/code.png differ diff --git a/docs/screenshots/ferrobridge/forms-dark.png b/docs/screenshots/ferrobridge/forms-dark.png index 689de15..958feba 100644 Binary files a/docs/screenshots/ferrobridge/forms-dark.png and b/docs/screenshots/ferrobridge/forms-dark.png differ diff --git a/docs/screenshots/ferrobridge/forms-narrow-dark.png b/docs/screenshots/ferrobridge/forms-narrow-dark.png index f131b0b..0a33377 100644 Binary files a/docs/screenshots/ferrobridge/forms-narrow-dark.png and b/docs/screenshots/ferrobridge/forms-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/forms-narrow.png b/docs/screenshots/ferrobridge/forms-narrow.png index 6419e58..6bfae77 100644 Binary files a/docs/screenshots/ferrobridge/forms-narrow.png and b/docs/screenshots/ferrobridge/forms-narrow.png differ diff --git a/docs/screenshots/ferrobridge/forms.png b/docs/screenshots/ferrobridge/forms.png index 62b2dac..477ec4a 100644 Binary files a/docs/screenshots/ferrobridge/forms.png and b/docs/screenshots/ferrobridge/forms.png differ diff --git a/docs/screenshots/ferrobridge/layouts-dark.png b/docs/screenshots/ferrobridge/layouts-dark.png index 3ebd29a..1f1c6b9 100644 Binary files a/docs/screenshots/ferrobridge/layouts-dark.png and b/docs/screenshots/ferrobridge/layouts-dark.png differ diff --git a/docs/screenshots/ferrobridge/layouts-narrow-dark.png b/docs/screenshots/ferrobridge/layouts-narrow-dark.png index 058d04f..ba6a4f9 100644 Binary files a/docs/screenshots/ferrobridge/layouts-narrow-dark.png and b/docs/screenshots/ferrobridge/layouts-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/layouts-narrow.png b/docs/screenshots/ferrobridge/layouts-narrow.png index 508da5d..43ea689 100644 Binary files a/docs/screenshots/ferrobridge/layouts-narrow.png and b/docs/screenshots/ferrobridge/layouts-narrow.png differ diff --git a/docs/screenshots/ferrobridge/layouts.png b/docs/screenshots/ferrobridge/layouts.png index 0a36e8c..ecd89a1 100644 Binary files a/docs/screenshots/ferrobridge/layouts.png and b/docs/screenshots/ferrobridge/layouts.png differ diff --git a/docs/screenshots/ferrobridge/overlays-dark.png b/docs/screenshots/ferrobridge/overlays-dark.png index 7cb932e..1dc4a49 100644 Binary files a/docs/screenshots/ferrobridge/overlays-dark.png and b/docs/screenshots/ferrobridge/overlays-dark.png differ diff --git a/docs/screenshots/ferrobridge/overlays-narrow-dark.png b/docs/screenshots/ferrobridge/overlays-narrow-dark.png index 8ebd3cc..f13a588 100644 Binary files a/docs/screenshots/ferrobridge/overlays-narrow-dark.png and b/docs/screenshots/ferrobridge/overlays-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/overlays-narrow.png b/docs/screenshots/ferrobridge/overlays-narrow.png index 7cf20e5..dd2bc12 100644 Binary files a/docs/screenshots/ferrobridge/overlays-narrow.png and b/docs/screenshots/ferrobridge/overlays-narrow.png differ diff --git a/docs/screenshots/ferrobridge/overlays.png b/docs/screenshots/ferrobridge/overlays.png index 9f64452..bd9d8b2 100644 Binary files a/docs/screenshots/ferrobridge/overlays.png and b/docs/screenshots/ferrobridge/overlays.png differ diff --git a/docs/screenshots/ferrobridge/overview-dark.png b/docs/screenshots/ferrobridge/overview-dark.png index 5573801..8b51084 100644 Binary files a/docs/screenshots/ferrobridge/overview-dark.png and b/docs/screenshots/ferrobridge/overview-dark.png differ diff --git a/docs/screenshots/ferrobridge/overview-narrow-dark.png b/docs/screenshots/ferrobridge/overview-narrow-dark.png index 2f133d4..d60f3c9 100644 Binary files a/docs/screenshots/ferrobridge/overview-narrow-dark.png and b/docs/screenshots/ferrobridge/overview-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/overview-narrow.png b/docs/screenshots/ferrobridge/overview-narrow.png index 18bf880..03a3d8c 100644 Binary files a/docs/screenshots/ferrobridge/overview-narrow.png and b/docs/screenshots/ferrobridge/overview-narrow.png differ diff --git a/docs/screenshots/ferrobridge/overview.png b/docs/screenshots/ferrobridge/overview.png index 9ed9a03..1e11d32 100644 Binary files a/docs/screenshots/ferrobridge/overview.png and b/docs/screenshots/ferrobridge/overview.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-basic-dark.png b/docs/screenshots/ferrobridge/sign-in-basic-dark.png index c5fda93..412656c 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-basic-dark.png and b/docs/screenshots/ferrobridge/sign-in-basic-dark.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-basic-narrow-dark.png b/docs/screenshots/ferrobridge/sign-in-basic-narrow-dark.png index 9e4a54f..a3a279d 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-basic-narrow-dark.png and b/docs/screenshots/ferrobridge/sign-in-basic-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-basic-narrow.png b/docs/screenshots/ferrobridge/sign-in-basic-narrow.png index 6fa61c7..9e7dbd2 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-basic-narrow.png and b/docs/screenshots/ferrobridge/sign-in-basic-narrow.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-basic.png b/docs/screenshots/ferrobridge/sign-in-basic.png index 260e13a..cb5155c 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-basic.png and b/docs/screenshots/ferrobridge/sign-in-basic.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-both-dark.png b/docs/screenshots/ferrobridge/sign-in-both-dark.png index 3d94b44..71b9bfc 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-both-dark.png and b/docs/screenshots/ferrobridge/sign-in-both-dark.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-both-narrow-dark.png b/docs/screenshots/ferrobridge/sign-in-both-narrow-dark.png index eefe432..93abe8b 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-both-narrow-dark.png and b/docs/screenshots/ferrobridge/sign-in-both-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-both-narrow.png b/docs/screenshots/ferrobridge/sign-in-both-narrow.png index 8eff00a..8f46647 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-both-narrow.png and b/docs/screenshots/ferrobridge/sign-in-both-narrow.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-both.png b/docs/screenshots/ferrobridge/sign-in-both.png index ee93dd3..a5c7993 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-both.png and b/docs/screenshots/ferrobridge/sign-in-both.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-provider-dark.png b/docs/screenshots/ferrobridge/sign-in-provider-dark.png index a1eda66..f9ec900 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-provider-dark.png and b/docs/screenshots/ferrobridge/sign-in-provider-dark.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-provider-narrow-dark.png b/docs/screenshots/ferrobridge/sign-in-provider-narrow-dark.png index 366443f..fbebab9 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-provider-narrow-dark.png and b/docs/screenshots/ferrobridge/sign-in-provider-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-provider-narrow.png b/docs/screenshots/ferrobridge/sign-in-provider-narrow.png index 49584ba..613df6e 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-provider-narrow.png and b/docs/screenshots/ferrobridge/sign-in-provider-narrow.png differ diff --git a/docs/screenshots/ferrobridge/sign-in-provider.png b/docs/screenshots/ferrobridge/sign-in-provider.png index 495ca3f..53c82fa 100644 Binary files a/docs/screenshots/ferrobridge/sign-in-provider.png and b/docs/screenshots/ferrobridge/sign-in-provider.png differ diff --git a/docs/screenshots/ferrobridge/tables-dark.png b/docs/screenshots/ferrobridge/tables-dark.png index 4a795ea..aa149c9 100644 Binary files a/docs/screenshots/ferrobridge/tables-dark.png and b/docs/screenshots/ferrobridge/tables-dark.png differ diff --git a/docs/screenshots/ferrobridge/tables-narrow-dark.png b/docs/screenshots/ferrobridge/tables-narrow-dark.png index cd808de..00ba1ba 100644 Binary files a/docs/screenshots/ferrobridge/tables-narrow-dark.png and b/docs/screenshots/ferrobridge/tables-narrow-dark.png differ diff --git a/docs/screenshots/ferrobridge/tables-narrow.png b/docs/screenshots/ferrobridge/tables-narrow.png index c019a4b..bc125e9 100644 Binary files a/docs/screenshots/ferrobridge/tables-narrow.png and b/docs/screenshots/ferrobridge/tables-narrow.png differ diff --git a/docs/screenshots/ferrobridge/tables.png b/docs/screenshots/ferrobridge/tables.png index a780a5d..2de145b 100644 Binary files a/docs/screenshots/ferrobridge/tables.png and b/docs/screenshots/ferrobridge/tables.png differ diff --git a/docs/screenshots/ferroehr/charts-dark.png b/docs/screenshots/ferroehr/charts-dark.png index 9440627..6e4afc5 100644 Binary files a/docs/screenshots/ferroehr/charts-dark.png and b/docs/screenshots/ferroehr/charts-dark.png differ diff --git a/docs/screenshots/ferroehr/charts-narrow-dark.png b/docs/screenshots/ferroehr/charts-narrow-dark.png index 56c61a6..e0e4b80 100644 Binary files a/docs/screenshots/ferroehr/charts-narrow-dark.png and b/docs/screenshots/ferroehr/charts-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/charts-narrow.png b/docs/screenshots/ferroehr/charts-narrow.png index f9f1e24..84e92c7 100644 Binary files a/docs/screenshots/ferroehr/charts-narrow.png and b/docs/screenshots/ferroehr/charts-narrow.png differ diff --git a/docs/screenshots/ferroehr/charts.png b/docs/screenshots/ferroehr/charts.png index f9aeeb6..f0a43e5 100644 Binary files a/docs/screenshots/ferroehr/charts.png and b/docs/screenshots/ferroehr/charts.png differ diff --git a/docs/screenshots/ferroehr/code-dark.png b/docs/screenshots/ferroehr/code-dark.png index e8a73cf..0ee7144 100644 Binary files a/docs/screenshots/ferroehr/code-dark.png and b/docs/screenshots/ferroehr/code-dark.png differ diff --git a/docs/screenshots/ferroehr/code-narrow-dark.png b/docs/screenshots/ferroehr/code-narrow-dark.png index af205b9..76a9a9b 100644 Binary files a/docs/screenshots/ferroehr/code-narrow-dark.png and b/docs/screenshots/ferroehr/code-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/code-narrow.png b/docs/screenshots/ferroehr/code-narrow.png index c448e1e..b07875e 100644 Binary files a/docs/screenshots/ferroehr/code-narrow.png and b/docs/screenshots/ferroehr/code-narrow.png differ diff --git a/docs/screenshots/ferroehr/code.png b/docs/screenshots/ferroehr/code.png index dbcaffb..fdb6fca 100644 Binary files a/docs/screenshots/ferroehr/code.png and b/docs/screenshots/ferroehr/code.png differ diff --git a/docs/screenshots/ferroehr/forms-dark.png b/docs/screenshots/ferroehr/forms-dark.png index af8d395..11f91d6 100644 Binary files a/docs/screenshots/ferroehr/forms-dark.png and b/docs/screenshots/ferroehr/forms-dark.png differ diff --git a/docs/screenshots/ferroehr/forms-narrow-dark.png b/docs/screenshots/ferroehr/forms-narrow-dark.png index e7f7488..e0b52a5 100644 Binary files a/docs/screenshots/ferroehr/forms-narrow-dark.png and b/docs/screenshots/ferroehr/forms-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/forms-narrow.png b/docs/screenshots/ferroehr/forms-narrow.png index bed3b7b..aab3bfe 100644 Binary files a/docs/screenshots/ferroehr/forms-narrow.png and b/docs/screenshots/ferroehr/forms-narrow.png differ diff --git a/docs/screenshots/ferroehr/forms.png b/docs/screenshots/ferroehr/forms.png index 930a95f..b624b49 100644 Binary files a/docs/screenshots/ferroehr/forms.png and b/docs/screenshots/ferroehr/forms.png differ diff --git a/docs/screenshots/ferroehr/layouts-dark.png b/docs/screenshots/ferroehr/layouts-dark.png index e09d988..d50161a 100644 Binary files a/docs/screenshots/ferroehr/layouts-dark.png and b/docs/screenshots/ferroehr/layouts-dark.png differ diff --git a/docs/screenshots/ferroehr/layouts-narrow-dark.png b/docs/screenshots/ferroehr/layouts-narrow-dark.png index 5034d18..fdeb96a 100644 Binary files a/docs/screenshots/ferroehr/layouts-narrow-dark.png and b/docs/screenshots/ferroehr/layouts-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/layouts-narrow.png b/docs/screenshots/ferroehr/layouts-narrow.png index e77cb50..e5199e8 100644 Binary files a/docs/screenshots/ferroehr/layouts-narrow.png and b/docs/screenshots/ferroehr/layouts-narrow.png differ diff --git a/docs/screenshots/ferroehr/layouts.png b/docs/screenshots/ferroehr/layouts.png index 7b6e8b2..384565b 100644 Binary files a/docs/screenshots/ferroehr/layouts.png and b/docs/screenshots/ferroehr/layouts.png differ diff --git a/docs/screenshots/ferroehr/overlays-dark.png b/docs/screenshots/ferroehr/overlays-dark.png index 02c985a..ff72f18 100644 Binary files a/docs/screenshots/ferroehr/overlays-dark.png and b/docs/screenshots/ferroehr/overlays-dark.png differ diff --git a/docs/screenshots/ferroehr/overlays-narrow-dark.png b/docs/screenshots/ferroehr/overlays-narrow-dark.png index 4798bd5..3607c5a 100644 Binary files a/docs/screenshots/ferroehr/overlays-narrow-dark.png and b/docs/screenshots/ferroehr/overlays-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/overlays-narrow.png b/docs/screenshots/ferroehr/overlays-narrow.png index 5492cfb..a057825 100644 Binary files a/docs/screenshots/ferroehr/overlays-narrow.png and b/docs/screenshots/ferroehr/overlays-narrow.png differ diff --git a/docs/screenshots/ferroehr/overlays.png b/docs/screenshots/ferroehr/overlays.png index 02341c8..509b017 100644 Binary files a/docs/screenshots/ferroehr/overlays.png and b/docs/screenshots/ferroehr/overlays.png differ diff --git a/docs/screenshots/ferroehr/overview-dark.png b/docs/screenshots/ferroehr/overview-dark.png index d825962..52b9228 100644 Binary files a/docs/screenshots/ferroehr/overview-dark.png and b/docs/screenshots/ferroehr/overview-dark.png differ diff --git a/docs/screenshots/ferroehr/overview-narrow-dark.png b/docs/screenshots/ferroehr/overview-narrow-dark.png index 0323347..a2fa74e 100644 Binary files a/docs/screenshots/ferroehr/overview-narrow-dark.png and b/docs/screenshots/ferroehr/overview-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/overview-narrow.png b/docs/screenshots/ferroehr/overview-narrow.png index d677af4..57319f7 100644 Binary files a/docs/screenshots/ferroehr/overview-narrow.png and b/docs/screenshots/ferroehr/overview-narrow.png differ diff --git a/docs/screenshots/ferroehr/overview.png b/docs/screenshots/ferroehr/overview.png index 839d1de..51fceae 100644 Binary files a/docs/screenshots/ferroehr/overview.png and b/docs/screenshots/ferroehr/overview.png differ diff --git a/docs/screenshots/ferroehr/sign-in-basic-dark.png b/docs/screenshots/ferroehr/sign-in-basic-dark.png index 22133ec..88cb1d6 100644 Binary files a/docs/screenshots/ferroehr/sign-in-basic-dark.png and b/docs/screenshots/ferroehr/sign-in-basic-dark.png differ diff --git a/docs/screenshots/ferroehr/sign-in-basic-narrow-dark.png b/docs/screenshots/ferroehr/sign-in-basic-narrow-dark.png index 34516b6..3438c5e 100644 Binary files a/docs/screenshots/ferroehr/sign-in-basic-narrow-dark.png and b/docs/screenshots/ferroehr/sign-in-basic-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/sign-in-basic-narrow.png b/docs/screenshots/ferroehr/sign-in-basic-narrow.png index 7bae421..96f4b0f 100644 Binary files a/docs/screenshots/ferroehr/sign-in-basic-narrow.png and b/docs/screenshots/ferroehr/sign-in-basic-narrow.png differ diff --git a/docs/screenshots/ferroehr/sign-in-basic.png b/docs/screenshots/ferroehr/sign-in-basic.png index 25c46bf..b3c1f3a 100644 Binary files a/docs/screenshots/ferroehr/sign-in-basic.png and b/docs/screenshots/ferroehr/sign-in-basic.png differ diff --git a/docs/screenshots/ferroehr/sign-in-both-dark.png b/docs/screenshots/ferroehr/sign-in-both-dark.png index 352bcbf..5d426a5 100644 Binary files a/docs/screenshots/ferroehr/sign-in-both-dark.png and b/docs/screenshots/ferroehr/sign-in-both-dark.png differ diff --git a/docs/screenshots/ferroehr/sign-in-both-narrow-dark.png b/docs/screenshots/ferroehr/sign-in-both-narrow-dark.png index 7fba4a3..7f87660 100644 Binary files a/docs/screenshots/ferroehr/sign-in-both-narrow-dark.png and b/docs/screenshots/ferroehr/sign-in-both-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/sign-in-both-narrow.png b/docs/screenshots/ferroehr/sign-in-both-narrow.png index 4fbbb29..26155df 100644 Binary files a/docs/screenshots/ferroehr/sign-in-both-narrow.png and b/docs/screenshots/ferroehr/sign-in-both-narrow.png differ diff --git a/docs/screenshots/ferroehr/sign-in-both.png b/docs/screenshots/ferroehr/sign-in-both.png index b714877..0173209 100644 Binary files a/docs/screenshots/ferroehr/sign-in-both.png and b/docs/screenshots/ferroehr/sign-in-both.png differ diff --git a/docs/screenshots/ferroehr/sign-in-provider-dark.png b/docs/screenshots/ferroehr/sign-in-provider-dark.png index cd851ba..6ab00bd 100644 Binary files a/docs/screenshots/ferroehr/sign-in-provider-dark.png and b/docs/screenshots/ferroehr/sign-in-provider-dark.png differ diff --git a/docs/screenshots/ferroehr/sign-in-provider-narrow-dark.png b/docs/screenshots/ferroehr/sign-in-provider-narrow-dark.png index 8a44af3..08f4d17 100644 Binary files a/docs/screenshots/ferroehr/sign-in-provider-narrow-dark.png and b/docs/screenshots/ferroehr/sign-in-provider-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/sign-in-provider-narrow.png b/docs/screenshots/ferroehr/sign-in-provider-narrow.png index 1d98557..a7e3ad7 100644 Binary files a/docs/screenshots/ferroehr/sign-in-provider-narrow.png and b/docs/screenshots/ferroehr/sign-in-provider-narrow.png differ diff --git a/docs/screenshots/ferroehr/sign-in-provider.png b/docs/screenshots/ferroehr/sign-in-provider.png index 429672f..0d42760 100644 Binary files a/docs/screenshots/ferroehr/sign-in-provider.png and b/docs/screenshots/ferroehr/sign-in-provider.png differ diff --git a/docs/screenshots/ferroehr/tables-dark.png b/docs/screenshots/ferroehr/tables-dark.png index 8476855..617c1a3 100644 Binary files a/docs/screenshots/ferroehr/tables-dark.png and b/docs/screenshots/ferroehr/tables-dark.png differ diff --git a/docs/screenshots/ferroehr/tables-narrow-dark.png b/docs/screenshots/ferroehr/tables-narrow-dark.png index 0165ca8..6fb9b3f 100644 Binary files a/docs/screenshots/ferroehr/tables-narrow-dark.png and b/docs/screenshots/ferroehr/tables-narrow-dark.png differ diff --git a/docs/screenshots/ferroehr/tables-narrow.png b/docs/screenshots/ferroehr/tables-narrow.png index 6328a5a..c6382dc 100644 Binary files a/docs/screenshots/ferroehr/tables-narrow.png and b/docs/screenshots/ferroehr/tables-narrow.png differ diff --git a/docs/screenshots/ferroehr/tables.png b/docs/screenshots/ferroehr/tables.png index 9ddfa42..fba9a60 100644 Binary files a/docs/screenshots/ferroehr/tables.png and b/docs/screenshots/ferroehr/tables.png differ diff --git a/docs/screenshots/ferrofed/charts-dark.png b/docs/screenshots/ferrofed/charts-dark.png index 8ff84e8..17026e2 100644 Binary files a/docs/screenshots/ferrofed/charts-dark.png and b/docs/screenshots/ferrofed/charts-dark.png differ diff --git a/docs/screenshots/ferrofed/charts-narrow-dark.png b/docs/screenshots/ferrofed/charts-narrow-dark.png index 5b5b505..1ba6e5f 100644 Binary files a/docs/screenshots/ferrofed/charts-narrow-dark.png and b/docs/screenshots/ferrofed/charts-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/charts-narrow.png b/docs/screenshots/ferrofed/charts-narrow.png index 882cc55..ecd4995 100644 Binary files a/docs/screenshots/ferrofed/charts-narrow.png and b/docs/screenshots/ferrofed/charts-narrow.png differ diff --git a/docs/screenshots/ferrofed/charts.png b/docs/screenshots/ferrofed/charts.png index 8c1a3ef..22b51a7 100644 Binary files a/docs/screenshots/ferrofed/charts.png and b/docs/screenshots/ferrofed/charts.png differ diff --git a/docs/screenshots/ferrofed/code-dark.png b/docs/screenshots/ferrofed/code-dark.png index 524b224..0f2626b 100644 Binary files a/docs/screenshots/ferrofed/code-dark.png and b/docs/screenshots/ferrofed/code-dark.png differ diff --git a/docs/screenshots/ferrofed/code-narrow-dark.png b/docs/screenshots/ferrofed/code-narrow-dark.png index c09e8ec..88084ab 100644 Binary files a/docs/screenshots/ferrofed/code-narrow-dark.png and b/docs/screenshots/ferrofed/code-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/code-narrow.png b/docs/screenshots/ferrofed/code-narrow.png index 8f844f5..0ab6459 100644 Binary files a/docs/screenshots/ferrofed/code-narrow.png and b/docs/screenshots/ferrofed/code-narrow.png differ diff --git a/docs/screenshots/ferrofed/code.png b/docs/screenshots/ferrofed/code.png index 0e8cf14..d8927c7 100644 Binary files a/docs/screenshots/ferrofed/code.png and b/docs/screenshots/ferrofed/code.png differ diff --git a/docs/screenshots/ferrofed/forms-dark.png b/docs/screenshots/ferrofed/forms-dark.png index 0d965a4..fcea260 100644 Binary files a/docs/screenshots/ferrofed/forms-dark.png and b/docs/screenshots/ferrofed/forms-dark.png differ diff --git a/docs/screenshots/ferrofed/forms-narrow-dark.png b/docs/screenshots/ferrofed/forms-narrow-dark.png index 711a2a4..bfa662a 100644 Binary files a/docs/screenshots/ferrofed/forms-narrow-dark.png and b/docs/screenshots/ferrofed/forms-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/forms-narrow.png b/docs/screenshots/ferrofed/forms-narrow.png index ac50001..db9ffef 100644 Binary files a/docs/screenshots/ferrofed/forms-narrow.png and b/docs/screenshots/ferrofed/forms-narrow.png differ diff --git a/docs/screenshots/ferrofed/forms.png b/docs/screenshots/ferrofed/forms.png index 924a53c..4d04621 100644 Binary files a/docs/screenshots/ferrofed/forms.png and b/docs/screenshots/ferrofed/forms.png differ diff --git a/docs/screenshots/ferrofed/layouts-dark.png b/docs/screenshots/ferrofed/layouts-dark.png index 3f6d426..feca83d 100644 Binary files a/docs/screenshots/ferrofed/layouts-dark.png and b/docs/screenshots/ferrofed/layouts-dark.png differ diff --git a/docs/screenshots/ferrofed/layouts-narrow-dark.png b/docs/screenshots/ferrofed/layouts-narrow-dark.png index 60f02ea..1092c62 100644 Binary files a/docs/screenshots/ferrofed/layouts-narrow-dark.png and b/docs/screenshots/ferrofed/layouts-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/layouts-narrow.png b/docs/screenshots/ferrofed/layouts-narrow.png index 471caf1..9a7c42b 100644 Binary files a/docs/screenshots/ferrofed/layouts-narrow.png and b/docs/screenshots/ferrofed/layouts-narrow.png differ diff --git a/docs/screenshots/ferrofed/layouts.png b/docs/screenshots/ferrofed/layouts.png index 2fb481c..4e73fad 100644 Binary files a/docs/screenshots/ferrofed/layouts.png and b/docs/screenshots/ferrofed/layouts.png differ diff --git a/docs/screenshots/ferrofed/overlays-dark.png b/docs/screenshots/ferrofed/overlays-dark.png index 86705ce..e48e8c7 100644 Binary files a/docs/screenshots/ferrofed/overlays-dark.png and b/docs/screenshots/ferrofed/overlays-dark.png differ diff --git a/docs/screenshots/ferrofed/overlays-narrow-dark.png b/docs/screenshots/ferrofed/overlays-narrow-dark.png index 19fe0fd..64df72c 100644 Binary files a/docs/screenshots/ferrofed/overlays-narrow-dark.png and b/docs/screenshots/ferrofed/overlays-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/overlays-narrow.png b/docs/screenshots/ferrofed/overlays-narrow.png index ea81fde..df52810 100644 Binary files a/docs/screenshots/ferrofed/overlays-narrow.png and b/docs/screenshots/ferrofed/overlays-narrow.png differ diff --git a/docs/screenshots/ferrofed/overlays.png b/docs/screenshots/ferrofed/overlays.png index 90fb1b8..a8f77a9 100644 Binary files a/docs/screenshots/ferrofed/overlays.png and b/docs/screenshots/ferrofed/overlays.png differ diff --git a/docs/screenshots/ferrofed/overview-dark.png b/docs/screenshots/ferrofed/overview-dark.png index 9d4f9fd..0789be6 100644 Binary files a/docs/screenshots/ferrofed/overview-dark.png and b/docs/screenshots/ferrofed/overview-dark.png differ diff --git a/docs/screenshots/ferrofed/overview-narrow-dark.png b/docs/screenshots/ferrofed/overview-narrow-dark.png index cb268c4..09960e2 100644 Binary files a/docs/screenshots/ferrofed/overview-narrow-dark.png and b/docs/screenshots/ferrofed/overview-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/overview-narrow.png b/docs/screenshots/ferrofed/overview-narrow.png index e8684d2..98dfc06 100644 Binary files a/docs/screenshots/ferrofed/overview-narrow.png and b/docs/screenshots/ferrofed/overview-narrow.png differ diff --git a/docs/screenshots/ferrofed/overview.png b/docs/screenshots/ferrofed/overview.png index 8a36915..c1e9fd2 100644 Binary files a/docs/screenshots/ferrofed/overview.png and b/docs/screenshots/ferrofed/overview.png differ diff --git a/docs/screenshots/ferrofed/sign-in-basic-dark.png b/docs/screenshots/ferrofed/sign-in-basic-dark.png index 418e693..7a093f2 100644 Binary files a/docs/screenshots/ferrofed/sign-in-basic-dark.png and b/docs/screenshots/ferrofed/sign-in-basic-dark.png differ diff --git a/docs/screenshots/ferrofed/sign-in-basic-narrow-dark.png b/docs/screenshots/ferrofed/sign-in-basic-narrow-dark.png index 5290215..af58fbb 100644 Binary files a/docs/screenshots/ferrofed/sign-in-basic-narrow-dark.png and b/docs/screenshots/ferrofed/sign-in-basic-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/sign-in-basic-narrow.png b/docs/screenshots/ferrofed/sign-in-basic-narrow.png index b3f60f6..a694baf 100644 Binary files a/docs/screenshots/ferrofed/sign-in-basic-narrow.png and b/docs/screenshots/ferrofed/sign-in-basic-narrow.png differ diff --git a/docs/screenshots/ferrofed/sign-in-basic.png b/docs/screenshots/ferrofed/sign-in-basic.png index 68d80b6..0d1bd12 100644 Binary files a/docs/screenshots/ferrofed/sign-in-basic.png and b/docs/screenshots/ferrofed/sign-in-basic.png differ diff --git a/docs/screenshots/ferrofed/sign-in-both-dark.png b/docs/screenshots/ferrofed/sign-in-both-dark.png index f6f292d..e0a3a99 100644 Binary files a/docs/screenshots/ferrofed/sign-in-both-dark.png and b/docs/screenshots/ferrofed/sign-in-both-dark.png differ diff --git a/docs/screenshots/ferrofed/sign-in-both-narrow-dark.png b/docs/screenshots/ferrofed/sign-in-both-narrow-dark.png index d689c3a..b54e6dc 100644 Binary files a/docs/screenshots/ferrofed/sign-in-both-narrow-dark.png and b/docs/screenshots/ferrofed/sign-in-both-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/sign-in-both-narrow.png b/docs/screenshots/ferrofed/sign-in-both-narrow.png index bb92e74..9693c54 100644 Binary files a/docs/screenshots/ferrofed/sign-in-both-narrow.png and b/docs/screenshots/ferrofed/sign-in-both-narrow.png differ diff --git a/docs/screenshots/ferrofed/sign-in-both.png b/docs/screenshots/ferrofed/sign-in-both.png index cc70e08..ea2c042 100644 Binary files a/docs/screenshots/ferrofed/sign-in-both.png and b/docs/screenshots/ferrofed/sign-in-both.png differ diff --git a/docs/screenshots/ferrofed/sign-in-provider-dark.png b/docs/screenshots/ferrofed/sign-in-provider-dark.png index ed37e4d..f7a7457 100644 Binary files a/docs/screenshots/ferrofed/sign-in-provider-dark.png and b/docs/screenshots/ferrofed/sign-in-provider-dark.png differ diff --git a/docs/screenshots/ferrofed/sign-in-provider-narrow-dark.png b/docs/screenshots/ferrofed/sign-in-provider-narrow-dark.png index d83b36f..247b8db 100644 Binary files a/docs/screenshots/ferrofed/sign-in-provider-narrow-dark.png and b/docs/screenshots/ferrofed/sign-in-provider-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/sign-in-provider-narrow.png b/docs/screenshots/ferrofed/sign-in-provider-narrow.png index 345f3ec..09dd176 100644 Binary files a/docs/screenshots/ferrofed/sign-in-provider-narrow.png and b/docs/screenshots/ferrofed/sign-in-provider-narrow.png differ diff --git a/docs/screenshots/ferrofed/sign-in-provider.png b/docs/screenshots/ferrofed/sign-in-provider.png index ef4e9e6..957f553 100644 Binary files a/docs/screenshots/ferrofed/sign-in-provider.png and b/docs/screenshots/ferrofed/sign-in-provider.png differ diff --git a/docs/screenshots/ferrofed/tables-dark.png b/docs/screenshots/ferrofed/tables-dark.png index 168f350..f309549 100644 Binary files a/docs/screenshots/ferrofed/tables-dark.png and b/docs/screenshots/ferrofed/tables-dark.png differ diff --git a/docs/screenshots/ferrofed/tables-narrow-dark.png b/docs/screenshots/ferrofed/tables-narrow-dark.png index 82bad3b..288fec3 100644 Binary files a/docs/screenshots/ferrofed/tables-narrow-dark.png and b/docs/screenshots/ferrofed/tables-narrow-dark.png differ diff --git a/docs/screenshots/ferrofed/tables-narrow.png b/docs/screenshots/ferrofed/tables-narrow.png index 5cc59ea..5a47cf6 100644 Binary files a/docs/screenshots/ferrofed/tables-narrow.png and b/docs/screenshots/ferrofed/tables-narrow.png differ diff --git a/docs/screenshots/ferrofed/tables.png b/docs/screenshots/ferrofed/tables.png index e424507..a4f6b7e 100644 Binary files a/docs/screenshots/ferrofed/tables.png and b/docs/screenshots/ferrofed/tables.png differ diff --git a/docs/screenshots/ferroterm/charts-dark.png b/docs/screenshots/ferroterm/charts-dark.png index 7a08c2e..f01a21d 100644 Binary files a/docs/screenshots/ferroterm/charts-dark.png and b/docs/screenshots/ferroterm/charts-dark.png differ diff --git a/docs/screenshots/ferroterm/charts-narrow-dark.png b/docs/screenshots/ferroterm/charts-narrow-dark.png index f362d5f..7e9cb56 100644 Binary files a/docs/screenshots/ferroterm/charts-narrow-dark.png and b/docs/screenshots/ferroterm/charts-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/charts-narrow.png b/docs/screenshots/ferroterm/charts-narrow.png index 6056047..ab2fd52 100644 Binary files a/docs/screenshots/ferroterm/charts-narrow.png and b/docs/screenshots/ferroterm/charts-narrow.png differ diff --git a/docs/screenshots/ferroterm/charts.png b/docs/screenshots/ferroterm/charts.png index ba1bcc8..0383892 100644 Binary files a/docs/screenshots/ferroterm/charts.png and b/docs/screenshots/ferroterm/charts.png differ diff --git a/docs/screenshots/ferroterm/code-dark.png b/docs/screenshots/ferroterm/code-dark.png index ed2cbe8..193e7f9 100644 Binary files a/docs/screenshots/ferroterm/code-dark.png and b/docs/screenshots/ferroterm/code-dark.png differ diff --git a/docs/screenshots/ferroterm/code-narrow-dark.png b/docs/screenshots/ferroterm/code-narrow-dark.png index e072790..e02977e 100644 Binary files a/docs/screenshots/ferroterm/code-narrow-dark.png and b/docs/screenshots/ferroterm/code-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/code-narrow.png b/docs/screenshots/ferroterm/code-narrow.png index 8132a23..c133b2b 100644 Binary files a/docs/screenshots/ferroterm/code-narrow.png and b/docs/screenshots/ferroterm/code-narrow.png differ diff --git a/docs/screenshots/ferroterm/code.png b/docs/screenshots/ferroterm/code.png index 97c0790..0f016f6 100644 Binary files a/docs/screenshots/ferroterm/code.png and b/docs/screenshots/ferroterm/code.png differ diff --git a/docs/screenshots/ferroterm/forms-dark.png b/docs/screenshots/ferroterm/forms-dark.png index 8d6ad28..eacedd0 100644 Binary files a/docs/screenshots/ferroterm/forms-dark.png and b/docs/screenshots/ferroterm/forms-dark.png differ diff --git a/docs/screenshots/ferroterm/forms-narrow-dark.png b/docs/screenshots/ferroterm/forms-narrow-dark.png index 2ab7a6c..e816441 100644 Binary files a/docs/screenshots/ferroterm/forms-narrow-dark.png and b/docs/screenshots/ferroterm/forms-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/forms-narrow.png b/docs/screenshots/ferroterm/forms-narrow.png index 9793733..83de605 100644 Binary files a/docs/screenshots/ferroterm/forms-narrow.png and b/docs/screenshots/ferroterm/forms-narrow.png differ diff --git a/docs/screenshots/ferroterm/forms.png b/docs/screenshots/ferroterm/forms.png index 9653dfc..7633d63 100644 Binary files a/docs/screenshots/ferroterm/forms.png and b/docs/screenshots/ferroterm/forms.png differ diff --git a/docs/screenshots/ferroterm/layouts-dark.png b/docs/screenshots/ferroterm/layouts-dark.png index baf69db..f346c8a 100644 Binary files a/docs/screenshots/ferroterm/layouts-dark.png and b/docs/screenshots/ferroterm/layouts-dark.png differ diff --git a/docs/screenshots/ferroterm/layouts-narrow-dark.png b/docs/screenshots/ferroterm/layouts-narrow-dark.png index 9e92cf4..ce40df0 100644 Binary files a/docs/screenshots/ferroterm/layouts-narrow-dark.png and b/docs/screenshots/ferroterm/layouts-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/layouts-narrow.png b/docs/screenshots/ferroterm/layouts-narrow.png index d0b64fe..b6776da 100644 Binary files a/docs/screenshots/ferroterm/layouts-narrow.png and b/docs/screenshots/ferroterm/layouts-narrow.png differ diff --git a/docs/screenshots/ferroterm/layouts.png b/docs/screenshots/ferroterm/layouts.png index 5898685..ee718ad 100644 Binary files a/docs/screenshots/ferroterm/layouts.png and b/docs/screenshots/ferroterm/layouts.png differ diff --git a/docs/screenshots/ferroterm/overlays-dark.png b/docs/screenshots/ferroterm/overlays-dark.png index fa26409..52e5b13 100644 Binary files a/docs/screenshots/ferroterm/overlays-dark.png and b/docs/screenshots/ferroterm/overlays-dark.png differ diff --git a/docs/screenshots/ferroterm/overlays-narrow-dark.png b/docs/screenshots/ferroterm/overlays-narrow-dark.png index 39c36c6..6c97917 100644 Binary files a/docs/screenshots/ferroterm/overlays-narrow-dark.png and b/docs/screenshots/ferroterm/overlays-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/overlays-narrow.png b/docs/screenshots/ferroterm/overlays-narrow.png index a75878f..5bb72bc 100644 Binary files a/docs/screenshots/ferroterm/overlays-narrow.png and b/docs/screenshots/ferroterm/overlays-narrow.png differ diff --git a/docs/screenshots/ferroterm/overlays.png b/docs/screenshots/ferroterm/overlays.png index 5d63aea..dbabcba 100644 Binary files a/docs/screenshots/ferroterm/overlays.png and b/docs/screenshots/ferroterm/overlays.png differ diff --git a/docs/screenshots/ferroterm/overview-dark.png b/docs/screenshots/ferroterm/overview-dark.png index a03f71c..695f4ac 100644 Binary files a/docs/screenshots/ferroterm/overview-dark.png and b/docs/screenshots/ferroterm/overview-dark.png differ diff --git a/docs/screenshots/ferroterm/overview-narrow-dark.png b/docs/screenshots/ferroterm/overview-narrow-dark.png index 892984a..f9ec41a 100644 Binary files a/docs/screenshots/ferroterm/overview-narrow-dark.png and b/docs/screenshots/ferroterm/overview-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/overview-narrow.png b/docs/screenshots/ferroterm/overview-narrow.png index 0218ec4..df612ef 100644 Binary files a/docs/screenshots/ferroterm/overview-narrow.png and b/docs/screenshots/ferroterm/overview-narrow.png differ diff --git a/docs/screenshots/ferroterm/overview.png b/docs/screenshots/ferroterm/overview.png index 7af6820..bf68081 100644 Binary files a/docs/screenshots/ferroterm/overview.png and b/docs/screenshots/ferroterm/overview.png differ diff --git a/docs/screenshots/ferroterm/sign-in-basic-dark.png b/docs/screenshots/ferroterm/sign-in-basic-dark.png index 0ec274c..1d057a0 100644 Binary files a/docs/screenshots/ferroterm/sign-in-basic-dark.png and b/docs/screenshots/ferroterm/sign-in-basic-dark.png differ diff --git a/docs/screenshots/ferroterm/sign-in-basic-narrow-dark.png b/docs/screenshots/ferroterm/sign-in-basic-narrow-dark.png index 90a7fdf..45c4b6a 100644 Binary files a/docs/screenshots/ferroterm/sign-in-basic-narrow-dark.png and b/docs/screenshots/ferroterm/sign-in-basic-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/sign-in-basic-narrow.png b/docs/screenshots/ferroterm/sign-in-basic-narrow.png index 32e1ebb..0695058 100644 Binary files a/docs/screenshots/ferroterm/sign-in-basic-narrow.png and b/docs/screenshots/ferroterm/sign-in-basic-narrow.png differ diff --git a/docs/screenshots/ferroterm/sign-in-basic.png b/docs/screenshots/ferroterm/sign-in-basic.png index 2c69991..bbabbca 100644 Binary files a/docs/screenshots/ferroterm/sign-in-basic.png and b/docs/screenshots/ferroterm/sign-in-basic.png differ diff --git a/docs/screenshots/ferroterm/sign-in-both-dark.png b/docs/screenshots/ferroterm/sign-in-both-dark.png index e4c2be9..5753eb6 100644 Binary files a/docs/screenshots/ferroterm/sign-in-both-dark.png and b/docs/screenshots/ferroterm/sign-in-both-dark.png differ diff --git a/docs/screenshots/ferroterm/sign-in-both-narrow-dark.png b/docs/screenshots/ferroterm/sign-in-both-narrow-dark.png index c8ed576..d9ebaf9 100644 Binary files a/docs/screenshots/ferroterm/sign-in-both-narrow-dark.png and b/docs/screenshots/ferroterm/sign-in-both-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/sign-in-both-narrow.png b/docs/screenshots/ferroterm/sign-in-both-narrow.png index bf3f055..2c35bfd 100644 Binary files a/docs/screenshots/ferroterm/sign-in-both-narrow.png and b/docs/screenshots/ferroterm/sign-in-both-narrow.png differ diff --git a/docs/screenshots/ferroterm/sign-in-both.png b/docs/screenshots/ferroterm/sign-in-both.png index 41d950e..a55e612 100644 Binary files a/docs/screenshots/ferroterm/sign-in-both.png and b/docs/screenshots/ferroterm/sign-in-both.png differ diff --git a/docs/screenshots/ferroterm/sign-in-provider-dark.png b/docs/screenshots/ferroterm/sign-in-provider-dark.png index d5a33a2..e9eebcb 100644 Binary files a/docs/screenshots/ferroterm/sign-in-provider-dark.png and b/docs/screenshots/ferroterm/sign-in-provider-dark.png differ diff --git a/docs/screenshots/ferroterm/sign-in-provider-narrow-dark.png b/docs/screenshots/ferroterm/sign-in-provider-narrow-dark.png index a4131c1..711a6fb 100644 Binary files a/docs/screenshots/ferroterm/sign-in-provider-narrow-dark.png and b/docs/screenshots/ferroterm/sign-in-provider-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/sign-in-provider-narrow.png b/docs/screenshots/ferroterm/sign-in-provider-narrow.png index 94c8eb8..580e9be 100644 Binary files a/docs/screenshots/ferroterm/sign-in-provider-narrow.png and b/docs/screenshots/ferroterm/sign-in-provider-narrow.png differ diff --git a/docs/screenshots/ferroterm/sign-in-provider.png b/docs/screenshots/ferroterm/sign-in-provider.png index 955381b..97e6bd8 100644 Binary files a/docs/screenshots/ferroterm/sign-in-provider.png and b/docs/screenshots/ferroterm/sign-in-provider.png differ diff --git a/docs/screenshots/ferroterm/tables-dark.png b/docs/screenshots/ferroterm/tables-dark.png index 1e2b9cf..b6597bc 100644 Binary files a/docs/screenshots/ferroterm/tables-dark.png and b/docs/screenshots/ferroterm/tables-dark.png differ diff --git a/docs/screenshots/ferroterm/tables-narrow-dark.png b/docs/screenshots/ferroterm/tables-narrow-dark.png index 7cf5d88..270d22d 100644 Binary files a/docs/screenshots/ferroterm/tables-narrow-dark.png and b/docs/screenshots/ferroterm/tables-narrow-dark.png differ diff --git a/docs/screenshots/ferroterm/tables-narrow.png b/docs/screenshots/ferroterm/tables-narrow.png index 8bd398f..0b186de 100644 Binary files a/docs/screenshots/ferroterm/tables-narrow.png and b/docs/screenshots/ferroterm/tables-narrow.png differ diff --git a/docs/screenshots/ferroterm/tables.png b/docs/screenshots/ferroterm/tables.png index 38de35e..3a682da 100644 Binary files a/docs/screenshots/ferroterm/tables.png and b/docs/screenshots/ferroterm/tables.png differ diff --git a/docs/screenshots/index-dark.png b/docs/screenshots/index-dark.png index 0809a88..38b78a1 100644 Binary files a/docs/screenshots/index-dark.png and b/docs/screenshots/index-dark.png differ diff --git a/docs/screenshots/index-narrow-dark.png b/docs/screenshots/index-narrow-dark.png index cef253a..70be2ee 100644 Binary files a/docs/screenshots/index-narrow-dark.png and b/docs/screenshots/index-narrow-dark.png differ diff --git a/docs/screenshots/index-narrow.png b/docs/screenshots/index-narrow.png index 9c1b96d..92de5ea 100644 Binary files a/docs/screenshots/index-narrow.png and b/docs/screenshots/index-narrow.png differ diff --git a/docs/screenshots/index.png b/docs/screenshots/index.png index 1ca42ec..552d408 100644 Binary files a/docs/screenshots/index.png and b/docs/screenshots/index.png differ diff --git a/docs/screenshots/not-found-dark.png b/docs/screenshots/not-found-dark.png index 680a8a6..007365e 100644 Binary files a/docs/screenshots/not-found-dark.png and b/docs/screenshots/not-found-dark.png differ diff --git a/docs/screenshots/not-found-narrow-dark.png b/docs/screenshots/not-found-narrow-dark.png index 9c7a73c..b122d9a 100644 Binary files a/docs/screenshots/not-found-narrow-dark.png and b/docs/screenshots/not-found-narrow-dark.png differ diff --git a/docs/screenshots/not-found-narrow.png b/docs/screenshots/not-found-narrow.png index 13f8c56..927a756 100644 Binary files a/docs/screenshots/not-found-narrow.png and b/docs/screenshots/not-found-narrow.png differ diff --git a/docs/screenshots/not-found.png b/docs/screenshots/not-found.png index b9ec681..3f957c6 100644 Binary files a/docs/screenshots/not-found.png and b/docs/screenshots/not-found.png differ diff --git a/docs/screenshots/states/ferrobridge/combobox-hover-dark.png b/docs/screenshots/states/ferrobridge/combobox-hover-dark.png index 3224201..408ec6f 100644 Binary files a/docs/screenshots/states/ferrobridge/combobox-hover-dark.png and b/docs/screenshots/states/ferrobridge/combobox-hover-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/combobox-hover.png b/docs/screenshots/states/ferrobridge/combobox-hover.png index 37b23db..211408f 100644 Binary files a/docs/screenshots/states/ferrobridge/combobox-hover.png and b/docs/screenshots/states/ferrobridge/combobox-hover.png differ diff --git a/docs/screenshots/states/ferrobridge/combobox-keyboard-dark.png b/docs/screenshots/states/ferrobridge/combobox-keyboard-dark.png index e65ef8d..c2abdb7 100644 Binary files a/docs/screenshots/states/ferrobridge/combobox-keyboard-dark.png and b/docs/screenshots/states/ferrobridge/combobox-keyboard-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/combobox-keyboard.png b/docs/screenshots/states/ferrobridge/combobox-keyboard.png index 451296b..3b47d95 100644 Binary files a/docs/screenshots/states/ferrobridge/combobox-keyboard.png and b/docs/screenshots/states/ferrobridge/combobox-keyboard.png differ diff --git a/docs/screenshots/states/ferrobridge/combobox-open-dark.png b/docs/screenshots/states/ferrobridge/combobox-open-dark.png index 57fadfe..acb55ff 100644 Binary files a/docs/screenshots/states/ferrobridge/combobox-open-dark.png and b/docs/screenshots/states/ferrobridge/combobox-open-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/combobox-open.png b/docs/screenshots/states/ferrobridge/combobox-open.png index 7f2161f..d033d69 100644 Binary files a/docs/screenshots/states/ferrobridge/combobox-open.png and b/docs/screenshots/states/ferrobridge/combobox-open.png differ diff --git a/docs/screenshots/states/ferrobridge/density-compact-dark.png b/docs/screenshots/states/ferrobridge/density-compact-dark.png index 5dff0ba..0238ec0 100644 Binary files a/docs/screenshots/states/ferrobridge/density-compact-dark.png and b/docs/screenshots/states/ferrobridge/density-compact-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/density-compact.png b/docs/screenshots/states/ferrobridge/density-compact.png index eba5bea..4e1aea6 100644 Binary files a/docs/screenshots/states/ferrobridge/density-compact.png and b/docs/screenshots/states/ferrobridge/density-compact.png differ diff --git a/docs/screenshots/states/ferrobridge/dialog-danger-dark.png b/docs/screenshots/states/ferrobridge/dialog-danger-dark.png index 53f64dd..8fa2bc4 100644 Binary files a/docs/screenshots/states/ferrobridge/dialog-danger-dark.png and b/docs/screenshots/states/ferrobridge/dialog-danger-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/dialog-danger.png b/docs/screenshots/states/ferrobridge/dialog-danger.png index f2ba6c1..e296a92 100644 Binary files a/docs/screenshots/states/ferrobridge/dialog-danger.png and b/docs/screenshots/states/ferrobridge/dialog-danger.png differ diff --git a/docs/screenshots/states/ferrobridge/dialog-open-dark.png b/docs/screenshots/states/ferrobridge/dialog-open-dark.png index 4a3cee9..cdc89d1 100644 Binary files a/docs/screenshots/states/ferrobridge/dialog-open-dark.png and b/docs/screenshots/states/ferrobridge/dialog-open-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/dialog-open.png b/docs/screenshots/states/ferrobridge/dialog-open.png index 7e31fb6..8adcd96 100644 Binary files a/docs/screenshots/states/ferrobridge/dialog-open.png and b/docs/screenshots/states/ferrobridge/dialog-open.png differ diff --git a/docs/screenshots/states/ferrobridge/drawer-open-dark.png b/docs/screenshots/states/ferrobridge/drawer-open-dark.png index c22b28c..a55cc52 100644 Binary files a/docs/screenshots/states/ferrobridge/drawer-open-dark.png and b/docs/screenshots/states/ferrobridge/drawer-open-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/drawer-open.png b/docs/screenshots/states/ferrobridge/drawer-open.png index 1aaa085..f3aa60e 100644 Binary files a/docs/screenshots/states/ferrobridge/drawer-open.png and b/docs/screenshots/states/ferrobridge/drawer-open.png differ diff --git a/docs/screenshots/states/ferrobridge/focus-search-dark.png b/docs/screenshots/states/ferrobridge/focus-search-dark.png index 4898fa7..1ee1ae8 100644 Binary files a/docs/screenshots/states/ferrobridge/focus-search-dark.png and b/docs/screenshots/states/ferrobridge/focus-search-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/focus-search.png b/docs/screenshots/states/ferrobridge/focus-search.png index 3981d51..e2dc4ae 100644 Binary files a/docs/screenshots/states/ferrobridge/focus-search.png and b/docs/screenshots/states/ferrobridge/focus-search.png differ diff --git a/docs/screenshots/states/ferrobridge/focus-theme-toggle-dark.png b/docs/screenshots/states/ferrobridge/focus-theme-toggle-dark.png index 409d35a..5506204 100644 Binary files a/docs/screenshots/states/ferrobridge/focus-theme-toggle-dark.png and b/docs/screenshots/states/ferrobridge/focus-theme-toggle-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/focus-theme-toggle.png b/docs/screenshots/states/ferrobridge/focus-theme-toggle.png index 66f5f23..1b1ec26 100644 Binary files a/docs/screenshots/states/ferrobridge/focus-theme-toggle.png and b/docs/screenshots/states/ferrobridge/focus-theme-toggle.png differ diff --git a/docs/screenshots/states/ferrobridge/hover-row-dark.png b/docs/screenshots/states/ferrobridge/hover-row-dark.png index 0bf1ba3..dea3dbc 100644 Binary files a/docs/screenshots/states/ferrobridge/hover-row-dark.png and b/docs/screenshots/states/ferrobridge/hover-row-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/hover-row.png b/docs/screenshots/states/ferrobridge/hover-row.png index d9fd2a6..30b474b 100644 Binary files a/docs/screenshots/states/ferrobridge/hover-row.png and b/docs/screenshots/states/ferrobridge/hover-row.png differ diff --git a/docs/screenshots/states/ferrobridge/modal-open-dark.png b/docs/screenshots/states/ferrobridge/modal-open-dark.png index 352fa9e..2ff6003 100644 Binary files a/docs/screenshots/states/ferrobridge/modal-open-dark.png and b/docs/screenshots/states/ferrobridge/modal-open-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/modal-open.png b/docs/screenshots/states/ferrobridge/modal-open.png index d43dfe4..1ec0022 100644 Binary files a/docs/screenshots/states/ferrobridge/modal-open.png and b/docs/screenshots/states/ferrobridge/modal-open.png differ diff --git a/docs/screenshots/states/ferrobridge/modal-wide-dark.png b/docs/screenshots/states/ferrobridge/modal-wide-dark.png index f66ae30..59b81b7 100644 Binary files a/docs/screenshots/states/ferrobridge/modal-wide-dark.png and b/docs/screenshots/states/ferrobridge/modal-wide-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/modal-wide.png b/docs/screenshots/states/ferrobridge/modal-wide.png index cb7ef56..88e420d 100644 Binary files a/docs/screenshots/states/ferrobridge/modal-wide.png and b/docs/screenshots/states/ferrobridge/modal-wide.png differ diff --git a/docs/screenshots/states/ferrobridge/operator-menu-dark.png b/docs/screenshots/states/ferrobridge/operator-menu-dark.png index bccc886..6a9d9de 100644 Binary files a/docs/screenshots/states/ferrobridge/operator-menu-dark.png and b/docs/screenshots/states/ferrobridge/operator-menu-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/operator-menu.png b/docs/screenshots/states/ferrobridge/operator-menu.png index 8297891..733b913 100644 Binary files a/docs/screenshots/states/ferrobridge/operator-menu.png and b/docs/screenshots/states/ferrobridge/operator-menu.png differ diff --git a/docs/screenshots/states/ferrobridge/side-panel-dark.png b/docs/screenshots/states/ferrobridge/side-panel-dark.png index cdac8fc..d27da16 100644 Binary files a/docs/screenshots/states/ferrobridge/side-panel-dark.png and b/docs/screenshots/states/ferrobridge/side-panel-dark.png differ diff --git a/docs/screenshots/states/ferrobridge/side-panel.png b/docs/screenshots/states/ferrobridge/side-panel.png index b859846..8a6b472 100644 Binary files a/docs/screenshots/states/ferrobridge/side-panel.png and b/docs/screenshots/states/ferrobridge/side-panel.png differ diff --git a/docs/screenshots/states/ferroehr/combobox-hover-dark.png b/docs/screenshots/states/ferroehr/combobox-hover-dark.png index 915f512..7cb3656 100644 Binary files a/docs/screenshots/states/ferroehr/combobox-hover-dark.png and b/docs/screenshots/states/ferroehr/combobox-hover-dark.png differ diff --git a/docs/screenshots/states/ferroehr/combobox-hover.png b/docs/screenshots/states/ferroehr/combobox-hover.png index d8f51c6..a31e58d 100644 Binary files a/docs/screenshots/states/ferroehr/combobox-hover.png and b/docs/screenshots/states/ferroehr/combobox-hover.png differ diff --git a/docs/screenshots/states/ferroehr/combobox-keyboard-dark.png b/docs/screenshots/states/ferroehr/combobox-keyboard-dark.png index 48ba186..082cb52 100644 Binary files a/docs/screenshots/states/ferroehr/combobox-keyboard-dark.png and b/docs/screenshots/states/ferroehr/combobox-keyboard-dark.png differ diff --git a/docs/screenshots/states/ferroehr/combobox-keyboard.png b/docs/screenshots/states/ferroehr/combobox-keyboard.png index 207cedd..4008a45 100644 Binary files a/docs/screenshots/states/ferroehr/combobox-keyboard.png and b/docs/screenshots/states/ferroehr/combobox-keyboard.png differ diff --git a/docs/screenshots/states/ferroehr/combobox-open-dark.png b/docs/screenshots/states/ferroehr/combobox-open-dark.png index 0b53990..71bd85b 100644 Binary files a/docs/screenshots/states/ferroehr/combobox-open-dark.png and b/docs/screenshots/states/ferroehr/combobox-open-dark.png differ diff --git a/docs/screenshots/states/ferroehr/combobox-open.png b/docs/screenshots/states/ferroehr/combobox-open.png index 9569197..d28cb1d 100644 Binary files a/docs/screenshots/states/ferroehr/combobox-open.png and b/docs/screenshots/states/ferroehr/combobox-open.png differ diff --git a/docs/screenshots/states/ferroehr/density-compact-dark.png b/docs/screenshots/states/ferroehr/density-compact-dark.png index 3e1dd15..5a10d86 100644 Binary files a/docs/screenshots/states/ferroehr/density-compact-dark.png and b/docs/screenshots/states/ferroehr/density-compact-dark.png differ diff --git a/docs/screenshots/states/ferroehr/density-compact.png b/docs/screenshots/states/ferroehr/density-compact.png index 40f4c0a..9145f8c 100644 Binary files a/docs/screenshots/states/ferroehr/density-compact.png and b/docs/screenshots/states/ferroehr/density-compact.png differ diff --git a/docs/screenshots/states/ferroehr/dialog-danger-dark.png b/docs/screenshots/states/ferroehr/dialog-danger-dark.png index 5669630..15f4540 100644 Binary files a/docs/screenshots/states/ferroehr/dialog-danger-dark.png and b/docs/screenshots/states/ferroehr/dialog-danger-dark.png differ diff --git a/docs/screenshots/states/ferroehr/dialog-danger.png b/docs/screenshots/states/ferroehr/dialog-danger.png index a97c5b7..8f313ee 100644 Binary files a/docs/screenshots/states/ferroehr/dialog-danger.png and b/docs/screenshots/states/ferroehr/dialog-danger.png differ diff --git a/docs/screenshots/states/ferroehr/dialog-open-dark.png b/docs/screenshots/states/ferroehr/dialog-open-dark.png index cad037e..835216f 100644 Binary files a/docs/screenshots/states/ferroehr/dialog-open-dark.png and b/docs/screenshots/states/ferroehr/dialog-open-dark.png differ diff --git a/docs/screenshots/states/ferroehr/dialog-open.png b/docs/screenshots/states/ferroehr/dialog-open.png index 09b3db7..e8b105f 100644 Binary files a/docs/screenshots/states/ferroehr/dialog-open.png and b/docs/screenshots/states/ferroehr/dialog-open.png differ diff --git a/docs/screenshots/states/ferroehr/drawer-open-dark.png b/docs/screenshots/states/ferroehr/drawer-open-dark.png index 3e2018f..f7ad766 100644 Binary files a/docs/screenshots/states/ferroehr/drawer-open-dark.png and b/docs/screenshots/states/ferroehr/drawer-open-dark.png differ diff --git a/docs/screenshots/states/ferroehr/drawer-open.png b/docs/screenshots/states/ferroehr/drawer-open.png index 2fc594c..100a77a 100644 Binary files a/docs/screenshots/states/ferroehr/drawer-open.png and b/docs/screenshots/states/ferroehr/drawer-open.png differ diff --git a/docs/screenshots/states/ferroehr/focus-search-dark.png b/docs/screenshots/states/ferroehr/focus-search-dark.png index 7847547..b94a4d6 100644 Binary files a/docs/screenshots/states/ferroehr/focus-search-dark.png and b/docs/screenshots/states/ferroehr/focus-search-dark.png differ diff --git a/docs/screenshots/states/ferroehr/focus-search.png b/docs/screenshots/states/ferroehr/focus-search.png index b42519c..1641fb2 100644 Binary files a/docs/screenshots/states/ferroehr/focus-search.png and b/docs/screenshots/states/ferroehr/focus-search.png differ diff --git a/docs/screenshots/states/ferroehr/focus-theme-toggle-dark.png b/docs/screenshots/states/ferroehr/focus-theme-toggle-dark.png index 0c3239c..2ebdd44 100644 Binary files a/docs/screenshots/states/ferroehr/focus-theme-toggle-dark.png and b/docs/screenshots/states/ferroehr/focus-theme-toggle-dark.png differ diff --git a/docs/screenshots/states/ferroehr/focus-theme-toggle.png b/docs/screenshots/states/ferroehr/focus-theme-toggle.png index 11363e3..b3d6fc2 100644 Binary files a/docs/screenshots/states/ferroehr/focus-theme-toggle.png and b/docs/screenshots/states/ferroehr/focus-theme-toggle.png differ diff --git a/docs/screenshots/states/ferroehr/hover-row-dark.png b/docs/screenshots/states/ferroehr/hover-row-dark.png index 53b7710..1ba38ec 100644 Binary files a/docs/screenshots/states/ferroehr/hover-row-dark.png and b/docs/screenshots/states/ferroehr/hover-row-dark.png differ diff --git a/docs/screenshots/states/ferroehr/hover-row.png b/docs/screenshots/states/ferroehr/hover-row.png index 374f456..5336779 100644 Binary files a/docs/screenshots/states/ferroehr/hover-row.png and b/docs/screenshots/states/ferroehr/hover-row.png differ diff --git a/docs/screenshots/states/ferroehr/modal-open-dark.png b/docs/screenshots/states/ferroehr/modal-open-dark.png index aa3d2c8..46e1b25 100644 Binary files a/docs/screenshots/states/ferroehr/modal-open-dark.png and b/docs/screenshots/states/ferroehr/modal-open-dark.png differ diff --git a/docs/screenshots/states/ferroehr/modal-open.png b/docs/screenshots/states/ferroehr/modal-open.png index 45fae15..57d2105 100644 Binary files a/docs/screenshots/states/ferroehr/modal-open.png and b/docs/screenshots/states/ferroehr/modal-open.png differ diff --git a/docs/screenshots/states/ferroehr/modal-wide-dark.png b/docs/screenshots/states/ferroehr/modal-wide-dark.png index a467c57..b8a1c65 100644 Binary files a/docs/screenshots/states/ferroehr/modal-wide-dark.png and b/docs/screenshots/states/ferroehr/modal-wide-dark.png differ diff --git a/docs/screenshots/states/ferroehr/modal-wide.png b/docs/screenshots/states/ferroehr/modal-wide.png index 63e6b74..5538a85 100644 Binary files a/docs/screenshots/states/ferroehr/modal-wide.png and b/docs/screenshots/states/ferroehr/modal-wide.png differ diff --git a/docs/screenshots/states/ferroehr/operator-menu-dark.png b/docs/screenshots/states/ferroehr/operator-menu-dark.png index bf1893d..d8c6a8d 100644 Binary files a/docs/screenshots/states/ferroehr/operator-menu-dark.png and b/docs/screenshots/states/ferroehr/operator-menu-dark.png differ diff --git a/docs/screenshots/states/ferroehr/operator-menu.png b/docs/screenshots/states/ferroehr/operator-menu.png index 591761b..bbe27c5 100644 Binary files a/docs/screenshots/states/ferroehr/operator-menu.png and b/docs/screenshots/states/ferroehr/operator-menu.png differ diff --git a/docs/screenshots/states/ferroehr/side-panel-dark.png b/docs/screenshots/states/ferroehr/side-panel-dark.png index b39d00d..1133227 100644 Binary files a/docs/screenshots/states/ferroehr/side-panel-dark.png and b/docs/screenshots/states/ferroehr/side-panel-dark.png differ diff --git a/docs/screenshots/states/ferroehr/side-panel.png b/docs/screenshots/states/ferroehr/side-panel.png index c80b3c3..9563dcb 100644 Binary files a/docs/screenshots/states/ferroehr/side-panel.png and b/docs/screenshots/states/ferroehr/side-panel.png differ diff --git a/docs/screenshots/states/ferrofed/combobox-hover-dark.png b/docs/screenshots/states/ferrofed/combobox-hover-dark.png index c7a92d5..799f523 100644 Binary files a/docs/screenshots/states/ferrofed/combobox-hover-dark.png and b/docs/screenshots/states/ferrofed/combobox-hover-dark.png differ diff --git a/docs/screenshots/states/ferrofed/combobox-hover.png b/docs/screenshots/states/ferrofed/combobox-hover.png index a7f3d7c..fda1165 100644 Binary files a/docs/screenshots/states/ferrofed/combobox-hover.png and b/docs/screenshots/states/ferrofed/combobox-hover.png differ diff --git a/docs/screenshots/states/ferrofed/combobox-keyboard-dark.png b/docs/screenshots/states/ferrofed/combobox-keyboard-dark.png index ee74269..2b89622 100644 Binary files a/docs/screenshots/states/ferrofed/combobox-keyboard-dark.png and b/docs/screenshots/states/ferrofed/combobox-keyboard-dark.png differ diff --git a/docs/screenshots/states/ferrofed/combobox-keyboard.png b/docs/screenshots/states/ferrofed/combobox-keyboard.png index 1c3fa85..1ba6b6d 100644 Binary files a/docs/screenshots/states/ferrofed/combobox-keyboard.png and b/docs/screenshots/states/ferrofed/combobox-keyboard.png differ diff --git a/docs/screenshots/states/ferrofed/combobox-open-dark.png b/docs/screenshots/states/ferrofed/combobox-open-dark.png index 2673570..17af3f6 100644 Binary files a/docs/screenshots/states/ferrofed/combobox-open-dark.png and b/docs/screenshots/states/ferrofed/combobox-open-dark.png differ diff --git a/docs/screenshots/states/ferrofed/combobox-open.png b/docs/screenshots/states/ferrofed/combobox-open.png index 5aac47e..d09293c 100644 Binary files a/docs/screenshots/states/ferrofed/combobox-open.png and b/docs/screenshots/states/ferrofed/combobox-open.png differ diff --git a/docs/screenshots/states/ferrofed/density-compact-dark.png b/docs/screenshots/states/ferrofed/density-compact-dark.png index 1df856b..6d74b21 100644 Binary files a/docs/screenshots/states/ferrofed/density-compact-dark.png and b/docs/screenshots/states/ferrofed/density-compact-dark.png differ diff --git a/docs/screenshots/states/ferrofed/density-compact.png b/docs/screenshots/states/ferrofed/density-compact.png index cf33819..f711426 100644 Binary files a/docs/screenshots/states/ferrofed/density-compact.png and b/docs/screenshots/states/ferrofed/density-compact.png differ diff --git a/docs/screenshots/states/ferrofed/dialog-danger-dark.png b/docs/screenshots/states/ferrofed/dialog-danger-dark.png index cd47d92..8531ba7 100644 Binary files a/docs/screenshots/states/ferrofed/dialog-danger-dark.png and b/docs/screenshots/states/ferrofed/dialog-danger-dark.png differ diff --git a/docs/screenshots/states/ferrofed/dialog-danger.png b/docs/screenshots/states/ferrofed/dialog-danger.png index a740e63..e73643b 100644 Binary files a/docs/screenshots/states/ferrofed/dialog-danger.png and b/docs/screenshots/states/ferrofed/dialog-danger.png differ diff --git a/docs/screenshots/states/ferrofed/dialog-open-dark.png b/docs/screenshots/states/ferrofed/dialog-open-dark.png index 0f69745..2f5ee0b 100644 Binary files a/docs/screenshots/states/ferrofed/dialog-open-dark.png and b/docs/screenshots/states/ferrofed/dialog-open-dark.png differ diff --git a/docs/screenshots/states/ferrofed/dialog-open.png b/docs/screenshots/states/ferrofed/dialog-open.png index dbf09d4..8984845 100644 Binary files a/docs/screenshots/states/ferrofed/dialog-open.png and b/docs/screenshots/states/ferrofed/dialog-open.png differ diff --git a/docs/screenshots/states/ferrofed/drawer-open-dark.png b/docs/screenshots/states/ferrofed/drawer-open-dark.png index c590c14..67658ad 100644 Binary files a/docs/screenshots/states/ferrofed/drawer-open-dark.png and b/docs/screenshots/states/ferrofed/drawer-open-dark.png differ diff --git a/docs/screenshots/states/ferrofed/drawer-open.png b/docs/screenshots/states/ferrofed/drawer-open.png index 0d60362..1f7e784 100644 Binary files a/docs/screenshots/states/ferrofed/drawer-open.png and b/docs/screenshots/states/ferrofed/drawer-open.png differ diff --git a/docs/screenshots/states/ferrofed/focus-search-dark.png b/docs/screenshots/states/ferrofed/focus-search-dark.png index 60df160..2034ef8 100644 Binary files a/docs/screenshots/states/ferrofed/focus-search-dark.png and b/docs/screenshots/states/ferrofed/focus-search-dark.png differ diff --git a/docs/screenshots/states/ferrofed/focus-search.png b/docs/screenshots/states/ferrofed/focus-search.png index 806768a..a17bd60 100644 Binary files a/docs/screenshots/states/ferrofed/focus-search.png and b/docs/screenshots/states/ferrofed/focus-search.png differ diff --git a/docs/screenshots/states/ferrofed/focus-theme-toggle-dark.png b/docs/screenshots/states/ferrofed/focus-theme-toggle-dark.png index fca91d9..3593c2c 100644 Binary files a/docs/screenshots/states/ferrofed/focus-theme-toggle-dark.png and b/docs/screenshots/states/ferrofed/focus-theme-toggle-dark.png differ diff --git a/docs/screenshots/states/ferrofed/focus-theme-toggle.png b/docs/screenshots/states/ferrofed/focus-theme-toggle.png index 41aedf2..df79f42 100644 Binary files a/docs/screenshots/states/ferrofed/focus-theme-toggle.png and b/docs/screenshots/states/ferrofed/focus-theme-toggle.png differ diff --git a/docs/screenshots/states/ferrofed/hover-row-dark.png b/docs/screenshots/states/ferrofed/hover-row-dark.png index ef59ad5..b7275c2 100644 Binary files a/docs/screenshots/states/ferrofed/hover-row-dark.png and b/docs/screenshots/states/ferrofed/hover-row-dark.png differ diff --git a/docs/screenshots/states/ferrofed/hover-row.png b/docs/screenshots/states/ferrofed/hover-row.png index 5933366..2dfb2e0 100644 Binary files a/docs/screenshots/states/ferrofed/hover-row.png and b/docs/screenshots/states/ferrofed/hover-row.png differ diff --git a/docs/screenshots/states/ferrofed/modal-open-dark.png b/docs/screenshots/states/ferrofed/modal-open-dark.png index 6b18eba..002bc35 100644 Binary files a/docs/screenshots/states/ferrofed/modal-open-dark.png and b/docs/screenshots/states/ferrofed/modal-open-dark.png differ diff --git a/docs/screenshots/states/ferrofed/modal-open.png b/docs/screenshots/states/ferrofed/modal-open.png index 9cc639e..8e3231a 100644 Binary files a/docs/screenshots/states/ferrofed/modal-open.png and b/docs/screenshots/states/ferrofed/modal-open.png differ diff --git a/docs/screenshots/states/ferrofed/modal-wide-dark.png b/docs/screenshots/states/ferrofed/modal-wide-dark.png index 5e8e727..00a1e23 100644 Binary files a/docs/screenshots/states/ferrofed/modal-wide-dark.png and b/docs/screenshots/states/ferrofed/modal-wide-dark.png differ diff --git a/docs/screenshots/states/ferrofed/modal-wide.png b/docs/screenshots/states/ferrofed/modal-wide.png index d10beb4..f4d8dab 100644 Binary files a/docs/screenshots/states/ferrofed/modal-wide.png and b/docs/screenshots/states/ferrofed/modal-wide.png differ diff --git a/docs/screenshots/states/ferrofed/operator-menu-dark.png b/docs/screenshots/states/ferrofed/operator-menu-dark.png index 1ab6b09..4c01918 100644 Binary files a/docs/screenshots/states/ferrofed/operator-menu-dark.png and b/docs/screenshots/states/ferrofed/operator-menu-dark.png differ diff --git a/docs/screenshots/states/ferrofed/operator-menu.png b/docs/screenshots/states/ferrofed/operator-menu.png index 92af763..78f800c 100644 Binary files a/docs/screenshots/states/ferrofed/operator-menu.png and b/docs/screenshots/states/ferrofed/operator-menu.png differ diff --git a/docs/screenshots/states/ferrofed/side-panel-dark.png b/docs/screenshots/states/ferrofed/side-panel-dark.png index 37cfa68..9534b0b 100644 Binary files a/docs/screenshots/states/ferrofed/side-panel-dark.png and b/docs/screenshots/states/ferrofed/side-panel-dark.png differ diff --git a/docs/screenshots/states/ferrofed/side-panel.png b/docs/screenshots/states/ferrofed/side-panel.png index 4f9830d..5a30b4b 100644 Binary files a/docs/screenshots/states/ferrofed/side-panel.png and b/docs/screenshots/states/ferrofed/side-panel.png differ diff --git a/docs/screenshots/states/ferroterm/combobox-hover-dark.png b/docs/screenshots/states/ferroterm/combobox-hover-dark.png index 5f2d783..e376493 100644 Binary files a/docs/screenshots/states/ferroterm/combobox-hover-dark.png and b/docs/screenshots/states/ferroterm/combobox-hover-dark.png differ diff --git a/docs/screenshots/states/ferroterm/combobox-hover.png b/docs/screenshots/states/ferroterm/combobox-hover.png index 276254a..c450a63 100644 Binary files a/docs/screenshots/states/ferroterm/combobox-hover.png and b/docs/screenshots/states/ferroterm/combobox-hover.png differ diff --git a/docs/screenshots/states/ferroterm/combobox-keyboard-dark.png b/docs/screenshots/states/ferroterm/combobox-keyboard-dark.png index fa81301..c1c9e58 100644 Binary files a/docs/screenshots/states/ferroterm/combobox-keyboard-dark.png and b/docs/screenshots/states/ferroterm/combobox-keyboard-dark.png differ diff --git a/docs/screenshots/states/ferroterm/combobox-keyboard.png b/docs/screenshots/states/ferroterm/combobox-keyboard.png index 35448d0..57badaa 100644 Binary files a/docs/screenshots/states/ferroterm/combobox-keyboard.png and b/docs/screenshots/states/ferroterm/combobox-keyboard.png differ diff --git a/docs/screenshots/states/ferroterm/combobox-open-dark.png b/docs/screenshots/states/ferroterm/combobox-open-dark.png index 5f5e90a..83e1ba8 100644 Binary files a/docs/screenshots/states/ferroterm/combobox-open-dark.png and b/docs/screenshots/states/ferroterm/combobox-open-dark.png differ diff --git a/docs/screenshots/states/ferroterm/combobox-open.png b/docs/screenshots/states/ferroterm/combobox-open.png index 230062f..7aab2cb 100644 Binary files a/docs/screenshots/states/ferroterm/combobox-open.png and b/docs/screenshots/states/ferroterm/combobox-open.png differ diff --git a/docs/screenshots/states/ferroterm/density-compact-dark.png b/docs/screenshots/states/ferroterm/density-compact-dark.png index bc99378..3665a47 100644 Binary files a/docs/screenshots/states/ferroterm/density-compact-dark.png and b/docs/screenshots/states/ferroterm/density-compact-dark.png differ diff --git a/docs/screenshots/states/ferroterm/density-compact.png b/docs/screenshots/states/ferroterm/density-compact.png index bfbe488..e9567cc 100644 Binary files a/docs/screenshots/states/ferroterm/density-compact.png and b/docs/screenshots/states/ferroterm/density-compact.png differ diff --git a/docs/screenshots/states/ferroterm/dialog-danger-dark.png b/docs/screenshots/states/ferroterm/dialog-danger-dark.png index 590b265..e76869a 100644 Binary files a/docs/screenshots/states/ferroterm/dialog-danger-dark.png and b/docs/screenshots/states/ferroterm/dialog-danger-dark.png differ diff --git a/docs/screenshots/states/ferroterm/dialog-danger.png b/docs/screenshots/states/ferroterm/dialog-danger.png index 69abf4c..30cc637 100644 Binary files a/docs/screenshots/states/ferroterm/dialog-danger.png and b/docs/screenshots/states/ferroterm/dialog-danger.png differ diff --git a/docs/screenshots/states/ferroterm/dialog-open-dark.png b/docs/screenshots/states/ferroterm/dialog-open-dark.png index 1ffcd9f..c23600b 100644 Binary files a/docs/screenshots/states/ferroterm/dialog-open-dark.png and b/docs/screenshots/states/ferroterm/dialog-open-dark.png differ diff --git a/docs/screenshots/states/ferroterm/dialog-open.png b/docs/screenshots/states/ferroterm/dialog-open.png index b139d4e..4702404 100644 Binary files a/docs/screenshots/states/ferroterm/dialog-open.png and b/docs/screenshots/states/ferroterm/dialog-open.png differ diff --git a/docs/screenshots/states/ferroterm/drawer-open-dark.png b/docs/screenshots/states/ferroterm/drawer-open-dark.png index 9ef9bca..69d97bc 100644 Binary files a/docs/screenshots/states/ferroterm/drawer-open-dark.png and b/docs/screenshots/states/ferroterm/drawer-open-dark.png differ diff --git a/docs/screenshots/states/ferroterm/drawer-open.png b/docs/screenshots/states/ferroterm/drawer-open.png index 22a3044..847e733 100644 Binary files a/docs/screenshots/states/ferroterm/drawer-open.png and b/docs/screenshots/states/ferroterm/drawer-open.png differ diff --git a/docs/screenshots/states/ferroterm/focus-search-dark.png b/docs/screenshots/states/ferroterm/focus-search-dark.png index ef1765f..c738d85 100644 Binary files a/docs/screenshots/states/ferroterm/focus-search-dark.png and b/docs/screenshots/states/ferroterm/focus-search-dark.png differ diff --git a/docs/screenshots/states/ferroterm/focus-search.png b/docs/screenshots/states/ferroterm/focus-search.png index a94c5bc..eaf8e70 100644 Binary files a/docs/screenshots/states/ferroterm/focus-search.png and b/docs/screenshots/states/ferroterm/focus-search.png differ diff --git a/docs/screenshots/states/ferroterm/focus-theme-toggle-dark.png b/docs/screenshots/states/ferroterm/focus-theme-toggle-dark.png index 0385ce7..e78f2d4 100644 Binary files a/docs/screenshots/states/ferroterm/focus-theme-toggle-dark.png and b/docs/screenshots/states/ferroterm/focus-theme-toggle-dark.png differ diff --git a/docs/screenshots/states/ferroterm/focus-theme-toggle.png b/docs/screenshots/states/ferroterm/focus-theme-toggle.png index b6579cf..7f716c4 100644 Binary files a/docs/screenshots/states/ferroterm/focus-theme-toggle.png and b/docs/screenshots/states/ferroterm/focus-theme-toggle.png differ diff --git a/docs/screenshots/states/ferroterm/hover-row-dark.png b/docs/screenshots/states/ferroterm/hover-row-dark.png index fefd907..f15980c 100644 Binary files a/docs/screenshots/states/ferroterm/hover-row-dark.png and b/docs/screenshots/states/ferroterm/hover-row-dark.png differ diff --git a/docs/screenshots/states/ferroterm/hover-row.png b/docs/screenshots/states/ferroterm/hover-row.png index a13dba9..ab657fd 100644 Binary files a/docs/screenshots/states/ferroterm/hover-row.png and b/docs/screenshots/states/ferroterm/hover-row.png differ diff --git a/docs/screenshots/states/ferroterm/modal-open-dark.png b/docs/screenshots/states/ferroterm/modal-open-dark.png index 66527f6..1a9e696 100644 Binary files a/docs/screenshots/states/ferroterm/modal-open-dark.png and b/docs/screenshots/states/ferroterm/modal-open-dark.png differ diff --git a/docs/screenshots/states/ferroterm/modal-open.png b/docs/screenshots/states/ferroterm/modal-open.png index 0ca8dd8..ee7d812 100644 Binary files a/docs/screenshots/states/ferroterm/modal-open.png and b/docs/screenshots/states/ferroterm/modal-open.png differ diff --git a/docs/screenshots/states/ferroterm/modal-wide-dark.png b/docs/screenshots/states/ferroterm/modal-wide-dark.png index ad05e59..f1ff7be 100644 Binary files a/docs/screenshots/states/ferroterm/modal-wide-dark.png and b/docs/screenshots/states/ferroterm/modal-wide-dark.png differ diff --git a/docs/screenshots/states/ferroterm/modal-wide.png b/docs/screenshots/states/ferroterm/modal-wide.png index d4ac6b2..1f4bb67 100644 Binary files a/docs/screenshots/states/ferroterm/modal-wide.png and b/docs/screenshots/states/ferroterm/modal-wide.png differ diff --git a/docs/screenshots/states/ferroterm/operator-menu-dark.png b/docs/screenshots/states/ferroterm/operator-menu-dark.png index 90e4414..484c306 100644 Binary files a/docs/screenshots/states/ferroterm/operator-menu-dark.png and b/docs/screenshots/states/ferroterm/operator-menu-dark.png differ diff --git a/docs/screenshots/states/ferroterm/operator-menu.png b/docs/screenshots/states/ferroterm/operator-menu.png index f61e792..701a220 100644 Binary files a/docs/screenshots/states/ferroterm/operator-menu.png and b/docs/screenshots/states/ferroterm/operator-menu.png differ diff --git a/docs/screenshots/states/ferroterm/side-panel-dark.png b/docs/screenshots/states/ferroterm/side-panel-dark.png index a951700..6fb287f 100644 Binary files a/docs/screenshots/states/ferroterm/side-panel-dark.png and b/docs/screenshots/states/ferroterm/side-panel-dark.png differ diff --git a/docs/screenshots/states/ferroterm/side-panel.png b/docs/screenshots/states/ferroterm/side-panel.png index 85d69d9..1f5ab0d 100644 Binary files a/docs/screenshots/states/ferroterm/side-panel.png and b/docs/screenshots/states/ferroterm/side-panel.png differ diff --git a/scripts/checks/stray-literals.allow b/scripts/checks/stray-literals.allow new file mode 100644 index 0000000..e1065bc --- /dev/null +++ b/scripts/checks/stray-literals.allow @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Cadasto B.V. +# SPDX-License-Identifier: BUSL-1.1 +# The literals scripts/checks/stray-literals.sh reads as words that no +# reader sees as the library's text, each with the reason, and the files it +# does not cover. + +skip crates/ferrohealth-ui/src/highlight/ # tokenizers: grammar keywords and the markup they write +skip crates/ferrohealth-ui/src/icon.rs # the vendored Lucide outlines and their names +skip crates/ferrohealth-ui/src/i18n.rs # the catalog mechanism: message ids and diagnostics + +allow ^(ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Home|End|Enter|Escape)$ # UI Events KeyboardEvent.key values +allow ^Ferro(BRIDGE|FED|EHR|TERM)$ # product names, the same in every language +allow ^Cadasto B\.V\.$ # the maker's legal name +allow ^(start|middle|end)$ # SVG text-anchor keywords +allow ^< # SVG markup a chart writes as a string +allow ^M\{ # SVG path data +allow ^(Zz|Tt )$ # the bytes a timestamp parser accepts +allow ^\{\}T\{\}Z?$ # the HTML machine form of a date-time +allow ^\{id\}-L # an editor line's id diff --git a/scripts/checks/stray-literals.sh b/scripts/checks/stray-literals.sh new file mode 100755 index 0000000..1e851b3 --- /dev/null +++ b/scripts/checks/stray-literals.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Cadasto B.V. +# SPDX-License-Identifier: BUSL-1.1 +# Stray-literal guard: a string a reader sees belongs in a catalog under +# locales/, never in the code (.claude/rules/leptos-ui.md §13; no +# specification governs this: our own design). +# +# It reads the hand-written .rs files under each ROOT, up to the file's +# `#[cfg(test)]` module, and reports every string literal that reads as +# words a reader sees: +# +# 1. the value of an attribute a reader sees or hears (`aria-label`, +# `title`, `placeholder`, `alt`, `data-label`, `aria-description`, +# `aria-roledescription`, `aria-valuetext`, `label`) holding a letter; +# 2. a text node of a `view!`: a literal between two tags (`>"Close"<`) +# or alone on its line, holding a letter; +# 3. any other literal holding words: an upper-case letter, or a space +# next to a letter, outside its `{}` placeables, and not a class list. +# +# A single lower-case word in code (`=> "fatal"`) reads as a key and is +# passed over, so a word drawn that way is review's to catch. Lines that +# speak to a developer are passed over: `expect(`, log macros, +# `write!(f`, `f.write_str(`, `debug_struct(`, `.field(`, a lint's `reason`, assertions and +# attributes. A literal the check reads wrong is allowed by a line of the +# allow file, and a file the check does not cover is skipped there: +# +# allow <perl regex matched against the literal's text> # the reason +# skip <path or directory prefix, from the repository root> # the reason +# +# The baseline file holds `<count> <path>` lines for a tree whose strings +# are still moving to the catalogs: a file may hold up to its count and no +# more, and a file not named may hold none. With --update the script +# rewrites the baseline to the counts it finds, which only a change that +# moves strings to the catalogs should need. +# +# Usage: +# scripts/checks/stray-literals.sh [--update] ROOT... +# +# The allow file is scripts/checks/stray-literals.allow and the baseline +# scripts/checks/stray-literals.baseline, both optional. +# +# Exit 0 = clean, 1 = a stray literal (listed as file:line: literal), 2 = +# a usage error. +set -Eeuo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$root" + +update=0 +if [ "${1:-}" = "--update" ]; then + update=1 + shift +fi +if [ "$#" -eq 0 ]; then + echo "usage: scripts/checks/stray-literals.sh [--update] ROOT..." >&2 + exit 2 +fi +for dir in "$@"; do + if [ ! -d "$dir" ]; then + echo "stray-literals: $dir is no directory" >&2 + exit 2 + fi +done + +allow="scripts/checks/stray-literals.allow" +baseline="scripts/checks/stray-literals.baseline" + +files=() +while IFS= read -r file; do + files+=("$file") +done < <(find "$@" -type f -name '*.rs' | LC_ALL=C sort) +if [ "${#files[@]}" -eq 0 ]; then + echo "stray-literals: no .rs file under $*" + exit 0 +fi + +found="$( + awk -v allowfile="$allow" ' + function wordy(t, bare, n, i, toks, plain) { + bare = t + gsub(/\{[^{}]*\}/, "", bare) + if (bare !~ /[A-Za-z]/) return 0 + n = split(bare, toks, /[ \t]+/) + plain = 1 + for (i = 1; i <= n; i++) if (toks[i] != "" && toks[i] !~ /^fui-[a-z0-9-]+$/) plain = 0 + if (plain) return 0 + if (bare ~ /[A-Z]/) return 1 + if (bare ~ /[A-Za-z][ \t]/ || bare ~ /[ \t][A-Za-z]/) return 1 + return 0 + } + function classes(t, n, i, toks) { + n = split(t, toks, /[ \t]+/) + for (i = 1; i <= n; i++) if (toks[i] != "" && toks[i] !~ /^fui-[a-z0-9-]+$/) return 0 + return 1 + } + BEGIN { + nallow = 0 + nskip = 0 + while ((getline line < allowfile) > 0) { + sub(/[ \t]+#.*$/, "", line) + if (line ~ /^[ \t]*(#|$)/) continue + if (line ~ /^allow[ \t]+/) { + sub(/^allow[ \t]+/, "", line) + sub(/[ \t]+$/, "", line) + allow[++nallow] = line + } else if (line ~ /^skip[ \t]+/) { + sub(/^skip[ \t]+/, "", line) + sub(/[ \t]+$/, "", line) + skip[++nskip] = line + } else { + print "stray-literals: " allowfile ": unreadable line: " line > "/dev/stderr" + exit 2 + } + } + visible = "(^|[^A-Za-z-])(aria-label|title|placeholder|alt|data-label|aria-description|aria-roledescription|aria-valuetext|label)[ \t]*=[ \t]*$" + } + FNR == 1 { + off = 0 + for (i = 1; i <= nskip; i++) if (index(FILENAME, skip[i]) == 1) off = 1 + if ($0 ~ /@generated/) off = 1 + } + off { next } + /^#\[cfg\(test\)\]/ { off = 1; next } + /^[ \t]*\/\// { next } + /^[ \t]*#!?\[/ { next } + /reason = "|expect\(|logging::|warn!\(|error!\(|debug!\(|log!\(|write!\(f|write_str\(|debug_struct\(|\.field\(|assert|panic!\(|unreachable!\(|include_str!\(/ { next } + { + code = $0 + while ((s = index(code, "r#\"")) > 0) { + rest = substr(code, s + 3) + e = index(rest, "\"#") + if (e == 0) { code = substr(code, 1, s - 1); break } + code = substr(code, 1, s - 1) substr(rest, e + 2) + } + n = length(code) + i = 1 + while (i <= n) { + c = substr(code, i, 1) + if (c == "'"'"'" && substr(code, i + 2, 1) == "'"'"'") { i += 3; continue } + if (c != "\"") { i++; continue } + start = i + lit = "" + i++ + while (i <= n) { + c = substr(code, i, 1) + if (c == "\\") { lit = lit substr(code, i, 2); i += 2; continue } + if (c == "\"") break + lit = lit c + i++ + } + pre = substr(code, 1, start - 1) + after = substr(code, i + 1) + i++ + hit = 0 + if (pre ~ visible) { + hit = lit ~ /[A-Za-z]/ + } else if (pre ~ /[A-Za-z:-]=$/) { + hit = 0 + } else if (pre ~ />[ \t]*$/ && after ~ /^[ \t]*</) { + hit = lit ~ /[A-Za-z]/ + } else if (pre ~ /^[ \t]*$/ && after ~ /^[ \t]*$/) { + hit = lit ~ /[A-Za-z]/ && !classes(lit) + } else { + hit = wordy(lit) + } + if (!hit) continue + allowed = 0 + for (a = 1; a <= nallow; a++) if (lit ~ allow[a]) allowed = 1 + if (!allowed) print FILENAME ":" FNR ": \"" lit "\"" + } + } + ' "${files[@]}" +)" + +counts="$(printf '%s\n' "$found" | sed -n 's/^\([^:]*\):[0-9]*:.*/\1/p' | LC_ALL=C sort | uniq -c | awk '{print $1, $2}')" + +if [ "$update" -eq 1 ]; then + { + echo "# SPDX-FileCopyrightText: Cadasto B.V." + echo "# SPDX-License-Identifier: BUSL-1.1" + echo "# The stray literals each file may still hold while its strings move to" + echo "# the catalogs; rewritten by scripts/checks/stray-literals.sh --update." + if [ -n "$counts" ]; then printf '%s\n' "$counts"; fi + } > "$baseline" + echo "stray-literals: wrote $baseline" + exit 0 +fi + +status=0 +while IFS= read -r entry; do + [ -n "$entry" ] || continue + count="${entry%% *}" + file="${entry#* }" + allowed=0 + if [ -f "$baseline" ]; then + allowed="$(awk -v f="$file" '$2 == f { print $1 }' "$baseline")" + allowed="${allowed:-0}" + fi + if [ "$count" -gt "$allowed" ]; then + status=1 + echo "stray-literals: $file holds $count stray literal(s), $allowed allowed:" >&2 + printf '%s\n' "$found" | awk -v f="$file:" 'index($0, f) == 1' >&2 + fi +done <<< "$counts" + +if [ "$status" -ne 0 ]; then + echo "stray-literals: move each string into the catalog under locales/ and look it up, or allow a literal that is no reader's text in $allow with the reason." >&2 + exit 1 +fi +echo "stray-literals: no stray literal under $*"