diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index 076a7ce4..f9c14e90 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -27,6 +27,24 @@ class OrdersModule(ModuleBase): The key (`"orders"`) is the **namespace** — it prefixes every key in the files. `smpy create-module` scaffolds this method and a starter `en.json` automatically. +### Audience + +Every module's catalog ships inside the Inertia shared props on full page +loads. A module whose UI sits entirely behind login can declare that: + +```python +meta = ModuleMeta(name="Orders", ..., i18n_audience="admin") +``` + +`"admin"` catalogs are withheld from anonymous visitors — a public content +page stops paying for settings-form labels it can never render — and shipped +as soon as the user authenticates (the login transition re-sends the bundle +even on an Inertia partial). The default is `"public"`: ship to everyone. +Server-side `Translator` lookups always see every namespace regardless. +The framework's own admin modules (settings, permissions, dashboard, +file_storage, audit_log, feature_flags, background_tasks, branding) declare +`"admin"`. + ## Key naming Keys are `..` with hierarchical JSON objects that flatten at boot: diff --git a/framework/cli/simple_module_cli/templates/host/templates/index.html b/framework/cli/simple_module_cli/templates/host/templates/index.html index ea8da7ea..eea793c9 100644 --- a/framework/cli/simple_module_cli/templates/host/templates/index.html +++ b/framework/cli/simple_module_cli/templates/host/templates/index.html @@ -3,7 +3,12 @@ - SimpleModule + {# branding_head reads the branding module's settings (when installed) so + crawlers and link previews see the configured site name before React + hydrates; Inertia's takes over per-page titles after that. #} + {% set brand = branding_head(request) %} + {{ brand.app_name }} + {% if brand.theme_color %}{% endif %} diff --git a/framework/core/simple_module_core/i18n.py b/framework/core/simple_module_core/i18n.py index f2e97b49..6d2751aa 100644 --- a/framework/core/simple_module_core/i18n.py +++ b/framework/core/simple_module_core/i18n.py @@ -78,7 +78,7 @@ class I18nRegistry: def __init__(self, default_locale: str, supported_locales: list[str]) -> None: self.default_locale = default_locale self.supported_locales = list(supported_locales) - self._sources: list[tuple[str, Path]] = [] + self._sources: list[tuple[str, Path, str]] = [] self._messages: dict[str, dict[str, str]] = {} # Immutable views into ``_messages`` — handed out by ``messages()`` to # avoid a per-call dict copy. Rebuilt whenever ``load()`` runs. @@ -88,15 +88,22 @@ def __init__(self, default_locale: str, supported_locales: list[str]) -> None: # avoids per-request dict copies that used to dominate allocations on the # Inertia render path. self._message_snapshots: dict[str, dict[str, str]] = {} + self._public_snapshots: dict[str, dict[str, str]] = {} self._available_locales: tuple[str, ...] = () self._available_locales_list: list[str] = [] self._empty_view: MappingProxyType[str, str] = MappingProxyType({}) self._empty_snapshot: dict[str, str] = {} self._loaded = False - def add_source(self, namespace: str, locale_dir: Path) -> None: - """Queue a module's locale directory for loading under a namespace.""" - self._sources.append((namespace, Path(locale_dir))) + def add_source(self, namespace: str, locale_dir: Path, *, audience: str = "public") -> None: + """Queue a module's locale directory for loading under a namespace. + + ``audience="admin"`` keeps the namespace out of the public snapshot + (:meth:`messages_snapshot` with ``include_admin=False``) so catalogs + for login-gated UI aren't shipped to anonymous visitors. Server-side + lookups (:meth:`messages`) always see every namespace. + """ + self._sources.append((namespace, Path(locale_dir), audience)) def load(self) -> None: """Read and flatten all registered JSON files. @@ -105,8 +112,11 @@ def load(self) -> None: warning but do not raise. Malformed JSON raises ValueError. """ self._messages = {locale: {} for locale in self.supported_locales} + public_messages: dict[str, dict[str, str]] = { + locale: {} for locale in self.supported_locales + } - for namespace, locale_dir in self._sources: + for namespace, locale_dir, audience in self._sources: for locale in self.supported_locales: path = locale_dir / f"{locale}.json" if not path.is_file(): @@ -124,6 +134,8 @@ def load(self) -> None: raise ValueError(f"{path} must contain a JSON object at the top level") flat = flatten_messages(raw, prefix=namespace) self._messages[locale].update(flat) + if audience != "admin": + public_messages[locale].update(flat) # Cache the derived views now that loading is complete. Downstream # (middleware, translator, switcher) reads these on every request. @@ -131,8 +143,10 @@ def load(self) -> None: locale: MappingProxyType(msgs) for locale, msgs in self._messages.items() } # Plain-dict snapshots for serialization callers. ``dict(msgs)`` runs - # once here rather than on every Inertia render. + # once here rather than on every Inertia render. The public variant + # (admin namespaces excluded) is what anonymous visitors receive. self._message_snapshots = {locale: dict(msgs) for locale, msgs in self._messages.items()} + self._public_snapshots = public_messages self._available_locales = tuple(locale for locale, msgs in self._messages.items() if msgs) self._available_locales_list = list(self._available_locales) self._loaded = True @@ -165,7 +179,7 @@ def messages(self, locale: str) -> Mapping[str, str]: return self._empty_view return MappingProxyType(raw) - def messages_snapshot(self, locale: str) -> dict[str, str]: + def messages_snapshot(self, locale: str, *, include_admin: bool = True) -> dict[str, str]: """Plain-dict snapshot for callers that JSON-serialize the result. Built once at :meth:`load` time and handed out by reference on every @@ -173,12 +187,17 @@ def messages_snapshot(self, locale: str) -> dict[str, str]: corrupts subsequent responses. Used by the Inertia shared-props builder where it sits on the request hot path; prior to this method, ``dict(messages(locale))`` per request was the top own-code allocator. + + ``include_admin=False`` returns the variant without ``audience="admin"`` + namespaces — what anonymous visitors are served. """ - snapshot = self._message_snapshots.get(locale) + pool = self._message_snapshots if include_admin else self._public_snapshots + snapshot = pool.get(locale) if snapshot is not None: return snapshot # Fallback for tests that skip ``load()``: synthesize the snapshot on - # demand from whatever ``_messages`` holds. + # demand from whatever ``_messages`` holds (audience information only + # exists for sources that went through ``load()``). raw = self._messages.get(locale) if raw is None: return self._empty_snapshot diff --git a/framework/core/simple_module_core/module.py b/framework/core/simple_module_core/module.py index d10ee0ca..703c04e6 100644 --- a/framework/core/simple_module_core/module.py +++ b/framework/core/simple_module_core/module.py @@ -35,6 +35,15 @@ class ModuleMeta: installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it. When ``None``, no compatibility check is performed (legacy modules). """ + i18n_audience: str = "public" + """Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``. + + ``"public"`` (the default) ships the catalog in every Inertia payload. + ``"admin"`` ships it only to authenticated users — declare it on modules + whose UI sits entirely behind login (settings, permissions, dashboards) so + anonymous visitors don't download admin form labels on every public page. + The catalog is always available server-side (``Translator``) either way. + """ class ModuleBase(ABC): diff --git a/framework/core/tests/test_i18n.py b/framework/core/tests/test_i18n.py index 253b9185..48b9de0a 100644 --- a/framework/core/tests/test_i18n.py +++ b/framework/core/tests/test_i18n.py @@ -65,6 +65,29 @@ def test_available_locales_reports_loaded(self, tmp_path: Path) -> None: reg.load() assert sorted(reg.available_locales()) == ["en", "es"] + def test_admin_sources_are_excluded_from_the_public_snapshot(self, tmp_path: Path) -> None: + """Anonymous visitors must not download catalogs for login-gated UI.""" + self._write_locale(tmp_path / "pages", "en", {"title": "Pages"}) + self._write_locale(tmp_path / "settings", "en", {"form": {"key": "Key"}}) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("pages", tmp_path / "pages") + reg.add_source("settings", tmp_path / "settings", audience="admin") + reg.load() + assert reg.messages_snapshot("en", include_admin=False) == {"pages.title": "Pages"} + # The full snapshot and server-side lookups still see everything. + assert reg.messages_snapshot("en") == { + "pages.title": "Pages", + "settings.form.key": "Key", + } + assert reg.messages("en")["settings.form.key"] == "Key" + + def test_public_snapshot_defaults_to_everything(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "p", "en", {"title": "Products"}) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("products", tmp_path / "p") + reg.load() + assert reg.messages_snapshot("en", include_admin=False) == reg.messages_snapshot("en") + def test_missing_locale_file_is_warning_not_error( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/framework/hosting/simple_module_hosting/_inertia_shared.py b/framework/hosting/simple_module_hosting/_inertia_shared.py index 896c2814..01f82b78 100644 --- a/framework/hosting/simple_module_hosting/_inertia_shared.py +++ b/framework/hosting/simple_module_hosting/_inertia_shared.py @@ -12,20 +12,25 @@ logger = logging.getLogger(__name__) _I18N_SESSION_LOCALE_KEY = "__i18n_locale" +_I18N_SESSION_AUDIENCE_KEY = "__i18n_audience" _INERTIA_HEADER = "x-inertia" _INERTIA_HEADER_TRUE = "true" -def build_i18n_block(scope: Scope, request: Request) -> dict: +def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = True) -> dict: """Assemble the ``i18n`` shared-props block for the current request. Rules: * No registry / no locale → serve an empty English block and log once. * Inertia XHR partials (``X-Inertia: true``) reuse the client-side - cached messages; send ``messages: None`` unless the locale differs - from what was last served on this session. + cached messages; send ``messages: None`` unless the locale — or the + audience, see below — differs from what was last served this session. * Full page loads and locale transitions ship the complete dict. + * Anonymous visitors receive the public snapshot: catalogs of modules + declaring ``i18n_audience="admin"`` are withheld (issue #248). A login + or logout mid-session counts as a change so the freshly-authenticated + client isn't left holding the anonymous catalog (or vice versa). """ # Test fixtures sometimes build a bare FastAPI with a partial app.state.sm # stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable. @@ -42,6 +47,7 @@ def build_i18n_block(scope: Scope, request: Request) -> dict: return {"locale": "en", "supportedLocales": ["en"], "messages": {}} is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE + audience = "full" if is_authenticated else "public" session_dict = scope.get("session") # When the session is absent (pre-session-middleware routes, WebSocket # upgrades), treat locale as "unchanged" so Inertia XHR requests still @@ -52,13 +58,22 @@ def build_i18n_block(scope: Scope, request: Request) -> dict: locale_changed = last_locale != locale if locale_changed: session_dict[_I18N_SESSION_LOCALE_KEY] = locale + last_audience = session_dict.get(_I18N_SESSION_AUDIENCE_KEY) + audience_changed = last_audience != audience + if audience_changed: + session_dict[_I18N_SESSION_AUDIENCE_KEY] = audience else: locale_changed = False - send_messages = (not is_inertia) or locale_changed + audience_changed = False + send_messages = (not is_inertia) or locale_changed or audience_changed return { "locale": locale, "supportedLocales": registry.available_locales(), - "messages": registry.messages_snapshot(locale) if send_messages else None, + "messages": ( + registry.messages_snapshot(locale, include_admin=is_authenticated) + if send_messages + else None + ), } diff --git a/framework/hosting/simple_module_hosting/i18n_manifest.py b/framework/hosting/simple_module_hosting/i18n_manifest.py index 87b49dbc..fddbafd0 100644 --- a/framework/hosting/simple_module_hosting/i18n_manifest.py +++ b/framework/hosting/simple_module_hosting/i18n_manifest.py @@ -44,8 +44,9 @@ def build_i18n_registry( extra_sources: list[tuple[str, str, Path]] = [] for mod in modules: + audience = getattr(mod.meta, "i18n_audience", "public") for namespace, locale_dir in mod.locale_dirs().items(): - registry.add_source(namespace, locale_dir) + registry.add_source(namespace, locale_dir, audience=audience) host_locales = project_root / "host" / "locales" if host_locales.is_dir(): diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index 6dbb5cfe..7d917f50 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -256,7 +256,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: all_perms = self.permission_registry.all_permissions frontend_permissions = expand_permissions(resolved, all_perms) if is_authenticated else [] - i18n_block = build_i18n_block(scope, request) + i18n_block = build_i18n_block(scope, request, is_authenticated=is_authenticated) principal_serializer: PrincipalSerializer | None = getattr( scope["app"].state, "principal_serializer", None diff --git a/framework/hosting/tests/test_inertia_i18n_shared_props.py b/framework/hosting/tests/test_inertia_i18n_shared_props.py index f96b0e70..60ff63ee 100644 --- a/framework/hosting/tests/test_inertia_i18n_shared_props.py +++ b/framework/hosting/tests/test_inertia_i18n_shared_props.py @@ -64,6 +64,89 @@ def test_inertia_shared_props_reflect_cookie_locale() -> None: assert body["i18n"]["messages"] == {"hello": "Hola"} +def _build_audience_app(tmp_path) -> FastAPI: + """App with a *loaded* registry (public + admin sources) and header-driven auth. + + ``X-Test-Auth: 1`` marks the request authenticated, so one client session + can flip auth state mid-session — the login/logout transition the i18n + block must react to. + """ + import json + from types import SimpleNamespace + + for ns, data in ( + ("pages", {"title": "Pages"}), + ("settings", {"key": "Key"}), + ): + (tmp_path / ns).mkdir(exist_ok=True) + (tmp_path / ns / "en.json").write_text(json.dumps(data)) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("pages", tmp_path / "pages") + reg.add_source("settings", tmp_path / "settings", audience="admin") + reg.load() + + app = FastAPI() + app.state.sm = SimpleNamespace(i18n_registry=reg) + + @app.get("/shared") + def shared(request: Request) -> JSONResponse: + return JSONResponse(request.state.inertia_shared) + + app.add_middleware( + InertiaLayoutDataMiddleware, + menu_registry=MenuRegistry(), + permission_registry=PermissionRegistry(), + ) + app.add_middleware( + LocaleMiddleware, + supported_locales=["en"], + default_locale="en", + ) + + class _HeaderAuth: + def __init__(self, app_): + self.app = app_ + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + request = Request(scope) + if request.headers.get("X-Test-Auth") == "1": + request.state.user = SimpleNamespace(roles=[]) + await self.app(scope, receive, send) + + app.add_middleware(_HeaderAuth) + app.add_middleware(SessionMiddleware, secret_key="test-secret") + return app + + +def test_anonymous_visitors_receive_only_public_catalogs(tmp_path) -> None: + client = TestClient(_build_audience_app(tmp_path)) + body = client.get("/shared").json() + assert body["i18n"]["messages"] == {"pages.title": "Pages"} + + +def test_authenticated_users_receive_admin_catalogs_too(tmp_path) -> None: + client = TestClient(_build_audience_app(tmp_path)) + body = client.get("/shared", headers={"X-Test-Auth": "1"}).json() + assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"} + + +def test_login_mid_session_reships_messages_on_an_inertia_partial(tmp_path) -> None: + """An Inertia partial normally skips messages — but not right after login, + or the freshly-authenticated client would keep the anonymous catalog.""" + client = TestClient(_build_audience_app(tmp_path)) + client.get("/shared") # anonymous full load seeds the session audience + body = client.get("/shared", headers={"X-Test-Auth": "1", "X-Inertia": "true"}).json() + assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"} + + +def test_inertia_partial_with_unchanged_audience_still_skips_messages(tmp_path) -> None: + client = TestClient(_build_audience_app(tmp_path)) + client.get("/shared") + body = client.get("/shared", headers={"X-Inertia": "true"}).json() + assert body["i18n"]["messages"] is None + + def test_inertia_shared_props_fallback_when_registry_missing( caplog, ) -> None: diff --git a/modules/audit_log/audit_log/module.py b/modules/audit_log/audit_log/module.py index 982a4283..bebdf157 100644 --- a/modules/audit_log/audit_log/module.py +++ b/modules/audit_log/audit_log/module.py @@ -32,6 +32,7 @@ class AuditLogModule(ModuleBase): route_prefix=API_PREFIX, view_prefix=VIEW_PREFIX, depends_on=[_MODULE_USERS], + i18n_audience="admin", ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: diff --git a/modules/background_tasks/background_tasks/module.py b/modules/background_tasks/background_tasks/module.py index 6fa0c71f..a8c217ac 100644 --- a/modules/background_tasks/background_tasks/module.py +++ b/modules/background_tasks/background_tasks/module.py @@ -41,6 +41,7 @@ class BackgroundTasksModule(ModuleBase): route_prefix=API_PREFIX, view_prefix=VIEW_PREFIX, depends_on=[_MODULE_USERS], + i18n_audience="admin", ) def register_settings(self, app: FastAPI) -> None: diff --git a/modules/branding/branding/module.py b/modules/branding/branding/module.py index 926da776..91a3aa4f 100644 --- a/modules/branding/branding/module.py +++ b/modules/branding/branding/module.py @@ -26,6 +26,9 @@ class BrandingModule(ModuleBase): route_prefix=constants.ROUTE_PREFIX, view_prefix=constants.VIEW_PREFIX, depends_on=[constants._MODULE_SETTINGS, constants._MODULE_FILE_STORAGE], + # Branding's public contribution (site name, colors, design pack) rides + # the shared-props provider, not i18n keys — the catalog is admin forms. + i18n_audience="admin", ) def register_settings(self, app: FastAPI) -> None: diff --git a/modules/dashboard/dashboard/module.py b/modules/dashboard/dashboard/module.py index 04e2d4ce..5ea99ca9 100644 --- a/modules/dashboard/dashboard/module.py +++ b/modules/dashboard/dashboard/module.py @@ -22,6 +22,7 @@ class DashboardModule(ModuleBase): route_prefix="/api/dashboard", view_prefix="/dashboard", depends_on=[_MODULE_USERS], + i18n_audience="admin", ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: diff --git a/modules/feature_flags/feature_flags/module.py b/modules/feature_flags/feature_flags/module.py index bea41512..16a5ac6d 100644 --- a/modules/feature_flags/feature_flags/module.py +++ b/modules/feature_flags/feature_flags/module.py @@ -29,6 +29,7 @@ class FeatureFlagsModule(ModuleBase): name="FeatureFlags", route_prefix="/api/feature_flags", view_prefix="/feature_flags", + i18n_audience="admin", ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: diff --git a/modules/file_storage/file_storage/module.py b/modules/file_storage/file_storage/module.py index ecbefb8f..fadd0766 100644 --- a/modules/file_storage/file_storage/module.py +++ b/modules/file_storage/file_storage/module.py @@ -29,6 +29,7 @@ class FileStorageModule(ModuleBase): # Needs Settings to run first so register_module_settings can reach # app.state.settings.module_registry during register_settings. depends_on=[constants._MODULE_SETTINGS], + i18n_audience="admin", ) def register_settings(self, app: FastAPI) -> None: diff --git a/modules/permissions/permissions/module.py b/modules/permissions/permissions/module.py index 5fdaef9f..367ad459 100644 --- a/modules/permissions/permissions/module.py +++ b/modules/permissions/permissions/module.py @@ -22,6 +22,7 @@ class PermissionsModule(ModuleBase): route_prefix="/api/permissions", view_prefix="/permissions", depends_on=[_MODULE_AUTH, _MODULE_USERS], + i18n_audience="admin", ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: diff --git a/modules/settings/settings/module.py b/modules/settings/settings/module.py index 2870bf14..b60e0c97 100644 --- a/modules/settings/settings/module.py +++ b/modules/settings/settings/module.py @@ -30,6 +30,7 @@ class SettingsModule(ModuleBase): name=MODULE_NAME, route_prefix=API_PREFIX, view_prefix=VIEW_PREFIX, + i18n_audience="admin", ) def register_settings(self, app: FastAPI) -> None: diff --git a/qa-shots/brand/01-public-landing.png b/qa-shots/brand/01-public-landing.png new file mode 100644 index 00000000..f845d764 Binary files /dev/null and b/qa-shots/brand/01-public-landing.png differ diff --git a/qa-shots/brand/02-public-landing-geotrees.png b/qa-shots/brand/02-public-landing-geotrees.png new file mode 100644 index 00000000..f845d764 Binary files /dev/null and b/qa-shots/brand/02-public-landing-geotrees.png differ diff --git a/qa-shots/brand/03-landing-geotrees.png b/qa-shots/brand/03-landing-geotrees.png new file mode 100644 index 00000000..f845d764 Binary files /dev/null and b/qa-shots/brand/03-landing-geotrees.png differ diff --git a/qa-shots/brand/04-landing-geotrees-final.png b/qa-shots/brand/04-landing-geotrees-final.png new file mode 100644 index 00000000..18f0c0de Binary files /dev/null and b/qa-shots/brand/04-landing-geotrees-final.png differ diff --git a/qa-shots/brand/05-sidebar-geotrees.png b/qa-shots/brand/05-sidebar-geotrees.png new file mode 100644 index 00000000..cf781afb Binary files /dev/null and b/qa-shots/brand/05-sidebar-geotrees.png differ diff --git a/qa-shots/iteration-1/00-after-login.png b/qa-shots/iteration-1/00-after-login.png new file mode 100644 index 00000000..3505fb7b Binary files /dev/null and b/qa-shots/iteration-1/00-after-login.png differ diff --git a/qa-shots/iteration-1/01-dashboard.png b/qa-shots/iteration-1/01-dashboard.png new file mode 100644 index 00000000..483dd37d Binary files /dev/null and b/qa-shots/iteration-1/01-dashboard.png differ diff --git a/qa-shots/iteration-1/02-map.png b/qa-shots/iteration-1/02-map.png new file mode 100644 index 00000000..40957940 Binary files /dev/null and b/qa-shots/iteration-1/02-map.png differ diff --git a/qa-shots/iteration-1/03-plots-browse.png b/qa-shots/iteration-1/03-plots-browse.png new file mode 100644 index 00000000..a791011e Binary files /dev/null and b/qa-shots/iteration-1/03-plots-browse.png differ diff --git a/qa-shots/iteration-1/04-plots-create.png b/qa-shots/iteration-1/04-plots-create.png new file mode 100644 index 00000000..8e3bbb41 Binary files /dev/null and b/qa-shots/iteration-1/04-plots-create.png differ diff --git a/qa-shots/iteration-1/05-plots-edit.png b/qa-shots/iteration-1/05-plots-edit.png new file mode 100644 index 00000000..2936763d Binary files /dev/null and b/qa-shots/iteration-1/05-plots-edit.png differ diff --git a/qa-shots/iteration-1/06-maplayers-browse.png b/qa-shots/iteration-1/06-maplayers-browse.png new file mode 100644 index 00000000..cf19e7a0 Binary files /dev/null and b/qa-shots/iteration-1/06-maplayers-browse.png differ diff --git a/qa-shots/iteration-1/07-maplayers-create.png b/qa-shots/iteration-1/07-maplayers-create.png new file mode 100644 index 00000000..a1969126 Binary files /dev/null and b/qa-shots/iteration-1/07-maplayers-create.png differ diff --git a/qa-shots/iteration-1/08-maplayers-edit.png b/qa-shots/iteration-1/08-maplayers-edit.png new file mode 100644 index 00000000..49cff02a Binary files /dev/null and b/qa-shots/iteration-1/08-maplayers-edit.png differ diff --git a/qa-shots/iteration-1/09-profile-details.png b/qa-shots/iteration-1/09-profile-details.png new file mode 100644 index 00000000..9ea0d896 Binary files /dev/null and b/qa-shots/iteration-1/09-profile-details.png differ diff --git a/qa-shots/iteration-1/10-broadcasting.png b/qa-shots/iteration-1/10-broadcasting.png new file mode 100644 index 00000000..507d45fb Binary files /dev/null and b/qa-shots/iteration-1/10-broadcasting.png differ diff --git a/qa-shots/iteration-1/11-files.png b/qa-shots/iteration-1/11-files.png new file mode 100644 index 00000000..33d95c7f Binary files /dev/null and b/qa-shots/iteration-1/11-files.png differ diff --git a/qa-shots/iteration-1/12-settings-me.png b/qa-shots/iteration-1/12-settings-me.png new file mode 100644 index 00000000..62137cf3 Binary files /dev/null and b/qa-shots/iteration-1/12-settings-me.png differ diff --git a/qa-shots/iteration-1/13-admin.png b/qa-shots/iteration-1/13-admin.png new file mode 100644 index 00000000..831a27ad Binary files /dev/null and b/qa-shots/iteration-1/13-admin.png differ diff --git a/qa-shots/iteration-1/14-admin-users.png b/qa-shots/iteration-1/14-admin-users.png new file mode 100644 index 00000000..acda112a Binary files /dev/null and b/qa-shots/iteration-1/14-admin-users.png differ diff --git a/qa-shots/iteration-1/15-admin-roles.png b/qa-shots/iteration-1/15-admin-roles.png new file mode 100644 index 00000000..1b9019b5 Binary files /dev/null and b/qa-shots/iteration-1/15-admin-roles.png differ diff --git a/qa-shots/iteration-1/16-openiddict-clients.png b/qa-shots/iteration-1/16-openiddict-clients.png new file mode 100644 index 00000000..45bcf17b Binary files /dev/null and b/qa-shots/iteration-1/16-openiddict-clients.png differ diff --git a/qa-shots/iteration-1/17-tenants-manage.png b/qa-shots/iteration-1/17-tenants-manage.png new file mode 100644 index 00000000..1a3573b9 Binary files /dev/null and b/qa-shots/iteration-1/17-tenants-manage.png differ diff --git a/qa-shots/iteration-1/18-email-templates.png b/qa-shots/iteration-1/18-email-templates.png new file mode 100644 index 00000000..81beca47 Binary files /dev/null and b/qa-shots/iteration-1/18-email-templates.png differ diff --git a/qa-shots/iteration-1/19-email-history.png b/qa-shots/iteration-1/19-email-history.png new file mode 100644 index 00000000..37a09500 Binary files /dev/null and b/qa-shots/iteration-1/19-email-history.png differ diff --git a/qa-shots/iteration-1/20-settings-menus.png b/qa-shots/iteration-1/20-settings-menus.png new file mode 100644 index 00000000..a2ecb591 Binary files /dev/null and b/qa-shots/iteration-1/20-settings-menus.png differ diff --git a/qa-shots/iteration-1/21-feature-flags-manage.png b/qa-shots/iteration-1/21-feature-flags-manage.png new file mode 100644 index 00000000..c0f7340e Binary files /dev/null and b/qa-shots/iteration-1/21-feature-flags-manage.png differ diff --git a/qa-shots/iteration-1/22-rate-limiting-manage.png b/qa-shots/iteration-1/22-rate-limiting-manage.png new file mode 100644 index 00000000..79230258 Binary files /dev/null and b/qa-shots/iteration-1/22-rate-limiting-manage.png differ diff --git a/qa-shots/iteration-1/23-audit-logs-browse.png b/qa-shots/iteration-1/23-audit-logs-browse.png new file mode 100644 index 00000000..c737bb17 Binary files /dev/null and b/qa-shots/iteration-1/23-audit-logs-browse.png differ diff --git a/qa-shots/iteration-1/24-settings-manage.png b/qa-shots/iteration-1/24-settings-manage.png new file mode 100644 index 00000000..800bb7cb Binary files /dev/null and b/qa-shots/iteration-1/24-settings-manage.png differ diff --git a/qa-shots/iteration-1/25-account-manage.png b/qa-shots/iteration-1/25-account-manage.png new file mode 100644 index 00000000..37ffb2f9 Binary files /dev/null and b/qa-shots/iteration-1/25-account-manage.png differ diff --git a/qa-shots/iteration-1/30-plots-create-valid.png b/qa-shots/iteration-1/30-plots-create-valid.png new file mode 100644 index 00000000..7d6a500b Binary files /dev/null and b/qa-shots/iteration-1/30-plots-create-valid.png differ diff --git a/qa-shots/iteration-1/31-plots-create-invalid.png b/qa-shots/iteration-1/31-plots-create-invalid.png new file mode 100644 index 00000000..345fc182 Binary files /dev/null and b/qa-shots/iteration-1/31-plots-create-invalid.png differ diff --git a/qa-shots/iteration-1/32-maplayers-create-valid.png b/qa-shots/iteration-1/32-maplayers-create-valid.png new file mode 100644 index 00000000..5cb0a947 Binary files /dev/null and b/qa-shots/iteration-1/32-maplayers-create-valid.png differ diff --git a/qa-shots/iteration-1/33-maplayers-create-invalid.png b/qa-shots/iteration-1/33-maplayers-create-invalid.png new file mode 100644 index 00000000..e2b8f31e Binary files /dev/null and b/qa-shots/iteration-1/33-maplayers-create-invalid.png differ diff --git a/qa-shots/iteration-1/34-profile-save.png b/qa-shots/iteration-1/34-profile-save.png new file mode 100644 index 00000000..8a00ec2b Binary files /dev/null and b/qa-shots/iteration-1/34-profile-save.png differ diff --git a/qa-shots/iteration-1/35-profile-persisted.png b/qa-shots/iteration-1/35-profile-persisted.png new file mode 100644 index 00000000..6a8bebb0 Binary files /dev/null and b/qa-shots/iteration-1/35-profile-persisted.png differ diff --git a/qa-shots/iteration-2/00-dashboard-authed.png b/qa-shots/iteration-2/00-dashboard-authed.png new file mode 100644 index 00000000..a3022584 Binary files /dev/null and b/qa-shots/iteration-2/00-dashboard-authed.png differ diff --git a/qa-shots/iteration-2/01-dashboard-authed.png b/qa-shots/iteration-2/01-dashboard-authed.png new file mode 100644 index 00000000..a3022584 Binary files /dev/null and b/qa-shots/iteration-2/01-dashboard-authed.png differ diff --git a/qa-shots/iteration-2/01-dashboard.png b/qa-shots/iteration-2/01-dashboard.png new file mode 100644 index 00000000..990d24a3 Binary files /dev/null and b/qa-shots/iteration-2/01-dashboard.png differ diff --git a/qa-shots/iteration-2/02-map.png b/qa-shots/iteration-2/02-map.png new file mode 100644 index 00000000..fab2e833 Binary files /dev/null and b/qa-shots/iteration-2/02-map.png differ diff --git a/qa-shots/iteration-2/03-plots-browse.png b/qa-shots/iteration-2/03-plots-browse.png new file mode 100644 index 00000000..83dfff83 Binary files /dev/null and b/qa-shots/iteration-2/03-plots-browse.png differ diff --git a/qa-shots/iteration-2/04-plots-create.png b/qa-shots/iteration-2/04-plots-create.png new file mode 100644 index 00000000..b1acfb30 Binary files /dev/null and b/qa-shots/iteration-2/04-plots-create.png differ diff --git a/qa-shots/iteration-2/05-plots-edit.png b/qa-shots/iteration-2/05-plots-edit.png new file mode 100644 index 00000000..e2592d4b Binary files /dev/null and b/qa-shots/iteration-2/05-plots-edit.png differ diff --git a/qa-shots/iteration-2/06-maplayers-browse.png b/qa-shots/iteration-2/06-maplayers-browse.png new file mode 100644 index 00000000..a9cb7774 Binary files /dev/null and b/qa-shots/iteration-2/06-maplayers-browse.png differ diff --git a/qa-shots/iteration-2/07-maplayers-create.png b/qa-shots/iteration-2/07-maplayers-create.png new file mode 100644 index 00000000..064aab75 Binary files /dev/null and b/qa-shots/iteration-2/07-maplayers-create.png differ diff --git a/qa-shots/iteration-2/08-maplayers-edit.png b/qa-shots/iteration-2/08-maplayers-edit.png new file mode 100644 index 00000000..b8412459 Binary files /dev/null and b/qa-shots/iteration-2/08-maplayers-edit.png differ diff --git a/qa-shots/iteration-2/09-profile-details.png b/qa-shots/iteration-2/09-profile-details.png new file mode 100644 index 00000000..3adafc1a Binary files /dev/null and b/qa-shots/iteration-2/09-profile-details.png differ diff --git a/qa-shots/iteration-2/10-broadcasting.png b/qa-shots/iteration-2/10-broadcasting.png new file mode 100644 index 00000000..0615e654 Binary files /dev/null and b/qa-shots/iteration-2/10-broadcasting.png differ diff --git a/qa-shots/iteration-2/11-files.png b/qa-shots/iteration-2/11-files.png new file mode 100644 index 00000000..2f2a84b4 Binary files /dev/null and b/qa-shots/iteration-2/11-files.png differ diff --git a/qa-shots/iteration-2/12-settings-me.png b/qa-shots/iteration-2/12-settings-me.png new file mode 100644 index 00000000..d0a43b77 Binary files /dev/null and b/qa-shots/iteration-2/12-settings-me.png differ diff --git a/qa-shots/iteration-2/13-admin.png b/qa-shots/iteration-2/13-admin.png new file mode 100644 index 00000000..e0d9f7ea Binary files /dev/null and b/qa-shots/iteration-2/13-admin.png differ diff --git a/qa-shots/iteration-2/14-admin-users.png b/qa-shots/iteration-2/14-admin-users.png new file mode 100644 index 00000000..d5601c49 Binary files /dev/null and b/qa-shots/iteration-2/14-admin-users.png differ diff --git a/qa-shots/iteration-2/15-admin-roles.png b/qa-shots/iteration-2/15-admin-roles.png new file mode 100644 index 00000000..483a70fc Binary files /dev/null and b/qa-shots/iteration-2/15-admin-roles.png differ diff --git a/qa-shots/iteration-2/16-openiddict-clients.png b/qa-shots/iteration-2/16-openiddict-clients.png new file mode 100644 index 00000000..82e13b86 Binary files /dev/null and b/qa-shots/iteration-2/16-openiddict-clients.png differ diff --git a/qa-shots/iteration-2/17-tenants-manage.png b/qa-shots/iteration-2/17-tenants-manage.png new file mode 100644 index 00000000..d0c1c18a Binary files /dev/null and b/qa-shots/iteration-2/17-tenants-manage.png differ diff --git a/qa-shots/iteration-2/18-email-templates.png b/qa-shots/iteration-2/18-email-templates.png new file mode 100644 index 00000000..296dc2f9 Binary files /dev/null and b/qa-shots/iteration-2/18-email-templates.png differ diff --git a/qa-shots/iteration-2/19-email-history.png b/qa-shots/iteration-2/19-email-history.png new file mode 100644 index 00000000..e53c2863 Binary files /dev/null and b/qa-shots/iteration-2/19-email-history.png differ diff --git a/qa-shots/iteration-2/20-settings-menus.png b/qa-shots/iteration-2/20-settings-menus.png new file mode 100644 index 00000000..cbebce01 Binary files /dev/null and b/qa-shots/iteration-2/20-settings-menus.png differ diff --git a/qa-shots/iteration-2/21-feature-flags-manage.png b/qa-shots/iteration-2/21-feature-flags-manage.png new file mode 100644 index 00000000..a4699ced Binary files /dev/null and b/qa-shots/iteration-2/21-feature-flags-manage.png differ diff --git a/qa-shots/iteration-2/22-rate-limiting-manage.png b/qa-shots/iteration-2/22-rate-limiting-manage.png new file mode 100644 index 00000000..ca159c3c Binary files /dev/null and b/qa-shots/iteration-2/22-rate-limiting-manage.png differ diff --git a/qa-shots/iteration-2/23-audit-logs-browse.png b/qa-shots/iteration-2/23-audit-logs-browse.png new file mode 100644 index 00000000..95c2af92 Binary files /dev/null and b/qa-shots/iteration-2/23-audit-logs-browse.png differ diff --git a/qa-shots/iteration-2/24-settings-manage.png b/qa-shots/iteration-2/24-settings-manage.png new file mode 100644 index 00000000..22742ba1 Binary files /dev/null and b/qa-shots/iteration-2/24-settings-manage.png differ diff --git a/qa-shots/iteration-2/25-account-manage.png b/qa-shots/iteration-2/25-account-manage.png new file mode 100644 index 00000000..70144df8 Binary files /dev/null and b/qa-shots/iteration-2/25-account-manage.png differ diff --git a/qa-shots/iteration-2/26-dark-mode.png b/qa-shots/iteration-2/26-dark-mode.png new file mode 100644 index 00000000..27485ac7 Binary files /dev/null and b/qa-shots/iteration-2/26-dark-mode.png differ diff --git a/qa-shots/map-debug/01-map-initial.png b/qa-shots/map-debug/01-map-initial.png new file mode 100644 index 00000000..6cb21a19 Binary files /dev/null and b/qa-shots/map-debug/01-map-initial.png differ diff --git a/qa-shots/map-debug/02-map-with-plots-layer.png b/qa-shots/map-debug/02-map-with-plots-layer.png new file mode 100644 index 00000000..13605143 Binary files /dev/null and b/qa-shots/map-debug/02-map-with-plots-layer.png differ diff --git a/qa-shots/map-debug/03-map-world.png b/qa-shots/map-debug/03-map-world.png new file mode 100644 index 00000000..c3eddec8 Binary files /dev/null and b/qa-shots/map-debug/03-map-world.png differ diff --git a/qa-shots/map-debug/04-map-zoomed.png b/qa-shots/map-debug/04-map-zoomed.png new file mode 100644 index 00000000..a3e6c1a4 Binary files /dev/null and b/qa-shots/map-debug/04-map-zoomed.png differ diff --git a/qa-shots/map-debug/05-map-zoom2.png b/qa-shots/map-debug/05-map-zoom2.png new file mode 100644 index 00000000..45943633 Binary files /dev/null and b/qa-shots/map-debug/05-map-zoom2.png differ diff --git a/qa-shots/map-debug/06-map-plot-marker.png b/qa-shots/map-debug/06-map-plot-marker.png new file mode 100644 index 00000000..d4725d76 Binary files /dev/null and b/qa-shots/map-debug/06-map-plot-marker.png differ diff --git a/qa-shots/map-debug/07-layer-panel.png b/qa-shots/map-debug/07-layer-panel.png new file mode 100644 index 00000000..f1ffd9fc Binary files /dev/null and b/qa-shots/map-debug/07-layer-panel.png differ diff --git a/qa-shots/map-debug/10-map-fullscreen.png b/qa-shots/map-debug/10-map-fullscreen.png new file mode 100644 index 00000000..b6e87097 Binary files /dev/null and b/qa-shots/map-debug/10-map-fullscreen.png differ diff --git a/qa-shots/map-debug/11-map-fullscreen-fixed.png b/qa-shots/map-debug/11-map-fullscreen-fixed.png new file mode 100644 index 00000000..996159c0 Binary files /dev/null and b/qa-shots/map-debug/11-map-fullscreen-fixed.png differ diff --git a/qa-shots/map-debug/12-map-fullscreen-desktop.png b/qa-shots/map-debug/12-map-fullscreen-desktop.png new file mode 100644 index 00000000..c4eb65b4 Binary files /dev/null and b/qa-shots/map-debug/12-map-fullscreen-desktop.png differ diff --git a/qa-shots/map-debug/13-plots-browse-consistent.png b/qa-shots/map-debug/13-plots-browse-consistent.png new file mode 100644 index 00000000..cb8e70b6 Binary files /dev/null and b/qa-shots/map-debug/13-plots-browse-consistent.png differ diff --git a/qa-shots/map-debug/14-maplayers-browse.png b/qa-shots/map-debug/14-maplayers-browse.png new file mode 100644 index 00000000..45c09d57 Binary files /dev/null and b/qa-shots/map-debug/14-maplayers-browse.png differ diff --git a/qa-shots/map-debug/15-plots-create.png b/qa-shots/map-debug/15-plots-create.png new file mode 100644 index 00000000..a8420713 Binary files /dev/null and b/qa-shots/map-debug/15-plots-create.png differ