From 16697e7d59a492a1b3608c30d2877fe21dffa612 Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:12:57 +0530 Subject: [PATCH 01/15] Prevent frontend crash when approve fails with server error On POST /translations/approve/ 500, wrapFetch returns {} with no status/translation. The hook still dispatched updateEntityTranslation with undefined, wiping the current translation and (in older array-based code) crashing useTranslationStatus with "tx is undefined". Only dispatch entity updates on success (results.status), matching the guard already used in useSendTranslation. Success with translation=null (no active translation left) still clears to undefined correctly. Fixes #3612 --- .../editor/hooks/useUpdateTranslationStatus.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts b/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts index 19615cfc2c..eeb4cca47a 100644 --- a/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts +++ b/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts @@ -113,9 +113,16 @@ export function useUpdateTranslationStatus( } // Update entity translation data now that it has changed on the server. - dispatch( - updateEntityTranslation(entity.pk, results.translation ?? undefined), - ); + // Only update on success: on server errors (e.g. 500 returns {}) the + // response has no status, and dispatching undefined would wipe the + // current translation (see #3612). Note that success may legitimately + // return translation=null (no active translation left), which we map + // to undefined to show the entity as missing. + if (results.status) { + dispatch( + updateEntityTranslation(entity.pk, results.translation ?? undefined), + ); + } } NProgress.done(); From 2bccfda9ee7b59d08211027c1a508c7d789af1c2 Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:13:35 +0530 Subject: [PATCH 02/15] Catch Fluent trailing Junk and multiple entries in checks parse_entry() only returns the first entry and silently drops trailing Junk, so translations like 'key = value[foo]' passed validation and later failed sync with 'Expected an entry start'. Validate the full resource with parser.parse() and require exactly one Message/Term. Surface Junk annotation messages, report multiple entries as a single-entry error. Fixes #3621 --- pontoon/checks/libraries/custom.py | 54 +++++++++++++++++++----------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/pontoon/checks/libraries/custom.py b/pontoon/checks/libraries/custom.py index d3c160d280..9394e4568b 100644 --- a/pontoon/checks/libraries/custom.py +++ b/pontoon/checks/libraries/custom.py @@ -111,28 +111,42 @@ def run_custom_checks(entity: Entity, string: str) -> dict[str, list[str]]: errors.append("Ending newline mismatch") case Resource.Format.FLUENT: - translation_ast = parser.parse_entry(string) - entity_ast = parser.parse_entry(entity.string) - - # Parse error - if isinstance(translation_ast, ast.Junk): - errors.append(translation_ast.annotations[0].message) - - # Not a localizable entry - elif not isinstance(translation_ast, (ast.Message, ast.Term)): - errors.append("Translation needs to be a valid localizable entry") - - # Message ID mismatch - elif entity_ast.id.name != translation_ast.id.name: - errors.append("Translation key needs to match source string key") + res = parser.parse(string) + if len(res.body) != 1 or not isinstance( + res.body[0], (ast.Message, ast.Term) + ): + # parse_entry() only returns the first entry and silently drops + # trailing Junk (e.g. a line starting with `[`), so validate + # the full resource instead (see #3621). + junk = next( + (e for e in res.body if isinstance(e, ast.Junk)), None + ) + if junk is not None and junk.annotations: + errors.append(junk.annotations[0].message) + elif len(res.body) != 1: + errors.append( + "Translation needs to be a single localizable entry" + ) + else: + errors.append( + "Translation needs to be a valid localizable entry" + ) - # Empty translation entry warning; set here rather than with the other - # non-DB warnings to avoid needing to parse the Fluent message twice. else: - visitor = IsEmptyVisitor() - visitor.visit(translation_ast) - if visitor.is_empty: - non_db_warnings.append("Empty translation") + translation_ast = res.body[0] + entity_ast = parser.parse_entry(entity.string) + + # Message ID mismatch + if entity_ast.id.name != translation_ast.id.name: + errors.append("Translation key needs to match source string key") + + # Empty translation entry warning; set here rather than with the other + # non-DB warnings to avoid needing to parse the Fluent message twice. + else: + visitor = IsEmptyVisitor() + visitor.visit(translation_ast) + if visitor.is_empty: + non_db_warnings.append("Empty translation") case Resource.Format.WEBEXT: try: From 6ce4ce467a0843178dfdcc0b7a6577fe59541648 Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:14:12 +0530 Subject: [PATCH 03/15] Preserve custom X- headers when writing .po files Regenerating .po files from scratch dropped headers present only in target files, e.g. X-WagtailLocalize-TranslationID required by wagtail-localize. Merge any existing X- headers (outside the trim list) from the previous target file before serializing. Fixes #3596 --- pontoon/sync/core/translations_to_repo.py | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/pontoon/sync/core/translations_to_repo.py b/pontoon/sync/core/translations_to_repo.py index b7db262517..5d6755bec3 100644 --- a/pontoon/sync/core/translations_to_repo.py +++ b/pontoon/sync/core/translations_to_repo.py @@ -22,7 +22,7 @@ SelectMessage, ) from moz.l10n.paths import L10nConfigPaths, L10nDiscoverPaths -from moz.l10n.resource import serialize_resource +from moz.l10n.resource import parse_resource, serialize_resource from django.conf import settings from django.db.models import Q @@ -251,6 +251,28 @@ def update_changed_resources( try: lc_plurals = locale.cldr_plurals_list() tr_res = build_translated_resource(locale, lc_translations, res) + # Preserve custom X- headers (e.g. X-WagtailLocalize-TranslationID) + # present only in the existing target file. The file is regenerated + # from scratch, so without this they would be dropped (see #3596). + if tr_res.format == Format.gettext and isfile(target_path): + try: + prev_res = parse_resource( + target_path, gettext_plurals=lc_plurals + ) + current_keys = {m.key for m in tr_res.meta} + for m in prev_res.meta: + if ( + m.key.startswith("X-") + and m.key not in current_keys + and m.key not in gettext_trim_headers + ): + tr_res.meta.append(Metadata(m.key, m.value)) + current_keys.add(m.key) + except Exception as error: + log.warning( + f"[{project.slug}:{path}, {locale.code}] " + f"Preserving custom headers failed: {error}" + ) # A symlinked target should not redirect the write out of the checkout if paths.base and not is_inside(paths.base, target_path): log.error( From ca5d7b44d3c9408b80ac5604270f8ab0dc7b007e Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Mon, 12 Oct 2026 00:04:34 +0530 Subject: [PATCH 04/15] Make sync commit info accurate for forced pushes Forced syncs rewrite all locales, but git only commits byte-differing files. Counting every rewrite inflated messages (122 localizations for 2 files/1 locale) and entity-only translator matching credited stale authors across locales. - Serialize to string and skip unchanged files before counting. - Track (entity_id, locale_id) pairs for Co-authored-by attribution. --- pontoon/sync/core/translations_to_repo.py | 28 +++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/pontoon/sync/core/translations_to_repo.py b/pontoon/sync/core/translations_to_repo.py index 5d6755bec3..4a437da075 100644 --- a/pontoon/sync/core/translations_to_repo.py +++ b/pontoon/sync/core/translations_to_repo.py @@ -191,7 +191,10 @@ def update_changed_resources( prev = changed_resources[db_res.path] if prev[1]: prev[1].add(change.locale) - changed_entities = {change.entity for change in db_changes} + # Track (entity, locale) pairs, not just entities, to avoid attributing + # translators to locales they did not touch on forced all-locale rewrites + # (see #3601). + changed_keys = {(change.entity_id, change.locale_id) for change in db_changes} if changed_resources: n = len(changed_resources) str_resources = "resource" if n == 1 else "resources" @@ -281,12 +284,29 @@ def update_changed_resources( ) continue makedirs(dirname(target_path), exist_ok=True) + content = "".join( + serialize_resource(tr_res, gettext_plurals=lc_plurals) + ) + # Only count byte-changed files: forced syncs rewrite all locales, + # but git only commits differing files. Counting every rewrite + # inflates commit messages (e.g. 122 localizations for 1 locale, + # see #3601). + try: + with open(target_path, encoding="utf-8") as prev_file: + prev_content = prev_file.read() + except FileNotFoundError: + prev_content = None + if content == prev_content: + continue with open(target_path, "w", encoding="utf-8") as file: - for line in serialize_resource(tr_res, gettext_plurals=lc_plurals): - file.write(line) + file.write(content) updated_locales.add(locale) for tx in lc_translations.values(): - if tx.approved and tx.entity in changed_entities and tx.user: + if ( + tx.approved + and (tx.entity_id, tx.locale_id) in changed_keys + and tx.user + ): translators[tx.user].add(locale.code) count += 1 except Exception as error: From eed591772a3bafe0e1e837fcef186d3f06161a27 Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:24:43 +0530 Subject: [PATCH 05/15] Harden active translation reset against duplicates Concurrent approve/sync could elect different active translations, violating the entity_locale_active unique constraint and aborting sync. Wrap reset in transaction.atomic with select_for_update, clear any duplicate active rows, and activate via queryset update inside the same transaction instead of instance save. Fixes #2284 --- pontoon/base/models/entity.py | 36 +++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/pontoon/base/models/entity.py b/pontoon/base/models/entity.py index cf2548429d..7f84c996bd 100644 --- a/pontoon/base/models/entity.py +++ b/pontoon/base/models/entity.py @@ -70,25 +70,33 @@ def reset_active_translation(self, locale: Locale) -> dict[str, Any] | None: Return data for the active translation if it exists, or None otherwise. """ - from pontoon.base.models.translation import Translation + from django.db import transaction - translations = self.translation_set.filter(locale=locale) - translations.update(active=False) + from pontoon.base.models.translation import Translation - candidates = translations.filter(rejected=False).order_by( - "-approved", "-pretranslated", "-fuzzy", "-date" - ) + with transaction.atomic(): + translations = self.translation_set.select_for_update().filter( + locale=locale + ) + translations.update(active=False) - if candidates: - active_translation = candidates[0] - active_translation.active = True + candidates = translations.filter(rejected=False).order_by( + "-approved", "-pretranslated", "-fuzzy", "-date" + ) - # Do not trigger the overridden Translation.save() method - super(Translation, active_translation).save(update_fields=["active"]) + if not candidates: + return None - return active_translation.serialize() - else: - return None + active_pk = candidates[0].pk + # Belt-and-braces: clear any duplicate active rows activated + # concurrently or left over from before the unique constraint + # (see #2284), then activate the winner via queryset update to + # stay inside the same transaction. + translations.exclude(pk=active_pk).filter(active=True).update( + active=False + ) + translations.filter(pk=active_pk).update(active=True) + return Translation.objects.get(pk=active_pk).serialize() def reset_term_translation(self, locale): """ From a21098fc39b04230f02ceeeb6e5bbef22b15531f Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:25:12 +0530 Subject: [PATCH 06/15] Skip source locales as sync targets Syncing en-US/en/templates as a target locale corrupts stats and violates base_locale check constraints, wiping the Resources tab. Filter source codes from locale_map with a warning instead of crashing. Fixes #2260 --- pontoon/sync/core/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pontoon/sync/core/__init__.py b/pontoon/sync/core/__init__.py index 8db7393962..7c7886b6f4 100644 --- a/pontoon/sync/core/__init__.py +++ b/pontoon/sync/core/__init__.py @@ -73,6 +73,19 @@ def sync_project( ) else: locale_map = {lc.code: lc for lc in project.locales.order_by("code")} + # Never sync the source locale as a target: translating en-US into itself + # corrupts stats and violates check constraints (see #2260). + source_locale_codes = {"en-US", "en", "templates"} + skipped = sorted(set(locale_map) & source_locale_codes) + if skipped: + log.warning( + f"{log_prefix} Skipping source locale(s) as sync target: {', '.join(skipped)}" + ) + locale_map = { + code: locale + for code, locale in locale_map.items() + if code not in source_locale_codes + } paths.locales = list(locale_map.keys()) added_entities_count, changed_paths, removed_paths = sync_resources_from_repo( From e677af759496659aad23744d67d9192ef5c19810 Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:25:49 +0530 Subject: [PATCH 07/15] Notify users about new Terminology terms The Terminology project is DB-only (sync_disabled), so sync's notify_users() never ran for it and new terms triggered no new_string notifications. Reuse notify_users() after term creation (new localizable terms, becoming-localizable, and text changes creating new entities). Notifications are best-effort and never break term saves. create_entity now returns (entity, created) and keeps self.entity_id in sync. Fixes #2562 --- pontoon/terminology/models.py | 39 ++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/pontoon/terminology/models.py b/pontoon/terminology/models.py index afdbd16e3a..23556a5a4f 100644 --- a/pontoon/terminology/models.py +++ b/pontoon/terminology/models.py @@ -21,6 +21,27 @@ def update_terminology_project_stats(): TranslatedResource.objects.filter(resource=resource).calculate_stats() +def notify_new_terminology_entity(entity: Entity) -> None: + """ + Notify users with new_string_notifications about a new Terminology entity. + + The Terminology project is DB-only (sync_disabled), so sync's notify_users() + never runs for it. Reuse the same logic here (see #2562). + """ + try: + from pontoon.sync.core import notify_users + + entity.refresh_from_db(fields=["date_created"]) + notify_users(entity.resource.project, entity.date_created) + except Exception: + # Notifications must never break term creation; sync logs the error. + import logging + + logging.getLogger(__name__).exception( + "Failed to notify users about new terminology term" + ) + + class TermQuerySet(models.QuerySet["Term"]): def for_string(self, string): terms = [] @@ -146,6 +167,7 @@ def create_entity(self): # Using update() to avoid circular Term.save() call Term.objects.filter(pk=self.pk).update(entity_id=entity.id) + self.entity_id = entity.id entity.term = self if not created: @@ -160,6 +182,7 @@ def create_entity(self): for index, e in enumerate(entities): e.order = index Entity.objects.bulk_update(entities, ["order"]) + return entity, created def obsolete_entity(self): entity = self.entity @@ -188,14 +211,22 @@ def handle_term_update(self): # If non-localizable term becomes localizable, create a corresponding Entity elif not old_term.localizable and term.localizable: - term.create_entity() + _, created = term.create_entity() + if created: + notify_new_terminology_entity( + Entity.objects.get(pk=term.entity_id) + ) # If relevant changes are made to the localizable term that stays localizable else: # If Term.text changes, a new Entity instance gets created and the previous one becomes obsolete. if old_term.text != term.text: old_term.obsolete_entity() - term.create_entity() + _, created = term.create_entity() + if created: + notify_new_terminology_entity( + Entity.objects.get(pk=term.entity_id) + ) # If Term.part_of_speech, Term.definition or Term.usage change, Entity.comment gets updated. elif ( @@ -220,8 +251,10 @@ def handle_term_create(self): """ After creating a new localizable Term, create its Entity """ - self.create_entity() + entity, created = self.create_entity() update_terminology_project_stats() + if created: + notify_new_terminology_entity(entity) def save(self, *args, **kwargs): created = self.pk is None From 3c5142ec5560ccfa84a8df03d2f91288d74553ab Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Mon, 12 Oct 2026 00:05:42 +0530 Subject: [PATCH 08/15] Preserve translation metadata when strings move between files Entities are keyed by (resource path, key), so a move obsoletes the old row and creates a new one, orphaning translations, change markers and stats. Match added entities to just-obsoleted ones by exact key and string, re-pointing non-rejected translations (pk unchanged, so comments, checks and TM follow) and carrying ChangedEntityLocale markers. Ambiguous keys are skipped. Also abort syncs that would obsolete >100 entities and >75% of rows, rolling back the atomic transaction instead of mass-deleting metadata. --- pontoon/sync/core/entities.py | 77 +++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/pontoon/sync/core/entities.py b/pontoon/sync/core/entities.py index 90d7b1abea..403a7a1d5b 100644 --- a/pontoon/sync/core/entities.py +++ b/pontoon/sync/core/entities.py @@ -15,12 +15,14 @@ from django.db.models import Q from pontoon.base.models import ( + ChangedEntityLocale, Entity, Locale, Project, Resource, Section, TranslatedResource, + Translation, ) from pontoon.sync.core.checkout import Checkout from pontoon.sync.formats import as_entity @@ -241,6 +243,19 @@ def update_resources( obsolete_entities.append(prev_ent) key_path, key_entity = key log_rm[key_path].append("/".join(key_entity)) + # Guard against mass loss of translation metadata from a bad sync + # (e.g. miscomputed paths obsoleting most entities). Aborting rolls back + # the surrounding atomic transaction (see #3500). + if ( + len(obsolete_entities) > 100 + and len(prev_entities) > 0 + and len(obsolete_entities) / len(prev_entities) > 0.75 + ): + log.error( + f"[{project.slug}] Refusing mass-obsolete of " + f"{len(obsolete_entities)}/{len(prev_entities)} entities; aborting sync" + ) + raise Exception("mass obsoletion guard triggered") Entity.objects.bulk_update( obsolete_entities, ["obsolete", "date_obsoleted", "section"] ) @@ -297,6 +312,8 @@ def update_resources( added_entities = Entity.objects.bulk_create(added_entities) add_count = len(added_entities) + carry_translations_for_moves(project, obsolete_entities, added_entities, now) + if log_rm or log_add or log_mod: for path in sorted(list(log_rm.keys() | log_add.keys() | log_mod.keys())): for desc, log_data in ( @@ -312,6 +329,54 @@ def update_resources( return add_count, changed_res_paths +def carry_translations_for_moves( + project: Project, + obsolete_entities: list[Entity], + added_entities: list[Entity], + now: datetime, +) -> None: + """ + Preserve translation attributions, timestamps and unreviewed suggestions + when strings move between files (see #3500). + + Entities are keyed by (resource path, key), so a move obsoletes the old + row and creates a brand-new one, orphaning its translations. When an added + entity matches a just-obsoleted one by exact key and string, re-point the + old non-rejected translations (and their change markers) at the new row + instead of leaving them on the obsolete entity. + """ + if not obsolete_entities or not added_entities: + return + # Group just-obsoleted entities by key for move detection. Require exact + # key + string matches, and skip ambiguous keys (same key obsoleted from + # several files) to avoid attributing to the wrong resource. + obsolete_by_key: dict[tuple[str, ...], list[Entity]] = defaultdict(list) + for ent in obsolete_entities: + obsolete_by_key[tuple(ent.key)].append(ent) + moves = 0 + for new_ent in added_entities: + candidates = obsolete_by_key.get(tuple(new_ent.key), []) + candidates = [e for e in candidates if e.string == new_ent.string] + if len(candidates) != 1: + continue + old_ent = candidates[0] + # Move non-rejected translations, preserving user, date, approved + # metadata and history (pk unchanged, so comments/checks/TM follow). + moved = Translation.objects.filter( + entity_id=old_ent.pk, rejected=False + ).update(entity_id=new_ent.pk) + if not moved: + continue + # Carry sync change markers so the moved translations commit outward. + for cl in ChangedEntityLocale.objects.filter(entity_id=old_ent.pk): + ChangedEntityLocale.objects.get_or_create( + entity_id=new_ent.pk, locale_id=cl.locale_id + ) + moves += 1 + if moves: + log.info(f"[{project.slug}] Preserved translations for {moves} moved strings") + + def add_resources( project: Project, updates: dict[str, L10nResource[Message]], @@ -372,6 +437,18 @@ def add_resources( Section.objects.bulk_create(new_sections) Entity.objects.bulk_create(new_entities) + # Preserve translations for strings moved into brand-new files: match new + # entities against entities obsoleted in this same sync (see #3500). + # Idempotent with the update_resources carry-over above (already-moved + # sources have no non-rejected translations left). + if new_entities: + just_obsoleted = list( + Entity.objects.filter( + resource__project=project, obsolete=True, date_obsoleted=now + ).iterator() + ) + carry_translations_for_moves(project, just_obsoleted, new_entities, now) + ent_count = len(new_entities) added_paths = {ar.path for ar in new_resources} log.info( From 6dc62a26b7c44998484d3da14a1993c5136c10dc Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Mon, 12 Oct 2026 00:08:34 +0530 Subject: [PATCH 09/15] Quarantine Fluent nested same-selector messages during sync moz.l10n collapses a select nested inside another select with the same selector, dropping outer text of inner variants. Detect such entries with fluent.syntax and preserve stored value/properties/string instead of persisting corruption; warn once per resource. New entries are stored as-is with a warning and heal on force resync after the library fix. --- pontoon/sync/core/entities.py | 64 +++++++++++++++++++++++++++++-- pontoon/sync/formats/__init__.py | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/pontoon/sync/core/entities.py b/pontoon/sync/core/entities.py index 403a7a1d5b..49273c0978 100644 --- a/pontoon/sync/core/entities.py +++ b/pontoon/sync/core/entities.py @@ -25,7 +25,7 @@ Translation, ) from pontoon.sync.core.checkout import Checkout -from pontoon.sync.formats import as_entity +from pontoon.sync.formats import as_entity, fluent_nested_same_selector_keys log = logging.getLogger(__name__) @@ -44,6 +44,10 @@ def sync_resources_from_repo( log.info(f"[{project.slug}] Syncing entities from repo...") # db_path -> parsed_resource updates: dict[str, L10nResource[Message]] = {} + # db_path -> entry names with nested same-selector Fluent selects, whose + # moz.l10n flattening is lossy (see #4183). Used to avoid persisting the + # corrupted value until the library is fixed. + nested_fluent: dict[str, set[str]] = {} source_paths = set(paths.ref_paths) source_plurals = ["one", "other"] for co_path in checkout.changed: @@ -66,6 +70,17 @@ def sync_resources_from_repo( try: Resource.Format(res.format.name) updates[db_path] = res + if res.format == L10nFormat.fluent: + try: + with open(path, encoding="utf-8") as f: + affected = fluent_nested_same_selector_keys(f.read()) + if affected: + nested_fluent[db_path] = affected + except Exception as error: + log.warning( + f"[{project.slug}:{db_path}] " + f"Nested Fluent detection failed: {error}" + ) except ValueError: log.error( f"[{project.slug}:{db_path}] Skipping resource with unsupported format: {res.format.name}" @@ -78,9 +93,11 @@ def sync_resources_from_repo( with transaction.atomic(): renamed_paths = rename_resources(project, paths, checkout) removed_paths = remove_resources(project, paths, checkout, now) - old_res_added_ent_count, changed_paths = update_resources(project, updates, now) + old_res_added_ent_count, changed_paths = update_resources( + project, updates, now, nested_fluent + ) new_res_added_ent_count, added_paths = add_resources( - project, updates, changed_paths, now + project, updates, changed_paths, now, nested_fluent ) update_translated_resources(project, locale_map, paths) @@ -140,6 +157,7 @@ def update_resources( project: Project, updates: dict[str, L10nResource[Message]], now: datetime, + nested_fluent: dict[str, set[str]] | None = None, ) -> tuple[int, set[str]]: changed_resources: dict[int, Resource] | None = ( {res.pk: res for res in project.resources.filter(path__in=updates.keys())} @@ -280,12 +298,38 @@ def update_resources( added_entities: list[Entity] = [] log_mod: dict[str, list[str]] = defaultdict(list) log_add: dict[str, list[str]] = defaultdict(list) + nested_warned: set[str] = set() for key, next_ent in next_entities.items(): key_path, key_entity = key prev_ent = prev_entities.get(key, None) if prev_ent is None: added_entities.append(next_ent) log_add[key_path].append("/".join(key_entity)) + elif ( + nested_fluent + and key_path in nested_fluent + and key_entity[-1] in nested_fluent[key_path] + ): + # moz.l10n collapses nested same-selector selects, losing the outer + # text of inner variants (mozilla/moz-l10n#211). Preserve the stored + # value/properties/string instead of persisting the corruption; + # other fields (comment, meta, section) still update. Remove this + # guard after the library fix + a force resync of affected files + # (see #4183). + if key_path not in nested_warned: + nested_warned.add(key_path) + log.warning( + f"[{project.slug}:{key_path}] Preserving stored Fluent value " + "for nested same-selector entries until mozilla/moz-l10n#211 " + "is fixed" + ) + if ( + model_update(prev_ent, "comment", next_ent.comment) + + model_update(prev_ent, "meta", next_ent.meta) + + model_update(prev_ent, "section", next_ent.section) + ): + mod_entities.append(prev_ent) + log_mod[key_path].append("/".join(key_entity)) elif ( model_update(prev_ent, "value", next_ent.value) + model_update(prev_ent, "properties", next_ent.properties) @@ -382,6 +426,7 @@ def add_resources( updates: dict[str, L10nResource[Message]], changed_paths: set[str], now: datetime, + nested_fluent: dict[str, set[str]] | None = None, ) -> tuple[int, set[str]]: new_resources = [ @@ -437,6 +482,19 @@ def add_resources( Section.objects.bulk_create(new_sections) Entity.objects.bulk_create(new_entities) + # Flag brand-new entries with nested same-selector selects: their stored + # value is already flattened by moz.l10n (see #4183) and will need a force + # resync once mozilla/moz-l10n#211 is fixed. + if nested_fluent: + for db_res in new_resources: + affected = nested_fluent.get(db_res.path) + if affected: + log.warning( + f"[{project.slug}:{db_res.path}] Stored flattened value for " + f"nested same-selector entries ({', '.join(sorted(affected))}); " + "resync after the moz.l10n fix to heal" + ) + # Preserve translations for strings moved into brand-new files: match new # entities against entities obsoleted in this same sync (see #3500). # Idempotent with the update_resources carry-over above (already-moved diff --git a/pontoon/sync/formats/__init__.py b/pontoon/sync/formats/__init__.py index f9f6a744a5..fee01eb736 100644 --- a/pontoon/sync/formats/__init__.py +++ b/pontoon/sync/formats/__init__.py @@ -25,6 +25,71 @@ from pontoon.base.models import Entity +def fluent_nested_same_selector_keys(source: str) -> set[str]: + """ + Return the names of Fluent entries with a select expression nested inside + another select using the same selector. + + moz.l10n (<= 0.14.2) collapses such nesting when parsing, dropping the + outer text of the inner variants (mozilla/moz-l10n#211, pontoon#4183). + Callers use this to avoid persisting the corrupted flattening until the + library is fixed. Never raises; returns an empty set when parsing fails. + """ + try: + from fluent.syntax import FluentParser + from fluent.syntax import ast as fluent_ast + except ImportError: + return set() + try: + res = FluentParser().parse(source) + except Exception: + return set() + + def selector_key(selector) -> tuple: + if isinstance(selector, fluent_ast.VariableReference): + return ("var", selector.id.name) + if isinstance(selector, fluent_ast.TermReference): + return ("term", selector.id.name) + if isinstance(selector, fluent_ast.MessageReference): + return ("msg", selector.id.name) + return (type(selector).__name__,) + + def has_nested_same_selector(node, ancestors: tuple = ()) -> bool: + if isinstance(node, fluent_ast.SelectExpression): + key = selector_key(node.selector) + if key in ancestors: + return True + ancestors = ancestors + (key,) + return any(has_nested_same_selector(v.value, ancestors) for v in node.variants) + if isinstance(node, fluent_ast.Pattern): + return any(has_nested_same_selector(el, ancestors) for el in node.elements) + if isinstance(node, fluent_ast.Placeable): + return has_nested_same_selector(node.expression, ancestors) + return False + + found: set[str] = set() + for entry in res.body: + if not isinstance(entry, (fluent_ast.Message, fluent_ast.Term)): + continue + try: + nested = entry.value is not None and has_nested_same_selector(entry.value) + if not nested: + for attr in entry.attributes or []: + if has_nested_same_selector(attr.value): + nested = True + break + if nested: + # fluent.syntax Term ids omit the leading "-", which Pontoon + # (and moz.l10n) entry ids include. + name = entry.id.name + if isinstance(entry, fluent_ast.Term): + name = "-" + name + found.add(name) + except Exception: + continue + return found + + @dataclass class RepoTranslation: """ From f108f7fd5904ee3d1f29edd24337819476b7e50e Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Mon, 12 Oct 2026 00:09:06 +0530 Subject: [PATCH 10/15] Add regression tests for Fluent validation and nested selects - checks: trailing Junk line and multiple entries are blocking errors. - sync formats: nested same-selector detection helper cases. --- pontoon/checks/tests/test_custom.py | 16 +++++++++ pontoon/sync/tests/formats/test_ftl.py | 47 +++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/pontoon/checks/tests/test_custom.py b/pontoon/checks/tests/test_custom.py index b40d654d17..8ff359ab50 100644 --- a/pontoon/checks/tests/test_custom.py +++ b/pontoon/checks/tests/test_custom.py @@ -181,6 +181,22 @@ def test_ftl_id_missmatch(): ) == {"pErrors": ["Translation key needs to match source string key"]} +def test_ftl_trailing_junk(): + """Content after a valid entry (e.g. a line starting with `[`) is an error""" + ftl_entity = mock_entity("fluent", string="key = value") + assert run_custom_checks(ftl_entity, "key = value\n[foo]") == { + "pErrors": ["Expected an entry start"] + } + + +def test_ftl_multiple_entries(): + """More than one entry in a translation is an error""" + ftl_entity = mock_entity("fluent", string="key = value") + assert run_custom_checks(ftl_entity, "key = value\nkey2 = value2") == { + "pErrors": ["Translation needs to be a single localizable entry"] + } + + def test_android_apostrophes(): original = "Source string" translation = "Translation with a straight '" diff --git a/pontoon/sync/tests/formats/test_ftl.py b/pontoon/sync/tests/formats/test_ftl.py index 259a868c4a..0ec89179bd 100644 --- a/pontoon/sync/tests/formats/test_ftl.py +++ b/pontoon/sync/tests/formats/test_ftl.py @@ -4,7 +4,11 @@ from moz.l10n.formats import Format from moz.l10n.resource import parse_resource -from pontoon.sync.formats import as_entity, as_repo_translations +from pontoon.sync.formats import ( + as_entity, + as_repo_translations, + fluent_nested_same_selector_keys, +) def test_fluent(): @@ -136,3 +140,44 @@ def test_fluent(): -term-key = Term .attr = Attribute """) + + +def test_fluent_nested_same_selector_keys(): + """Nested selects sharing a selector must be detected (see #4183).""" + assert fluent_nested_same_selector_keys( + dedent("""\ + error-title-429 = + { $retryAfter -> + [0] You are going too fast. + *[other] You are going too fast { $retryAfter -> + [one] second + *[other] seconds + }. + } + """) + ) == {"error-title-429"} + # Nested selects with different selectors are fine. + assert ( + fluent_nested_same_selector_keys( + dedent("""\ + key = + { $x -> + [a] { $y -> + [c] C + *[d] D + } + *[b] B + } + """) + ) + == set() + ) + # Simple selects, plain messages and invalid sources are fine. + assert ( + fluent_nested_same_selector_keys( + "key =\n { $n ->\n [one] One\n *[other] Other\n }\n" + ) + == set() + ) + assert fluent_nested_same_selector_keys("key = value\n") == set() + assert fluent_nested_same_selector_keys("[[[") == set() From 295f1833d338aeace1bd319b3d41f205629dd117 Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:31:04 +0530 Subject: [PATCH 11/15] Switch mail sender to no-reply@pontoon.mozilla.com Use Pontoon as default sender, add do-not-reply footer to transactional and verification emails, and point the disabled-account contact link at pontoon-team@mozilla.com instead of the no-reply address. Fixes #3831 --- documentation/docs/dev/deployment.md | 2 +- pontoon/base/templates/account/account_inactive.html | 2 +- .../templates/messaging/emails/transactional.html | 1 + .../templates/messaging/emails/verification_email.html | 1 + pontoon/settings/base.py | 10 +--------- 5 files changed, 5 insertions(+), 11 deletions(-) diff --git a/documentation/docs/dev/deployment.md b/documentation/docs/dev/deployment.md index b55ab36c1b..55874af6e5 100644 --- a/documentation/docs/dev/deployment.md +++ b/documentation/docs/dev/deployment.md @@ -88,7 +88,7 @@ Optional. Controls if the database needs a secure connection with the app. Default value is `True`. `DEFAULT_FROM_EMAIL` -Optional. Default email address to send emails from. Default value: `Pontoon `. +Optional. Default email address to send emails from. Default value: `Pontoon `. `DISABLE_COLLECTSTATIC` Disables running `./manage.py collectstatic` during the build. Should be diff --git a/pontoon/base/templates/account/account_inactive.html b/pontoon/base/templates/account/account_inactive.html index 5adcbdcfb0..9c1dc14229 100644 --- a/pontoon/base/templates/account/account_inactive.html +++ b/pontoon/base/templates/account/account_inactive.html @@ -12,7 +12,7 @@

