From a54341697089e1cd74a369c6a10210063fea67fc Mon Sep 17 00:00:00 2001 From: Steve Teece Date: Thu, 27 Aug 2026 21:51:27 +1000 Subject: [PATCH 01/80] spec(027): add localization support spec, plan, and tasks Feature 027 introduces per-language resource files (Australian English baseline) so every screen, report, menu, tile, exception message and user-facing enum value reads its text via IStringLocalizer, plus a persisted, OS-aware Settings.LanguageCode selection. Adds the full spec-kit artifact set: - spec.md - 3 prioritized user stories, FR-001..FR-024, SC-001..SC-010 - plan.md, research.md - IStringLocalizer + ~12 area .resx markers, missing-key logging decorator, MoneyFormatter (fixed AUD), startup culture ladder (explicit -> OS language -> en-AU) - data-model.md - Settings.LanguageCode column + migration outline - contracts/ - localization interfaces + resource-key catalog/guards - quickstart.md, checklists/requirements.md - tasks.md - 64 tasks (T001-T064) in 6 wave-grouped phases Bumps .specify/feature.json to specs/027-localization-support. Co-Authored-By: Claude Sonnet 5 --- .specify/feature.json | 2 +- .../.spec-context.json | 353 ++++++++++++++++++ .../checklists/requirements.md | 39 ++ .../contracts/localization-contracts.md | 208 +++++++++++ .../contracts/resource-key-catalog.md | 81 ++++ specs/027-localization-support/data-model.md | 87 +++++ specs/027-localization-support/plan.md | 120 ++++++ specs/027-localization-support/quickstart.md | 92 +++++ specs/027-localization-support/research.md | 162 ++++++++ specs/027-localization-support/spec.md | 163 ++++++++ specs/027-localization-support/tasks.md | 246 ++++++++++++ 11 files changed, 1552 insertions(+), 1 deletion(-) create mode 100644 specs/027-localization-support/.spec-context.json create mode 100644 specs/027-localization-support/checklists/requirements.md create mode 100644 specs/027-localization-support/contracts/localization-contracts.md create mode 100644 specs/027-localization-support/contracts/resource-key-catalog.md create mode 100644 specs/027-localization-support/data-model.md create mode 100644 specs/027-localization-support/plan.md create mode 100644 specs/027-localization-support/quickstart.md create mode 100644 specs/027-localization-support/research.md create mode 100644 specs/027-localization-support/spec.md create mode 100644 specs/027-localization-support/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index dc7b0b52..0217a363 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/026-past-agm-committee-report" + "feature_directory": "specs/027-localization-support" } diff --git a/specs/027-localization-support/.spec-context.json b/specs/027-localization-support/.spec-context.json new file mode 100644 index 00000000..45767b00 --- /dev/null +++ b/specs/027-localization-support/.spec-context.json @@ -0,0 +1,353 @@ +{ + "workflow": "speckit", + "specName": "localization support", + "branch": "027-localization-support", + "currentStep": "tasks", + "status": "ready-to-implement", + "history": [ + { + "step": "specify", + "substep": null, + "kind": "start", + "by": "extension", + "at": "2026-08-27T10:04:28.454Z" + }, + { + "step": "specify", + "substep": null, + "kind": "complete", + "by": "extension", + "at": "2026-08-27T10:10:32.296Z" + }, + { + "step": "plan", + "substep": null, + "kind": "start", + "by": "extension", + "at": "2026-08-27T10:11:40.110Z" + }, + { + "step": "plan", + "substep": "research", + "kind": "complete", + "by": "ai", + "at": "2026-08-27T10:17:43.415Z" + }, + { + "step": "plan", + "substep": "design", + "kind": "complete", + "by": "ai", + "at": "2026-08-27T10:22:18.848Z" + }, + { + "step": "plan", + "substep": null, + "kind": "complete", + "by": "extension", + "at": "2026-08-27T10:22:38.629Z" + }, + { + "step": "tasks", + "substep": null, + "kind": "start", + "by": "extension", + "at": "2026-08-27T11:29:38.644Z" + }, + { + "step": "tasks", + "substep": "generate", + "kind": "complete", + "by": "ai", + "at": "2026-08-27T11:37:41.575Z" + }, + { + "step": "tasks", + "substep": null, + "kind": "complete", + "by": "extension", + "at": "2026-08-27T11:37:54.840Z" + } + ], + "telemetryInstanceId": "bd71e5f2-b0ce-45df-9da3-06f68fa7a241", + "last_action": "spec amended (post-plan): FR-024 enum-value localisation; plan/research/data-model/contracts reconciled (EnumsResource + LocalizeEnum + guards)", + "coverage": { + "FR-001": { + "title": "Store app-authored user-facing text (incl. aria-label/alt/title) in resource files, not literals", + "tasks": [ + "T015", + "T018", + "T022", + "T026", + "T029", + "T060" + ] + }, + "FR-002": { + "title": "Address each text by a stable, wording-independent human-readable key", + "tasks": [ + "T015", + "T017", + "T018", + "T029", + "T031" + ] + }, + "FR-003": { + "title": "Ship a complete Australian English baseline covering every used key", + "tasks": [ + "T015", + "T017", + "T018", + "T029", + "T031" + ] + }, + "FR-004": { + "title": "Preserve current wording exactly during extraction (no re-copy-editing)", + "tasks": [ + "T017", + "T018", + "T020", + "T031", + "T042" + ] + }, + "FR-005": { + "title": "Every screen/dialog/component/tile/menu/tab/wizard step reads text via resource lookup", + "tasks": [ + "T022", + "T026", + "T031", + "T034", + "T037", + "T043" + ] + }, + "FR-006": { + "title": "Every PDF/CSV report reads titles, headers, section and total labels via resource lookup", + "tasks": [ + "T030", + "T039", + "T040" + ] + }, + "FR-007": { + "title": "User-facing validation/domain-error messages sourced from resources; logs may stay English", + "tasks": [ + "T019", + "T025", + "T041" + ] + }, + "FR-008": { + "title": "Missing key falls back to Australian English; never a blank or raw key", + "tasks": [ + "T008", + "T015", + "T047" + ] + }, + "FR-009": { + "title": "Record every missing-key fallback (log and/or test-detectable)", + "tasks": [ + "T008", + "T015" + ] + }, + "FR-010": { + "title": "Ordered/named placeholders for runtime values; per-language count-dependent wording", + "tasks": [ + "T018", + "T029" + ] + }, + "FR-011": { + "title": "A new language is addable by supplying a resource set only, discovered at runtime", + "tasks": [ + "T046", + "T052", + "T058" + ] + }, + "FR-012": { + "title": "Settings language selector lists shipped languages by endonym and marks the active one", + "tasks": [ + "T052", + "T056" + ] + }, + "FR-013": { + "title": "Setup Wizard first-run language choice, defaulting per FR-023", + "tasks": [ + "T057" + ] + }, + "FR-014": { + "title": "Persist the selected language and re-apply it automatically on restart", + "tasks": [ + "T048", + "T055", + "T056" + ] + }, + "FR-015": { + "title": "Culture date/number formatting to the region; monetary amounts stay fixed AUD symbol", + "tasks": [ + "T010", + "T034", + "T044" + ] + }, + "FR-016": { + "title": "Changing language never alters stored values, financial amounts or GL balances", + "tasks": [ + "T010", + "T046" + ] + }, + "FR-017": { + "title": "No stored preference runs identical to today (Australian English)", + "tasks": [ + "T053", + "T055" + ] + }, + "FR-018": { + "title": "Tests assert via keys/lookup; suite guards a used key missing its en-AU entry", + "tasks": [ + "T013", + "T015", + "T016", + "T029" + ] + }, + "FR-019": { + "title": "User-entered data is never translated", + "tasks": [ + "T015", + "T029" + ] + }, + "FR-020": { + "title": "Plugin text stays the plugin's responsibility; host renders as-provided, no failure", + "tasks": [ + "T030" + ] + }, + "FR-021": { + "title": "Inform the user a restart is needed; in-session switching out of scope for v1", + "tasks": [ + "T056", + "T057", + "T059" + ] + }, + "FR-022": { + "title": "Update docs on where resources live / how to add a language; refresh stale specs", + "tasks": [ + "T061", + "T062" + ] + }, + "FR-023": { + "title": "Default to OS display language when a matching set ships; else en-AU; explicit selection wins", + "tasks": [ + "T046", + "T053", + "T054", + "T055" + ] + }, + "FR-024": { + "title": "User-facing enum display text from resources keyed Enum__; identity stays invariant", + "tasks": [ + "T009", + "T020", + "T029", + "T042" + ] + } + }, + "size": "oversized", + "classification": { + "projectedFiles": 150, + "projectedTasks": 60, + "scopeSignal": "larger", + "verdict": "oversized" + }, + "context": [ + "living spec: none configured (living-specs.yml empty)", + "area: src/StageFright.UI — 65 .razor + 64 .razor.cs with hardcoded labels/headings/placeholders/status+error messages", + "area: src/StageFright.Core/Modules/*/MenuItemProvider.cs + dashboard tile providers — hardcoded Title/ShortLabel", + "area: src/StageFright.Reports providers + PDF/CSV renderers — report names, column headers, section/total labels", + "area: src/StageFright.Core/Exceptions + validation services — user-facing messages", + "area: src/StageFright.App/MauiProgram.cs — composition root; startup culture wiring belongs here", + "constraint: no existing i18n infra — zero .resx / IStringLocalizer / RequestLocalization / culture setup", + "constraint: CLAUDE.md — paired .razor.cs (no @code), one class per file, no custom JS, custom exceptions at boundaries, exhaustive test coverage", + "constraint: central NuGet versions via Directory.Packages.props", + "constraint: preserve Australian English wording verbatim; GL/stored values stay culture-invariant" + ], + "intent": "Move all app-authored user-facing text into per-language resource files (Australian English baseline) so every screen and report reads text via resource lookup, plus a persisted user-selectable display language.", + "expectations": [ + "Translating user-entered data (member names, organisation name, account names, event titles, notes)", + "Translating Serilog / developer log output", + "Right-to-left layout support and non-Latin typography tuning", + "Changing plugin contract interfaces or localizing plugin-supplied text", + "Responsive redesign of PDF/CSV reports beyond minor width/wrapping tolerance", + "Shipping any production-translated non-English language (infrastructure + pseudo/test locale only)", + "Committing the changes in this session" + ], + "approach": "Add Microsoft.Extensions.Localization with ~11 area-scoped IStringLocalizer resource markers; the neutral .resx holds the exact en-AU baseline and is the fallback. A logging IStringLocalizerFactory decorator records missing-key fallbacks; a new StageFright.Localization.Tests project guards baseline completeness and residual literals. Settings.LanguageCode (new nullable column + migration) is resolved by ILanguageProvider at MauiProgram startup and sets the process culture before first render. Extraction is staged: US1 (nav shell + Members) fixes the pattern, US2 rolls it across all surfaces incl. reports/PDF and user-facing exception text, US3 adds the Settings/Setup language picker (applies on next launch + restart notice) plus a qps-ploc test locale.", + "decisions": [ + { + "decision": "Use Microsoft.Extensions.Localization (IStringLocalizer) with ~11 area-scoped .resx marker classes; neutral .resx = en-AU baseline", + "why": "framework-standard, no new JS/toolkit, plain .resx for translators, built-in parent-culture fallback (FR-008); area scoping keeps ~11 files/culture vs ~130 per-component", + "rejected": "strongly-typed .resx designer only (no fallback/logging hook); JSON + custom loader (bespoke framework); DB-stored strings (no offline translator workflow)" + }, + { + "decision": "Missing-key handling: a MissingKeyLoggingLocalizerFactory decorator logs the fallback at runtime, plus a build/test-time resource-completeness guard in a new StageFright.Localization.Tests project", + "why": "runtime layer protects the user with graceful en-AU fallback + a recorded warning (FR-008/FR-009); test layer stops a gap ever shipping (SC-008) and can be scoped per phase", + "rejected": "hard-fail at runtime on missing key (FR-008 wants graceful fallback); test-only check (misses field locale gaps)" + }, + { + "decision": "Apply Settings.LanguageCode at MauiProgram startup by setting CultureInfo.DefaultThreadCurrentCulture/UICulture before first Blazor render, via a testable Core ILanguageProvider", + "why": "Hybrid BlazorWebView runs one process-wide culture with no request pipeline; startup is the only place that also covers non-UI paths (QuestPDF); mirrors how Settings.Theme is read at startup", + "rejected": "set culture in a root component OnInitialized (first-render flash, misses non-UI); RequestLocalization middleware (no ASP.NET pipeline in Hybrid)" + }, + { + "decision": "FR-021 v1 default: a language change persists immediately and applies on next launch with an inline restart notice; keep a LanguageProvider cascade seam for a later live-switch story", + "why": "true in-session switch needs re-init of every component and re-resolution of AddSingleton menu providers - real regression surface; spec records this as an informed default and defers the live-switch question to clarify", + "rejected": "programmatic forced restart (abrupt, risks unsaved work); full live re-render for v1 (scope/risk)" + }, + { + "decision": "Stage the extraction: US1 converts the nav shell + Members module end-to-end to fix the pattern (resx layout, key scheme, _Imports, code-behind injection, bUnit assertions, guard test); US2 repeats it mechanically across all remaining surfaces", + "why": "the design-bearing decisions must settle and pass review on one real slice before ~150 files of mechanical repetition; lets US1 merge with a per-phase-scoped residual-literal guard while US2 is still in progress", + "rejected": "big-bang all-files-at-once conversion (unreviewable, all-or-nothing merge)" + }, + { + "decision": "Prove language switching/fallback with a test-only qps-ploc pseudo-locale, not a translated production language", + "why": "meets SC-003 (adding a language = resx + one catalog entry, zero code) and SC-004 (fallback + logging) without committing to real translation, which the spec Assumptions place out of scope", + "rejected": "translate a real second language e.g. en-US or mi-NZ for v1 (business decision the spec defers; en-US too close to en-AU to prove much)" + }, + { + "decision": "Startup culture ladder: explicit Settings.LanguageCode -> OS display language (if catalog has an exact or parent-language match) -> en-AU; OS language read via an injectable ISystemCultureProvider seam; LanguageCode is never auto-persisted from the OS", + "why": "user requirement FR-023 - respect system localisation as the default when the resource set exists, else Australian English; keeping LanguageCode null until the user actively picks preserves follow-the-system behaviour across OS changes and an unchanged install still behaves exactly as before while en-AU is the only shipped set", + "rejected": "always follow the OS language ignoring the stored choice (explicit selection must win); auto-persist the detected OS language into LanguageCode on first run (would freeze the choice and stop later OS changes taking effect)" + }, + { + "decision": "Localise user-facing enum values via Enum__ keys in one shared EnumsResource (StageFright.Core) resolved by a LocalizeEnum(this Enum) helper; replace every enum.ToString()/switch at a display site; report-filter option VALUE stays the invariant token, only the LABEL is localised", + "why": "FR-024; same enums (MemberStatus, FeeType, PaymentMethod/Type, AccountType, TaxCode, ReconciliationStatus, JournalEntryType, Theme) render in both UI and Reports and must match; predictable key shape the completeness guard enforces; keeps enum identity out of storage/GL/filter tokens (FR-016)", + "rejected": "[Display(Name=)]/[Description] attributes (compile-time literal, no per-culture resource, bypasses missing-key logging); per-enum Dictionary (no fallback/logging, drifts); per-area enum keys (same enum gets two divergent labels on screen vs report)" + } + ], + "step_summaries": { + "plan": { + "summary": "Plan: IStringLocalizer + ~11 area .resx markers (neutral = exact en-AU baseline), logging fallback decorator + a new completeness/residual-literal guard test project, Settings.LanguageCode resolved at MauiProgram startup, staged US1(shell+Members)->US2(all surfaces incl reports/exceptions)->US3(Settings/Setup picker + qps-ploc). Constitution: all PASS, no violations.", + "key_finding": "No localization infra exists today; ~500-700 user-facing literals span ~150 files across UI/Core/Reports, and the existing Settings.Theme/ThemeProvider/ISettingsService pattern is a direct template for the language preference." + }, + "tasks": { + "summary": "64 tasks (T001-T064) across 6 phases: Setup(3) -> Foundational(11, blocks all) -> US1 shell+Members(14) -> US2 all remaining surfaces/reports/exceptions/enums(17) -> US3 Settings.LanguageCode + OS-aware startup ladder + pickers + qps-ploc(14) -> Polish(5); wave-grouped, widest parallel wave is US2 T031-T044 (one per module)." + } + } +} diff --git a/specs/027-localization-support/checklists/requirements.md b/specs/027-localization-support/checklists/requirements.md new file mode 100644 index 00000000..c75ed861 --- /dev/null +++ b/specs/027-localization-support/checklists/requirements.md @@ -0,0 +1,39 @@ +# Specification Quality Checklist: Localization Support (Language Resource Files) + +**Purpose**: Validate Companion specification completeness before planning +**Created**: 2026-08-27 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed (User Scenarios, Requirements, Success Criteria) + +## Requirement Completeness + +- [x] Any [NEEDS CLARIFICATION] markers are genuine ambiguities (≤3) deferred to clarify — not unresolved guesses +- [x] Each Functional Requirement is a single, testable MUST/SHOULD statement +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into the specification + +## Notes + +- Self-check pass completed 2026-08-27. All items pass. +- Amended 2026-08-27 (post-plan): added FR-023 + SC-010 + US3 scenarios/edge cases for "respect the OS display language as the default when a matching resource set ships, else fall back to Australian English". Plan artifacts (`plan.md`, `research.md`, `data-model.md`, `contracts/`) reconciled in the same edit; no new `[NEEDS CLARIFICATION]` introduced. +- Amended 2026-08-27 (post-plan): added FR-024 + US2 scenario 4 + edge cases + SC-001 clause for "user-facing enum values are localised" (`Enum__` in a shared `EnumsResource` via a `LocalizeEnum` helper; enum identity stays culture-invariant). Plan artifacts reconciled (new `EnumsResource`, `EnumLocalizationExtensions.cs`, Decision 10, enum-coverage / no-raw-enum-display guards); no new `[NEEDS CLARIFICATION]`. +- One `[NEEDS CLARIFICATION]` marker remains (FR-021): whether immediate in-session language switching is required for v1, or whether "applies on next launch" is acceptable. An informed default ("applies on next launch, with a restart notice") is recorded under Assumptions; the marker is deferred to `/speckit-companion-clarify`. +- "MAUI Blazor Hybrid", "GL balance", and "endonym" appear in Assumptions / Key Entities as pre-existing product context and domain vocabulary already used across the project's other specs, not as new implementation or design choices. +- Scope is deliberately staged: P1 proves the extraction pattern on the navigation shell + Members; P2 applies it across all remaining surfaces; P3 adds user-selectable language. Out-of-scope items (plugin text, RTL, report layout redesign, translating user-entered data, translating logs) are listed under Assumptions. diff --git a/specs/027-localization-support/contracts/localization-contracts.md b/specs/027-localization-support/contracts/localization-contracts.md new file mode 100644 index 00000000..b05b7d94 --- /dev/null +++ b/specs/027-localization-support/contracts/localization-contracts.md @@ -0,0 +1,208 @@ +# Contracts: Localization Infrastructure + +**Feature**: `027-localization-support` | **Date**: 2026-08-27 + +No REST/CLI surface. These are the C# interfaces, marker types, and the one entity-field change that components, providers, renderers, and tests code against. The spec pins no Verbatim Constraints, so every identifier below is a plan proposal, not a user-pinned string — implementation may refine names as long as the responsibilities hold. + +--- + +## 1. `ILocalizer` — thin facade (project: `StageFright.Core`) + +`src/StageFright.Core/Localization/ILocalizer.cs` + +```csharp +namespace StageFright.Core.Localization; + +/// +/// Area-agnostic access to localized strings. Wraps IStringLocalizerFactory so that +/// a missing key for the active culture logs a warning and falls back to the +/// Australian English (neutral) value — never returns blank or the raw key. +/// +public interface ILocalizer +{ + string Get(string key); + string Get(string key, params object[] args); // named-placeholder formatting + string Plural(string key, int count, params object[] args); // resolves key + "_One" / "_Other" + string Enum(System.Enum value); // EnumsResource["Enum_" + type.Name + "_" + value] (FR-024) +} +``` + +- Consumers that prefer the framework type inject `IStringLocalizer` directly; `ILocalizer` is the convenience wrapper used where an area marker would be noisy (e.g. a loop over report columns). +- Blazor components expose a `[Inject] IStringLocalizer L` in the code-behind; markup reads `@L["Members_List_Title"]`. +- **Enum display text** (FR-024): `ILocalizer.Enum(value)` — and a `LocalizeEnum` extension method over it — is the only sanctioned way to render an enum value to a user. It resolves `Enum__` against the shared `EnumsResource` through the same missing-key-logging path. `enum.ToString()` / interpolation of an enum at a display site is a guard-test failure. The enum's name/number stays the culture-invariant identity for storage, sorting, comparison, and `