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! {"0123456789ab" }.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::