Your account is disabled

If you believe this is a mistake or need further assistance, please - get in touch. + get in touch.

diff --git a/pontoon/messaging/templates/messaging/emails/transactional.html b/pontoon/messaging/templates/messaging/emails/transactional.html index de4bf8f35d..5c2c27b2d2 100644 --- a/pontoon/messaging/templates/messaging/emails/transactional.html +++ b/pontoon/messaging/templates/messaging/emails/transactional.html @@ -18,4 +18,5 @@ {% endblock %} {% block footer %} + Please do not reply to this email. If you need help, contact the Pontoon Team. {% endblock %} diff --git a/pontoon/messaging/templates/messaging/emails/verification_email.html b/pontoon/messaging/templates/messaging/emails/verification_email.html index 3d1683b3bf..c0b4f8f04f 100644 --- a/pontoon/messaging/templates/messaging/emails/verification_email.html +++ b/pontoon/messaging/templates/messaging/emails/verification_email.html @@ -24,4 +24,5 @@ {% endblock %} {% block footer %} + Please do not reply to this email. If you need help, contact the Pontoon Team. {% endblock %} diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index d7db67e717..c67cb5ce23 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -197,17 +197,9 @@ def path(*args): GOOGLE_ANALYTICS_KEY = os.environ.get("GOOGLE_ANALYTICS_KEY", "") -def _get_site_url_netloc(): - from urllib.parse import urlparse - - from django.conf import settings - - return urlparse(settings.SITE_URL).netloc - - def _default_from_email(): return os.environ.get( - "DEFAULT_FROM_EMAIL", f"Pontoon " + "DEFAULT_FROM_EMAIL", "Pontoon " ) From 3c972cb39ab27f51f1bf1de1f815fa7dbc4ca8ee Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:32:32 +0530 Subject: [PATCH 12/15] Ensure login failures show in logs Custom LOGGING wiped Django's default django.request logger, so 500s from broken allauth config (e.g. missing SocialApp table) were silent. Restore django.request ERROR logger and allauth INFO logger on console with verbose formatter, and log exceptions in provider_login_url with provider id for diagnosability. Fixes #4053 --- pontoon/base/templatetags/helpers.py | 11 ++++++++++- pontoon/settings/base.py | 19 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/pontoon/base/templatetags/helpers.py b/pontoon/base/templatetags/helpers.py index 6ed8764415..acf677ab76 100644 --- a/pontoon/base/templatetags/helpers.py +++ b/pontoon/base/templatetags/helpers.py @@ -232,7 +232,16 @@ def provider_login_url(request, provider_id=settings.AUTHENTICATION_METHOD, **qu This function adapts the django-allauth templatetags that don't support jinja2. @TODO: land support for the jinja2 tags in the django-allauth. """ - provider = get_adapter().get_provider(request, provider_id) + import logging + + try: + provider = get_adapter().get_provider(request, provider_id) + except Exception: + logging.getLogger(__name__).exception( + "Failed to get auth provider %r; check SOCIALACCOUNT_PROVIDERS config", + provider_id, + ) + raise auth_params = query.get("auth_params", None) process = query.get("process", None) diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index c67cb5ce23..1b1fa6cf24 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -880,12 +880,27 @@ def _default_from_email(): LOGGING = { "version": 1, "disable_existing_loggers": False, - "handlers": {"console": {"class": "logging.StreamHandler", "stream": sys.stdout}}, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "verbose", + } + }, "formatters": { "verbose": {"format": "[%(levelname)s:%(name)s] %(asctime)s %(message)s"}, }, "loggers": { - "django": {"handlers": ["console"]}, + "django": {"handlers": ["console"], "level": "INFO"}, + # Ensure 500s and auth failures are visible even when DEBUG=False. + # Without an explicit django.request logger, allauth/socialapp errors + # (e.g. missing SocialApp table) surfaced as silent 500s (see #4053). + "django.request": { + "handlers": ["console"], + "level": "ERROR", + "propagate": False, + }, + "allauth": {"handlers": ["console"], "level": "INFO", "propagate": False}, "pontoon": { "handlers": ["console"], "level": os.environ.get("DJANGO_LOG_LEVEL", "DEBUG" if DEBUG else "INFO"), From 096984c09f36d52409084518367ce8c5ee63aa50 Mon Sep 17 00:00:00 2001 From: Francesco Lodolo Date: Sun, 11 Oct 2026 23:34:36 +0530 Subject: [PATCH 13/15] Improve accessibility and contrast of light theme Apply WIP contrast patch (stronger control borders, placeholders, tooltip surfaces, status palette without -alt variants, single focus indicator). Based on attached WIP by flodolo, with profile.css hunk adapted to current main. Fixes #4206 --- .../static/css/admin_project.css | 2 +- pontoon/base/static/css/dark-theme.css | 13 ++- pontoon/base/static/css/light-theme.css | 81 ++++++++++++------- .../static/css/multiple_item_selector.css | 4 +- pontoon/base/static/css/nprogress.css | 8 +- pontoon/base/static/css/style.css | 25 ++++-- pontoon/base/static/css/table.css | 15 ++-- pontoon/base/static/css/terms.css | 2 +- .../contributors/static/css/contributors.css | 4 +- pontoon/contributors/static/css/profile.css | 12 +-- pontoon/contributors/static/css/settings.css | 8 +- pontoon/homepage/static/css/homepage.css | 2 +- pontoon/insights/static/css/insights.css | 2 +- .../insights/static/css/insights_charts.css | 2 +- pontoon/insights/static/css/insights_tab.css | 6 +- pontoon/insights/static/js/insights_tab.js | 12 +-- pontoon/messaging/static/css/messaging.css | 2 +- pontoon/search/static/css/search.css | 4 +- pontoon/teams/static/css/info.css | 2 +- pontoon/teams/static/css/request.css | 1 + pontoon/teams/static/css/team.css | 1 + .../teams/static/css/translation_memory.css | 3 + translate/src/App.css | 4 +- .../components/AddonPromotion.css | 3 +- .../batchactions/components/BatchActions.css | 7 +- .../entitydetails/components/Helpers.css | 2 +- .../history/components/HistoryTranslation.css | 4 +- .../machinery/components/Machinery.css | 5 +- .../components/MachineryTranslation.css | 2 +- .../modules/navbar/components/Navigation.css | 2 +- .../components/OtherLocaleTranslation.css | 2 +- .../project/components/ProjectItem.css | 2 +- .../resource/components/ResourceMenu.css | 2 +- .../resource/components/ResourcePercent.css | 2 +- .../search/components/FiltersPanel.css | 6 +- .../modules/search/components/SearchBox.css | 5 +- .../modules/search/components/SearchPanel.css | 2 +- .../search/components/TimeRangeFilter.css | 2 +- .../search/components/chart-options.ts | 2 +- .../src/modules/terms/components/Term.css | 4 +- .../components/TranslationForm.css | 4 +- .../user/components/UserNotificationsMenu.css | 1 + 42 files changed, 165 insertions(+), 109 deletions(-) diff --git a/pontoon/administration/static/css/admin_project.css b/pontoon/administration/static/css/admin_project.css index 3a5551b597..7f4fe5f0fd 100644 --- a/pontoon/administration/static/css/admin_project.css +++ b/pontoon/administration/static/css/admin_project.css @@ -156,7 +156,7 @@ form .controls .button.manage-resources[disabled]:hover { form a:link, form a:visited { - color: var(--status-translated-alt); + color: var(--status-translated); float: right; text-transform: uppercase; } diff --git a/pontoon/base/static/css/dark-theme.css b/pontoon/base/static/css/dark-theme.css index 52e849e8be..a40bc5706d 100644 --- a/pontoon/base/static/css/dark-theme.css +++ b/pontoon/base/static/css/dark-theme.css @@ -2,6 +2,8 @@ .dark-theme { /* Main */ --main-border-1: #4d5967; + --control-border: #4d5967; + --focus-ring: #4a9ccc; --moz-logo: url(../img/moz-logo-light.svg); /* User banner and details */ @@ -36,6 +38,9 @@ --tooltip-color: #ffffff; --tooltip-color-2: #aaaaaa; --tooltip-border: #4d5967; + --tooltip-surface-background: #000000dd; + --tooltip-surface-color: #ffffff; + --tooltip-surface-color-2: #888888; /* Homepage */ --homepage-background-image: url(../img/background.svg); @@ -44,13 +49,10 @@ /* Translation status */ --status-translated: #7bc876; - --status-translated-alt: #7bc876; --status-pretranslated: #c0ff00; - --status-pretranslated-alt: #c0ff00; --status-warning: #ffa10f; --status-error: #ff3366; --status-missing: #5f7285; - --status-missing-alt: #5f7285; --status-unreviewed: #4fc4f6; --status-fuzzy: #fed271; @@ -60,6 +62,7 @@ --translation-color: #ffffff; --translation-secondary-color: #aaaaaa; --translation-main-button-color: #272a2f; + --on-status-error-color: #ffffff; --translation-subtitle-color: #888888; --editor-background: #333941; --editor-color: #e6e6e6; @@ -108,6 +111,10 @@ --lilac: #c6c1f0; --grey-5: #385465; + /* Insights and profile charts */ + --chart-neutral: #272a2f; + --chart-label-muted: #4d5967; + /* Contribution graph (empty → most active) */ --contribution-graph-0: #333941; --contribution-graph-1: #41554c; diff --git a/pontoon/base/static/css/light-theme.css b/pontoon/base/static/css/light-theme.css index a4ecbc85c8..bd307eada1 100644 --- a/pontoon/base/static/css/light-theme.css +++ b/pontoon/base/static/css/light-theme.css @@ -1,15 +1,18 @@ /* Light Theme Variables */ .light-theme { /* Main */ - --main-border-1: #d8d8d8; + --main-border-1: #c4c4c4; + /* Form controls */ + --control-border: #8c8c8c; + --focus-ring: #1f86b8; --moz-logo: url(../img/moz-logo.svg); /* User banner and details */ - --user-admin: #ff3366; - --user-pm: #ffa10f; - --user-manager: #4fc4f6; - --user-translator: #7bc876; - --user-new: #fed271; + --user-admin: var(--status-error); + --user-pm: var(--status-warning); + --user-manager: var(--status-unreviewed); + --user-translator: var(--status-translated); + --user-new: var(--status-fuzzy); /* Primary (darker) background */ --background-1: #f6f6f6; @@ -19,8 +22,8 @@ --input-background-1: #ffffff; --input-color-1: #2a2a2a; --input-color-2: #000000; - --toggle-color-1: #999999; - --toggle-color-2: #d0d0d0; + --toggle-color-1: #6a6a6a; + --toggle-color-2: #bcbcbc; --icon-background-1: #d0d0d0; --icon-border-1: #bbbbbb; @@ -35,31 +38,34 @@ --tooltip-background: #000000dd; --tooltip-color: #ffffff; --tooltip-color-2: #aaaaaa; - --tooltip-border: #4d5967; + --tooltip-surface-background: #d8dbdf; + --tooltip-surface-color: #2b2b2b; + --tooltip-surface-color-2: #565656; + --tooltip-border: #bbc1c8; /* Homepage */ --homepage-background-image: url(../img/background-light.svg); --homepage-tour-button-background: #ffffff; --homepage-tour-button-color: #000000; - /* Translation status */ - --status-translated: #7bc876; - --status-translated-alt: #49a643; - --status-pretranslated: #c0ff00; - --status-pretranslated-alt: #80a900; - --status-warning: #ffa10f; - --status-error: #ff3366; - --status-missing: #bec7d1; - --status-missing-alt: #5f7285; - --status-unreviewed: #4fc4f6; - --status-fuzzy: #fed271; + /* Translation status colors shared by fills, text, and icons */ + --status-translated: #2f7a3b; + --status-pretranslated: #5b7a00; + --status-warning: #995f08; + --status-error: #d8143a; + --status-missing: #5f7285; + --status-unreviewed: #14749b; + --status-fuzzy: #846619; /* Translation workspace */ --translation-background: #f6f6f6; --translation-border: #bbbbbb; --translation-color: #2a2a2a; - --translation-secondary-color: #707070; - --translation-main-button-color: #2a2a2a; + --translation-secondary-color: #686868; + /* Main action buttons use status colors as backgrounds */ + --translation-main-button-color: #ffffff; + /* Text on error-colored banners and badges */ + --on-status-error-color: #ffffff; --translation-subtitle-color: #555555; --editor-background: #ffffff; --editor-color: #2a2a2a; @@ -77,14 +83,14 @@ --diff-del-color: #c61b3e; --diff-ins-background: #e5feeb; --diff-ins-color: #2c7022; - --search-color: #c98712; + --search-color: #8a5a00; --search-background: #fff4e2; --tags-brace-color: #6a1bcc; --tags-bracket-color: #2c7560; --tags-keyword-color: #6a1bcc; --tags-literal-color: #2c7560; --tags-name-color: #6a1bcc; - --tags-quote-color: #b66800; + --tags-quote-color: #9c5700; --tags-tag-name-color: #2c7560; --tags-string-color: #2a2a2a; @@ -108,6 +114,10 @@ --lilac: #c6c1f0; --grey-5: #385465; + /* Insights and profile charts */ + --chart-neutral: #5f6772; + --chart-label-muted: #767676; + /* Contribution graph (empty → most active) */ --contribution-graph-0: #ebedf0; --contribution-graph-1: #aadfa3; @@ -127,13 +137,30 @@ --purple: #8074a8; --green-brown: #7c7270; - --white-1: #2a2a2a; + --white-1: #3a3a3a; --light-grey-6: #555555; - --light-grey-7: #707070; - --light-grey-2: #777777; + --light-grey-7: #5c5c5c; + --light-grey-2: #6e6e6e; --light-grey-1: #bbbbbb; --grey-3: #e8e8e8; --dark-grey-2: #e8e8e8; --dark-grey-1: #ffffff; --black-3: #f6f6f6; } + +/* Separate light tooltips from the page */ +.light-theme .table .latest-activity .tooltip { + border: 1px solid var(--tooltip-border); + box-shadow: 0 4px 12px -4px #00000040; +} + +/* Show a high-contrast outline for keyboard-style focus in both themes. + Lives here because the translate bundle only loads the theme stylesheets. */ +:is(a, button, input, textarea, select, [tabindex]):focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.light-theme .failed-checks .warning:before { + color: var(--status-warning); +} diff --git a/pontoon/base/static/css/multiple_item_selector.css b/pontoon/base/static/css/multiple_item_selector.css index 8518a439f5..2d1782b1ef 100644 --- a/pontoon/base/static/css/multiple_item_selector.css +++ b/pontoon/base/static/css/multiple_item_selector.css @@ -41,7 +41,7 @@ margin-bottom: 0; li span.code { - color: var(--status-translated-alt); + color: var(--status-translated); float: right; text-align: left; width: auto; @@ -66,7 +66,7 @@ a:link, a:visited { - color: var(--status-translated-alt); + color: var(--status-translated); float: right; text-transform: uppercase; } diff --git a/pontoon/base/static/css/nprogress.css b/pontoon/base/static/css/nprogress.css index 087fda5bb1..9f65e294c1 100644 --- a/pontoon/base/static/css/nprogress.css +++ b/pontoon/base/static/css/nprogress.css @@ -4,7 +4,7 @@ } #nprogress .bar { - background: #7BC876; + background: var(--status-translated); position: fixed; z-index: 1031; @@ -22,7 +22,7 @@ right: 0px; width: 100px; height: 100%; - box-shadow: 0 0 10px #7BC876, 0 0 5px #7BC876; + box-shadow: 0 0 10px var(--status-translated), 0 0 5px var(--status-translated); opacity: 1.0; -webkit-transform: rotate(3deg) translate(0px, -4px); @@ -45,8 +45,8 @@ box-sizing: border-box; border: solid 2px transparent; - border-top-color: #7BC876; - border-left-color: #7BC876; + border-top-color: var(--status-translated); + border-left-color: var(--status-translated); border-radius: 50%; -webkit-animation: nprogress-spinner 400ms linear infinite; diff --git a/pontoon/base/static/css/style.css b/pontoon/base/static/css/style.css index 3b448942f5..9261a02514 100755 --- a/pontoon/base/static/css/style.css +++ b/pontoon/base/static/css/style.css @@ -143,7 +143,7 @@ input[type='email'], input[type='date'], textarea { background: var(--input-background-1); - border: 1px solid var(--main-border-1); + border: 1px solid var(--control-border); border-radius: 3px; color: var(--input-color-1); padding: 4px 3px 3px; @@ -155,6 +155,13 @@ textarea { float: none; } +/* Readable placeholders (opacity: 1 overrides Firefox's default dimming) */ +input::placeholder, +textarea::placeholder { + color: var(--light-grey-7); + opacity: 1; +} + input:focus, textarea:focus { outline: none; /* Remove WebKit Glow */ @@ -528,7 +535,7 @@ time { } .locale .code { - color: var(--status-translated-alt); + color: var(--status-translated); text-align: left; } @@ -716,7 +723,7 @@ header .right .select .menu { background: var(--status-error); border: 3px solid var(--black-3); border-radius: 12px; - color: var(--white-1); + color: var(--on-status-error-color); display: none; font-size: 12px; font-style: normal; @@ -752,6 +759,8 @@ header .right .select .menu { } #notifications .menu li.see-all { + background: var(--tooltip-surface-background); + border-top: 1px solid var(--main-border-1); padding: 0; text-align: center; } @@ -1029,7 +1038,7 @@ header .right .select .menu { } #profile .menu li.appearance button:last-child { - border-left: 1px solid var(--main-border-1); + border-left: 1px solid var(--control-border); } #profile .menu li.appearance button .icon { @@ -1333,7 +1342,7 @@ body > form, .controls > .search-wrapper input { background: var(--dark-grey-1); - border: 1px solid var(--main-border-1); + border: 1px solid var(--control-border); color: var(--input-color-2); font-size: 13px; height: 28px; @@ -1369,7 +1378,7 @@ body > form, } .container .info a { - color: var(--status-translated-alt); + color: var(--status-translated); } .toggle-button { @@ -1378,7 +1387,7 @@ body > form, .toggle-button button { background: transparent; - border: 1px solid var(--main-border-1); + border: 1px solid var(--control-border); border-radius: 3px; box-sizing: border-box; color: var(--toggle-color-1); @@ -1406,7 +1415,7 @@ body > form, .toggle-button button.active { background: var(--button-background-1); - border-color: var(--main-border-1); + border-color: var(--control-border); color: var(--light-grey-7); font-weight: 400; } diff --git a/pontoon/base/static/css/table.css b/pontoon/base/static/css/table.css index 6f19f5ccf7..f132022148 100644 --- a/pontoon/base/static/css/table.css +++ b/pontoon/base/static/css/table.css @@ -149,7 +149,7 @@ table.table.project-list.hidden { } .table td.code a { - color: var(--status-translated-alt); + color: var(--status-translated); line-height: 47px; padding: 15px 5px 14px; } @@ -182,10 +182,10 @@ table.table.project-list.hidden { .table .latest-activity .tooltip { display: block; - background: var(--tooltip-background); + background: var(--tooltip-surface-background); border-radius: 10px; bottom: 30px; - color: var(--tooltip-color); + color: var(--tooltip-surface-color); left: -100px; padding: 10px; position: absolute; @@ -198,7 +198,8 @@ table.table.project-list.hidden { content: ''; position: absolute; border: 10px solid; - border-color: var(--tooltip-background) transparent transparent transparent; + border-color: var(--tooltip-surface-background) transparent transparent + transparent; bottom: -20px; left: 160px; /* Must be (tooltip width + tooltip padding + bottom) / 2 */ clip: rect(0 20px 10px 0); @@ -219,7 +220,7 @@ table.table.project-list.hidden { } .table .latest-activity .tooltip footer { - color: var(--tooltip-color-2); + color: var(--tooltip-surface-color-2); font-style: italic; height: 48px; margin-top: 10px; @@ -366,7 +367,7 @@ table.table.project-list.hidden { } .table .progress .legend li.pretranslated .title { - color: var(--status-pretranslated-alt); + color: var(--status-pretranslated); } .table .progress .legend li.warnings .title { @@ -378,7 +379,7 @@ table.table.project-list.hidden { } .table .progress .legend li.missing .title { - color: var(--status-missing-alt); + color: var(--status-missing); } .table .progress .legend li.unreviewed .title { diff --git a/pontoon/base/static/css/terms.css b/pontoon/base/static/css/terms.css index 2a1e77b0e7..ea74693341 100644 --- a/pontoon/base/static/css/terms.css +++ b/pontoon/base/static/css/terms.css @@ -6,7 +6,7 @@ #main a:link, #main a:visited { - color: var(--status-translated-alt); + color: var(--status-translated); } #main p, diff --git a/pontoon/contributors/static/css/contributors.css b/pontoon/contributors/static/css/contributors.css index 860c60ecb4..3a5bd075ad 100644 --- a/pontoon/contributors/static/css/contributors.css +++ b/pontoon/contributors/static/css/contributors.css @@ -1,5 +1,5 @@ #heading .banner .title { - color: var(--status-translated-alt); + color: var(--status-translated); } #heading .legend { @@ -98,7 +98,7 @@ th:last-child sup { } .stats .details div.approved { - color: var(--status-translated-alt); + color: var(--status-translated); } .stats .details div.unreviewed { diff --git a/pontoon/contributors/static/css/profile.css b/pontoon/contributors/static/css/profile.css index b997586f37..b2b1e121e4 100644 --- a/pontoon/contributors/static/css/profile.css +++ b/pontoon/contributors/static/css/profile.css @@ -63,7 +63,7 @@ h2 { } .username { - color: var(--status-translated-alt); + color: var(--status-translated); line-height: 1.4em; font-size: 20px; font-weight: 300; @@ -106,7 +106,7 @@ h2 { } .item-with-icon .stress { - color: var(--status-translated-alt); + color: var(--status-translated); } .account-status-heading { @@ -212,7 +212,7 @@ h4.superuser { } #insights .chart-group-navigation ul li .icon { - background-color: var(--black-3); + background-color: var(--chart-neutral); border-radius: 50%; display: inline-block; margin-right: 8px; @@ -221,7 +221,7 @@ h4.superuser { } #insights .chart-group-navigation ul li .label { - color: var(--grey-3); + color: var(--chart-label-muted); cursor: pointer; font-weight: bold; vertical-align: text-top; @@ -271,7 +271,7 @@ h4.superuser { } #stats > div.translated span { - color: var(--status-translated-alt); + color: var(--status-translated); } #stats > div.unreviewed span { @@ -547,7 +547,7 @@ svg.js-calendar-graph-svg { } #timeline .localizations li a .stress { - color: var(--status-translated-alt); + color: var(--status-translated); } #timeline .localizations li a .contribution-count, diff --git a/pontoon/contributors/static/css/settings.css b/pontoon/contributors/static/css/settings.css index 71c0aa0174..87550cbb10 100644 --- a/pontoon/contributors/static/css/settings.css +++ b/pontoon/contributors/static/css/settings.css @@ -12,7 +12,7 @@ form.user-locales-settings .search-wrapper { form a:link, form a:visited { - color: var(--status-translated-alt); + color: var(--status-translated); float: right; text-transform: uppercase; } @@ -413,9 +413,9 @@ form a:visited { font-size: 18px; border-radius: 0 3px 3px 0; border-left: none; - border-right: 1px solid #4d5967; - border-bottom: 1px solid #4d5967; - border-top: 1px solid #4d5967; + border-right: 1px solid var(--control-border); + border-bottom: 1px solid var(--control-border); + border-top: 1px solid var(--control-border); } } diff --git a/pontoon/homepage/static/css/homepage.css b/pontoon/homepage/static/css/homepage.css index f6eefd10cf..6e603fdbb8 100644 --- a/pontoon/homepage/static/css/homepage.css +++ b/pontoon/homepage/static/css/homepage.css @@ -183,7 +183,7 @@ body.addon-promotion-active #edit-homepage .select { #edit-homepage .button { background: var(--status-translated); - color: var(--homepage-tour-button-color); + color: var(--translation-main-button-color); } #edit-homepage .fas { diff --git a/pontoon/insights/static/css/insights.css b/pontoon/insights/static/css/insights.css index 62e48bab64..089ff43194 100644 --- a/pontoon/insights/static/css/insights.css +++ b/pontoon/insights/static/css/insights.css @@ -101,7 +101,7 @@ height: 44px; a { - color: var(--status-translated-alt); + color: var(--status-translated); } div { diff --git a/pontoon/insights/static/css/insights_charts.css b/pontoon/insights/static/css/insights_charts.css index e2708157d0..4f41f2e79a 100644 --- a/pontoon/insights/static/css/insights_charts.css +++ b/pontoon/insights/static/css/insights_charts.css @@ -97,7 +97,7 @@ } #insights .legend li.disabled .label { - color: var(--grey-3); + color: var(--chart-label-muted); } #insights .legend li.disabled .label:hover { diff --git a/pontoon/insights/static/css/insights_tab.css b/pontoon/insights/static/css/insights_tab.css index 564a019694..1fe07f1cd6 100644 --- a/pontoon/insights/static/css/insights_tab.css +++ b/pontoon/insights/static/css/insights_tab.css @@ -122,7 +122,7 @@ } #insights h3 .tooltip li.new-source-strings::marker { - color: var(--black-3); + color: var(--chart-neutral); } #insights h3 .tooltip li.completion::marker { @@ -144,7 +144,7 @@ } #insights h3 .tooltip li.new-suggestions::marker { - color: var(--black-3); + color: var(--chart-neutral); } #insights h3 .tooltip li.unreviewed::marker { @@ -170,7 +170,7 @@ } #insights .pretranslation-quality h3 .tooltip li.new-pretranslations::marker { - color: var(--black-3); + color: var(--chart-neutral); } /* Community Health info tooltip */ diff --git a/pontoon/insights/static/js/insights_tab.js b/pontoon/insights/static/js/insights_tab.js index d7d48462a6..3bb181e0da 100644 --- a/pontoon/insights/static/js/insights_tab.js +++ b/pontoon/insights/static/js/insights_tab.js @@ -460,8 +460,8 @@ var Pontoon = (function (my) { label: 'New source strings', data: newSourcesData, yAxisID: 'strings-y-axis', - backgroundColor: style.getPropertyValue('--black-3'), - hoverBackgroundColor: style.getPropertyValue('--black-3'), + backgroundColor: style.getPropertyValue('--chart-neutral'), + hoverBackgroundColor: style.getPropertyValue('--chart-neutral'), stack: 'source-strings', order: 3, hidden: true, @@ -674,8 +674,8 @@ var Pontoon = (function (my) { label: 'New suggestions', data: chart.data('new-suggestions'), yAxisID: 'strings-y-axis', - backgroundColor: style.getPropertyValue('--black-3'), - hoverBackgroundColor: style.getPropertyValue('--black-3'), + backgroundColor: style.getPropertyValue('--chart-neutral'), + hoverBackgroundColor: style.getPropertyValue('--chart-neutral'), stack: 'new-suggestions', order: 4, hidden: true, @@ -886,8 +886,8 @@ var Pontoon = (function (my) { label: 'New pretranslations', data: newData, yAxisID: 'strings-y-axis', - backgroundColor: style.getPropertyValue('--black-3'), - hoverBackgroundColor: style.getPropertyValue('--black-3'), + backgroundColor: style.getPropertyValue('--chart-neutral'), + hoverBackgroundColor: style.getPropertyValue('--chart-neutral'), stack: 'new-pretranslations', order: 3, hidden: true, diff --git a/pontoon/messaging/static/css/messaging.css b/pontoon/messaging/static/css/messaging.css index dfb950339e..d6754f683b 100644 --- a/pontoon/messaging/static/css/messaging.css +++ b/pontoon/messaging/static/css/messaging.css @@ -338,7 +338,7 @@ font-size: 14px; .sender { - color: var(--status-translated-alt); + color: var(--status-translated); width: 50%; } diff --git a/pontoon/search/static/css/search.css b/pontoon/search/static/css/search.css index 679b6d497c..b023f5f3cf 100644 --- a/pontoon/search/static/css/search.css +++ b/pontoon/search/static/css/search.css @@ -199,6 +199,7 @@ .utility-container.controls a.edit:hover, .utility-container.controls a.all-locales:hover { background: var(--status-translated); + color: var(--translation-main-button-color); } #initial-section-container { @@ -224,6 +225,7 @@ .button:hover { background: var(--status-translated); + color: var(--translation-main-button-color); } } @@ -276,7 +278,7 @@ .translation-locale-code { font-style: italic; - color: var(--status-translated-alt); + color: var(--status-translated); vertical-align: sub; } } diff --git a/pontoon/teams/static/css/info.css b/pontoon/teams/static/css/info.css index 1887d12b3b..6a54543802 100644 --- a/pontoon/teams/static/css/info.css +++ b/pontoon/teams/static/css/info.css @@ -28,7 +28,7 @@ #info-wrapper .controls .cancel { display: none; - color: var(--status-translated-alt); + color: var(--status-translated); margin: 9px; text-transform: uppercase; } diff --git a/pontoon/teams/static/css/request.css b/pontoon/teams/static/css/request.css index 3f6e7b539e..149b47edbe 100644 --- a/pontoon/teams/static/css/request.css +++ b/pontoon/teams/static/css/request.css @@ -89,6 +89,7 @@ background: var(--status-translated); border: none; border-radius: 3px; + color: var(--translation-main-button-color); margin-top: 15px; padding: 10px; text-transform: uppercase; diff --git a/pontoon/teams/static/css/team.css b/pontoon/teams/static/css/team.css index c758c063c9..d545637d43 100644 --- a/pontoon/teams/static/css/team.css +++ b/pontoon/teams/static/css/team.css @@ -164,6 +164,7 @@ #permissions-form h3 .remove-project:hover { background: var(--status-error); + color: var(--translation-main-button-color); } #permissions-form h3 .remove-project .fas { diff --git a/pontoon/teams/static/css/translation_memory.css b/pontoon/teams/static/css/translation_memory.css index ff75fba3cb..f9bff4a64f 100644 --- a/pontoon/teams/static/css/translation_memory.css +++ b/pontoon/teams/static/css/translation_memory.css @@ -180,6 +180,7 @@ .button.edit:hover { background: var(--status-translated); + color: var(--translation-main-button-color); } .button.save { @@ -188,6 +189,7 @@ .button.save:hover { background: var(--status-translated); + color: var(--translation-main-button-color); } .button.delete { @@ -196,6 +198,7 @@ .button.delete:hover { background: var(--status-error); + color: var(--translation-main-button-color); } .button.are-you-sure { diff --git a/translate/src/App.css b/translate/src/App.css index 0079dc4c40..5c6fed3e0e 100644 --- a/translate/src/App.css +++ b/translate/src/App.css @@ -202,7 +202,7 @@ #app > .main-content .third-column .react-tabs span.count:has(.preferred), #app > .main-content .third-column .react-tabs span.count:has(.pinned) { - background: var(--status-translated-alt); - color: var(--status-translated-alt); /* used as box-shadow color */ + background: var(--status-translated); + color: var(--status-translated); /* used as box-shadow color */ } } diff --git a/translate/src/modules/addonpromotion/components/AddonPromotion.css b/translate/src/modules/addonpromotion/components/AddonPromotion.css index ad8568d6b7..8de5b337ea 100644 --- a/translate/src/modules/addonpromotion/components/AddonPromotion.css +++ b/translate/src/modules/addonpromotion/components/AddonPromotion.css @@ -15,6 +15,7 @@ .addon-promotion .container { align-items: center; background: var(--status-error); + color: var(--on-status-error-color); display: flex; height: 100%; padding: 0 10px; @@ -23,7 +24,7 @@ .addon-promotion .dismiss { background: transparent; border: none; - color: var(--white-1); + color: var(--on-status-error-color); font-size: 28px; padding: 0; } diff --git a/translate/src/modules/batchactions/components/BatchActions.css b/translate/src/modules/batchactions/components/BatchActions.css index e2f1be48e7..70d0e0c78f 100644 --- a/translate/src/modules/batchactions/components/BatchActions.css +++ b/translate/src/modules/batchactions/components/BatchActions.css @@ -36,7 +36,7 @@ } .batch-actions .topbar button.selected-count .stress { - color: var(--status-translated-alt); + color: var(--status-translated); } .batch-actions .topbar button .fas { @@ -191,7 +191,8 @@ display: none; } -/* Style placeholder color in Chrome */ -.batch-actions .actions-panel input::-webkit-input-placeholder { +/* Placeholder color (opacity: 1 overrides Firefox's default dimming) */ +.batch-actions .actions-panel input::placeholder { color: var(--light-grey-7); + opacity: 1; } diff --git a/translate/src/modules/entitydetails/components/Helpers.css b/translate/src/modules/entitydetails/components/Helpers.css index f484cf6f58..5e95dfcb4e 100644 --- a/translate/src/modules/entitydetails/components/Helpers.css +++ b/translate/src/modules/entitydetails/components/Helpers.css @@ -75,5 +75,5 @@ .react-tabs span.count .preferred, .react-tabs span.count .pinned { - color: var(--status-translated-alt); + color: var(--status-translated); } diff --git a/translate/src/modules/history/components/HistoryTranslation.css b/translate/src/modules/history/components/HistoryTranslation.css index 06a3ff8b0c..b51c543133 100644 --- a/translate/src/modules/history/components/HistoryTranslation.css +++ b/translate/src/modules/history/components/HistoryTranslation.css @@ -53,7 +53,7 @@ } .history .translation .content > header .info a { - color: var(--status-translated-alt); + color: var(--status-translated); } .history .translation .content > header .toggle { @@ -80,7 +80,7 @@ } .history .translation .content > header .toggle.active .stress { - color: var(--status-translated-alt); + color: var(--status-translated); } .history .translation .content > header button { diff --git a/translate/src/modules/machinery/components/Machinery.css b/translate/src/modules/machinery/components/Machinery.css index 68fd96658f..3b2419d0eb 100644 --- a/translate/src/modules/machinery/components/Machinery.css +++ b/translate/src/modules/machinery/components/Machinery.css @@ -65,7 +65,8 @@ display: none; } -/* Style placeholder color in Chrome */ -.machinery > .search-wrapper input[type='search']::-webkit-input-placeholder { +/* Placeholder color (opacity: 1 overrides Firefox's default dimming) */ +.machinery > .search-wrapper input[type='search']::placeholder { color: var(--light-grey-7); + opacity: 1; } diff --git a/translate/src/modules/machinery/components/MachineryTranslation.css b/translate/src/modules/machinery/components/MachineryTranslation.css index 49179313f4..d9b166313a 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.css +++ b/translate/src/modules/machinery/components/MachineryTranslation.css @@ -132,7 +132,7 @@ .machinery .translation > header .quality, .machinery .translation > header sup { - color: var(--status-translated-alt); + color: var(--status-translated); } .machinery .translation > header .projects { diff --git a/translate/src/modules/navbar/components/Navigation.css b/translate/src/modules/navbar/components/Navigation.css index a78e03c261..08718a1b22 100644 --- a/translate/src/modules/navbar/components/Navigation.css +++ b/translate/src/modules/navbar/components/Navigation.css @@ -32,6 +32,6 @@ } .navigation .locale-code { - color: var(--status-translated-alt); + color: var(--status-translated); padding-left: 7px; } diff --git a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.css b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.css index 4dc01f7a9b..db158f778a 100644 --- a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.css +++ b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.css @@ -28,7 +28,7 @@ } .other-locales .translation > header span { - color: var(--status-translated-alt); + color: var(--status-translated); padding-left: 3px; } diff --git a/translate/src/modules/project/components/ProjectItem.css b/translate/src/modules/project/components/ProjectItem.css index d86ec78a11..f68ac87e6b 100644 --- a/translate/src/modules/project/components/ProjectItem.css +++ b/translate/src/modules/project/components/ProjectItem.css @@ -18,6 +18,6 @@ } .project-menu .menu .percent { - color: var(--status-translate-alt); + color: var(--status-translated); float: right; } diff --git a/translate/src/modules/resource/components/ResourceMenu.css b/translate/src/modules/resource/components/ResourceMenu.css index 5a447708ca..383dd0c906 100644 --- a/translate/src/modules/resource/components/ResourceMenu.css +++ b/translate/src/modules/resource/components/ResourceMenu.css @@ -99,7 +99,7 @@ } .resource-menu .menu .current a { - color: var(--status-translated-alt); + color: var(--status-translated); } .resource-menu .menu .header { diff --git a/translate/src/modules/resource/components/ResourcePercent.css b/translate/src/modules/resource/components/ResourcePercent.css index 8040550c69..8de6332cc7 100644 --- a/translate/src/modules/resource/components/ResourcePercent.css +++ b/translate/src/modules/resource/components/ResourcePercent.css @@ -1,4 +1,4 @@ .resource-menu .menu .percent { - color: var(--status-translated-alt); + color: var(--status-translated); float: right; } diff --git a/translate/src/modules/search/components/FiltersPanel.css b/translate/src/modules/search/components/FiltersPanel.css index b0f2073482..d4e26f2b9d 100644 --- a/translate/src/modules/search/components/FiltersPanel.css +++ b/translate/src/modules/search/components/FiltersPanel.css @@ -101,7 +101,7 @@ .filters-panel .menu li.horizontal-separator { background: transparent; border-top: none; - color: var(--status-translated-alt); + color: var(--status-translated); cursor: default; font-size: 13px; height: auto; @@ -237,7 +237,7 @@ .filters-panel .toolbar { position: sticky; bottom: 0; - background: var(--tooltip-background); + background: var(--tooltip-surface-background); border-top: 1px solid var(--light-grey-1); box-sizing: border-box; line-height: 23px; @@ -248,7 +248,7 @@ .filters-panel .toolbar button { background: none; border: none; - color: var(--tooltip-color-2); + color: var(--tooltip-surface-color-2); font-weight: 300; } diff --git a/translate/src/modules/search/components/SearchBox.css b/translate/src/modules/search/components/SearchBox.css index a09c128afa..f559009ab7 100644 --- a/translate/src/modules/search/components/SearchBox.css +++ b/translate/src/modules/search/components/SearchBox.css @@ -26,9 +26,10 @@ display: none; } -/* Style placeholder color in Chrome */ -.search-box input[type='search']::-webkit-input-placeholder { +/* Placeholder color (opacity: 1 overrides Firefox's default dimming) */ +.search-box input[type='search']::placeholder { color: var(--light-grey-7); + opacity: 1; } .search-box > label { diff --git a/translate/src/modules/search/components/SearchPanel.css b/translate/src/modules/search/components/SearchPanel.css index feaf0ef460..68fcb88577 100644 --- a/translate/src/modules/search/components/SearchPanel.css +++ b/translate/src/modules/search/components/SearchPanel.css @@ -43,7 +43,7 @@ } & .title { - color: var(--status-translated-alt); + color: var(--status-translated); cursor: default; font-size: 13px; font-weight: 300; diff --git a/translate/src/modules/search/components/TimeRangeFilter.css b/translate/src/modules/search/components/TimeRangeFilter.css index 77ca827900..e6c3ba44a4 100644 --- a/translate/src/modules/search/components/TimeRangeFilter.css +++ b/translate/src/modules/search/components/TimeRangeFilter.css @@ -9,7 +9,7 @@ } .filters-panel .menu .for-time-range button.save-range { - color: var(--status-translated-alt); + color: var(--status-translated); } .filters-panel .menu li.for-time-range button:hover { diff --git a/translate/src/modules/search/components/chart-options.ts b/translate/src/modules/search/components/chart-options.ts index bf4cd9fc38..f5b5c34b05 100644 --- a/translate/src/modules/search/components/chart-options.ts +++ b/translate/src/modules/search/components/chart-options.ts @@ -107,7 +107,7 @@ export function getChartOptions( select: { fill: 'none', style: { - color: style.getPropertyValue('--status-translated-alt'), + color: style.getPropertyValue('--status-translated'), fontWeight: 300, }, }, diff --git a/translate/src/modules/terms/components/Term.css b/translate/src/modules/terms/components/Term.css index fdcd1c4844..7230242052 100644 --- a/translate/src/modules/terms/components/Term.css +++ b/translate/src/modules/terms/components/Term.css @@ -31,7 +31,7 @@ } .terms-list .term .translate { - color: var(--status-translated-alt); + color: var(--status-translated); float: right; font-size: 11px; font-weight: 300; @@ -50,7 +50,7 @@ } .terms-list .term .usage .title { - color: var(--status-translated-alt); + color: var(--status-translated); font-size: 11px; margin-right: 3px; } diff --git a/translate/src/modules/translationform/components/TranslationForm.css b/translate/src/modules/translationform/components/TranslationForm.css index c29b96d372..ae265e1bc8 100644 --- a/translate/src/modules/translationform/components/TranslationForm.css +++ b/translate/src/modules/translationform/components/TranslationForm.css @@ -29,12 +29,12 @@ .translationform label > :not(:last-child)::after { content: '·'; - color: var(--status-translated-alt); + color: var(--status-translated); padding: 0 3px; } .translationform label .stress { - color: var(--status-translated-alt); + color: var(--status-translated); } .translationform .accesskey-input { diff --git a/translate/src/modules/user/components/UserNotificationsMenu.css b/translate/src/modules/user/components/UserNotificationsMenu.css index 32cda5f1dd..d056de3528 100644 --- a/translate/src/modules/user/components/UserNotificationsMenu.css +++ b/translate/src/modules/user/components/UserNotificationsMenu.css @@ -88,6 +88,7 @@ } .user-notifications-menu .menu .see-all { + background: var(--tooltip-surface-background); border-top: 1px solid var(--main-border-1); } From 712fe06c2ecc744a1e053ded9d917e6e1f26749b Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Sun, 11 Oct 2026 23:37:34 +0530 Subject: [PATCH 14/15] Add ability to add comments via API POST /api/v2/comments/translations/ with {translation, content} creates a translation comment as the authenticated user (PAT or session), sanitizes HTML, logs COMMENT_ADDED, and sends comment notifications, mirroring the UI add-comment flow for LLM-based LQA. Fixes #4540 --- pontoon/api/serializers.py | 22 ++++++++++++- pontoon/api/urls.py | 5 +++ pontoon/api/views.py | 65 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/pontoon/api/serializers.py b/pontoon/api/serializers.py index d2be9e1fe4..8e36700e23 100644 --- a/pontoon/api/serializers.py +++ b/pontoon/api/serializers.py @@ -595,7 +595,6 @@ class UploadPretranslationsResponseSerializer(serializers.Serializer): class UploadSuggestionsResponseSerializer(serializers.Serializer): """Result of a suggestion file upload.""" - created = serializers.IntegerField( help_text="Number of suggestions added by the upload." ) @@ -619,3 +618,24 @@ class UploadSuggestionsResponseSerializer(serializers.Serializer): undefined_keys = undefined_keys_field() undefined_keys_count = undefined_keys_count_field() badge_updates = badge_updates_field() + + +class TranslationCommentRequestSerializer(serializers.Serializer): + """Request to post a comment on a translation (e.g. LLM-based LQA).""" + + translation = serializers.IntegerField( + help_text="Primary key of the translation to comment on." + ) + content = serializers.CharField( + help_text="Comment content (HTML sanitized on save)." + ) + + +class TranslationCommentResponseSerializer(serializers.Serializer): + """Created translation comment.""" + + id = serializers.IntegerField(help_text="Primary key of the created comment.") + translation = serializers.IntegerField( + help_text="Primary key of the commented translation." + ) + content = serializers.CharField(help_text="Sanitized comment content.") diff --git a/pontoon/api/urls.py b/pontoon/api/urls.py index 8c3b7a73e0..f3a7a0a3e5 100644 --- a/pontoon/api/urls.py +++ b/pontoon/api/urls.py @@ -66,6 +66,11 @@ views.UploadSuggestionsView.as_view(), name="upload-suggestions", ), + path( + "comments/translations/", + views.TranslationCommentView.as_view(), + name="comment-translation", + ), path( "terminology/matches/", views.TermMatchListView.as_view(), diff --git a/pontoon/api/views.py b/pontoon/api/views.py index 3269d568b1..a91e15bbeb 100644 --- a/pontoon/api/views.py +++ b/pontoon/api/views.py @@ -68,6 +68,8 @@ NestedProjectSerializer, PretranslationResponseSerializer, TermSerializer, + TranslationCommentRequestSerializer, + TranslationCommentResponseSerializer, TranslationMemorySerializer, UploadPretranslationsResponseSerializer, UploadSuggestionsResponseSerializer, @@ -1014,3 +1016,66 @@ def post(self, request): **self.badge_updates(badges), } ) + + +class TranslationCommentView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + request=TranslationCommentRequestSerializer, + responses={ + 201: TranslationCommentResponseSerializer, + 400: OpenApiResponse(description="Invalid translation pk or content."), + 403: OpenApiResponse(description="Authentication required."), + 404: OpenApiResponse(description="Unknown translation."), + }, + description=( + "Post a comment to a translation, e.g. as part of LLM-based LQA. " + "Content is HTML-sanitized, an action-log entry is recorded, and " + "translation comment notifications are sent, mirroring the UI." + ), + ) + def post(self, request): + from pontoon.actionlog.utils import log_action + from pontoon.base.models import Comment + from pontoon.base.utils import sanitize_html + from pontoon.base.views import _send_add_comment_notifications + + serializer = TranslationCommentRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + translation = get_object_or_404( + Translation.objects.select_related( + "entity__resource__project", "locale" + ), + pk=serializer.validated_data["translation"], + ) + content = sanitize_html(serializer.validated_data["content"]) + if not content.strip(): + raise ValidationError({"content": ["This field is required."]}) + + with transaction.atomic(): + comment = Comment.objects.create( + author=request.user, translation=translation, content=content + ) + log_action( + ActionLog.ActionType.COMMENT_ADDED, + request.user, + translation=translation, + ) + + _send_add_comment_notifications( + request.user, + content, + translation.entity, + translation.locale, + translation, + ) + + return Response( + { + "id": comment.pk, + "translation": translation.pk, + "content": comment.content, + }, + status=status.HTTP_201_CREATED, + ) From 5ba15e088bafa656107359fcd3c6c9f291727e10 Mon Sep 17 00:00:00 2001 From: Alvyn Abranches Date: Mon, 12 Oct 2026 00:58:20 +0530 Subject: [PATCH 15/15] style: apply ruff check and format to P2 fix batch --- pontoon/api/serializers.py | 1 + pontoon/api/views.py | 4 +--- pontoon/base/models/entity.py | 4 +--- pontoon/checks/libraries/custom.py | 12 +++--------- pontoon/sync/core/entities.py | 6 +++--- pontoon/sync/formats/__init__.py | 7 ++++--- pontoon/terminology/models.py | 8 ++------ 7 files changed, 15 insertions(+), 27 deletions(-) diff --git a/pontoon/api/serializers.py b/pontoon/api/serializers.py index 8e36700e23..96bd6edd33 100644 --- a/pontoon/api/serializers.py +++ b/pontoon/api/serializers.py @@ -595,6 +595,7 @@ class UploadPretranslationsResponseSerializer(serializers.Serializer): class UploadSuggestionsResponseSerializer(serializers.Serializer): """Result of a suggestion file upload.""" + created = serializers.IntegerField( help_text="Number of suggestions added by the upload." ) diff --git a/pontoon/api/views.py b/pontoon/api/views.py index a91e15bbeb..1939c15d7a 100644 --- a/pontoon/api/views.py +++ b/pontoon/api/views.py @@ -1044,9 +1044,7 @@ def post(self, request): serializer = TranslationCommentRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) translation = get_object_or_404( - Translation.objects.select_related( - "entity__resource__project", "locale" - ), + Translation.objects.select_related("entity__resource__project", "locale"), pk=serializer.validated_data["translation"], ) content = sanitize_html(serializer.validated_data["content"]) diff --git a/pontoon/base/models/entity.py b/pontoon/base/models/entity.py index 7f84c996bd..f7558688bc 100644 --- a/pontoon/base/models/entity.py +++ b/pontoon/base/models/entity.py @@ -92,9 +92,7 @@ def reset_active_translation(self, locale: Locale) -> dict[str, Any] | None: # concurrently or left over from before the unique constraint # (see #2284), then activate the winner via queryset update to # stay inside the same transaction. - translations.exclude(pk=active_pk).filter(active=True).update( - active=False - ) + translations.exclude(pk=active_pk).filter(active=True).update(active=False) translations.filter(pk=active_pk).update(active=True) return Translation.objects.get(pk=active_pk).serialize() diff --git a/pontoon/checks/libraries/custom.py b/pontoon/checks/libraries/custom.py index 9394e4568b..02fbecf9e9 100644 --- a/pontoon/checks/libraries/custom.py +++ b/pontoon/checks/libraries/custom.py @@ -118,19 +118,13 @@ def run_custom_checks(entity: Entity, string: str) -> dict[str, list[str]]: # parse_entry() only returns the first entry and silently drops # trailing Junk (e.g. a line starting with `[`), so validate # the full resource instead (see #3621). - junk = next( - (e for e in res.body if isinstance(e, ast.Junk)), None - ) + junk = next((e for e in res.body if isinstance(e, ast.Junk)), None) if junk is not None and junk.annotations: errors.append(junk.annotations[0].message) elif len(res.body) != 1: - errors.append( - "Translation needs to be a single localizable entry" - ) + errors.append("Translation needs to be a single localizable entry") else: - errors.append( - "Translation needs to be a valid localizable entry" - ) + errors.append("Translation needs to be a valid localizable entry") else: translation_ast = res.body[0] diff --git a/pontoon/sync/core/entities.py b/pontoon/sync/core/entities.py index 49273c0978..8cfcb7d1a6 100644 --- a/pontoon/sync/core/entities.py +++ b/pontoon/sync/core/entities.py @@ -406,9 +406,9 @@ def carry_translations_for_moves( old_ent = candidates[0] # Move non-rejected translations, preserving user, date, approved # metadata and history (pk unchanged, so comments/checks/TM follow). - moved = Translation.objects.filter( - entity_id=old_ent.pk, rejected=False - ).update(entity_id=new_ent.pk) + moved = Translation.objects.filter(entity_id=old_ent.pk, rejected=False).update( + entity_id=new_ent.pk + ) if not moved: continue # Carry sync change markers so the moved translations commit outward. diff --git a/pontoon/sync/formats/__init__.py b/pontoon/sync/formats/__init__.py index fee01eb736..74b92dd1d1 100644 --- a/pontoon/sync/formats/__init__.py +++ b/pontoon/sync/formats/__init__.py @@ -36,8 +36,7 @@ def fluent_nested_same_selector_keys(source: str) -> set[str]: library is fixed. Never raises; returns an empty set when parsing fails. """ try: - from fluent.syntax import FluentParser - from fluent.syntax import ast as fluent_ast + from fluent.syntax import FluentParser, ast as fluent_ast except ImportError: return set() try: @@ -60,7 +59,9 @@ def has_nested_same_selector(node, ancestors: tuple = ()) -> bool: if key in ancestors: return True ancestors = ancestors + (key,) - return any(has_nested_same_selector(v.value, ancestors) for v in node.variants) + return any( + has_nested_same_selector(v.value, ancestors) for v in node.variants + ) if isinstance(node, fluent_ast.Pattern): return any(has_nested_same_selector(el, ancestors) for el in node.elements) if isinstance(node, fluent_ast.Placeable): diff --git a/pontoon/terminology/models.py b/pontoon/terminology/models.py index 23556a5a4f..64f1cf88fc 100644 --- a/pontoon/terminology/models.py +++ b/pontoon/terminology/models.py @@ -213,9 +213,7 @@ def handle_term_update(self): elif not old_term.localizable and term.localizable: _, created = term.create_entity() if created: - notify_new_terminology_entity( - Entity.objects.get(pk=term.entity_id) - ) + notify_new_terminology_entity(Entity.objects.get(pk=term.entity_id)) # If relevant changes are made to the localizable term that stays localizable else: @@ -224,9 +222,7 @@ def handle_term_update(self): old_term.obsolete_entity() _, created = term.create_entity() if created: - notify_new_terminology_entity( - Entity.objects.get(pk=term.entity_id) - ) + notify_new_terminology_entity(Entity.objects.get(pk=term.entity_id)) # If Term.part_of_speech, Term.definition or Term.usage change, Entity.comment gets updated. elif (