From 2f759ed07304ab7bd6f629c18a4b3d5e6dd679e1 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Fri, 4 Sep 2026 23:32:06 -0700 Subject: [PATCH 01/13] docs(tracker): capture product context in PRODUCT.md Record durable product truth for the Tracker web UI: primary and secondary users, positioning, operating context, capabilities and constraints, the binding watercolor brand identity, and the evidence on hand. Also records the Impeccable build-path workflow default and gitignores the skill's machine-local state. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RifJr7xyhZkaTWn4ssV1b4 --- .gitignore | 4 + apps/tracker/.impeccable/config.json | 3 + apps/tracker/PRODUCT.md | 117 +++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 apps/tracker/.impeccable/config.json create mode 100644 apps/tracker/PRODUCT.md diff --git a/.gitignore b/.gitignore index 59ebf3f2..31fcd7ad 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,7 @@ vitest.config.*.timestamp* .claude/worktrees .nx/polygraph .nx/self-healing + +# Impeccable design skill — machine-local state (config.json is committed) +.impeccable/config.local.json +.impeccable/live/ diff --git a/apps/tracker/.impeccable/config.json b/apps/tracker/.impeccable/config.json new file mode 100644 index 00000000..7142df2d --- /dev/null +++ b/apps/tracker/.impeccable/config.json @@ -0,0 +1,3 @@ +{ + "buildPath": "comp" +} diff --git a/apps/tracker/PRODUCT.md b/apps/tracker/PRODUCT.md new file mode 100644 index 00000000..22118207 --- /dev/null +++ b/apps/tracker/PRODUCT.md @@ -0,0 +1,117 @@ +# Product + + + +## Platform + +web + +## Users + +Primary: an application developer, mid-feature, who has just written UI and needs to add, +find, or verify translation keys and get back to coding. They already know the codebase and +the key they are looking for. Interruption cost is the dominant concern. + +Secondary (real, must not be blocked): + +- Translators and reviewers working through `new` / `stale` entries a locale at a time, + judging wording rather than moving fast. +- The i18n owner, accountable for translation health across collections before a release — + hunting gaps, stale entries, and protected-term violations. + +## Product Purpose + +LingoTracker manages translation resources for apps using the Transloco library. The Tracker +web UI is its visual surface: browse a collection's folder tree, search across keys and +values, filter by locale and status, edit translations, and trigger auto-translation. + +Success is a developer resolving a translation question in the UI faster than they could by +hand-editing JSON — and a release going out with no `new` or `stale` entries left behind. + +## Positioning + +Translations live as plain JSON files in the user's own repository, next to the code, with +metadata (MD5 checksums, status) in sibling files. There is no hosted service and no +database: every change is a Git diff a teammate can review in a normal pull request. The +UI is a lens onto those files, not a system of record that owns them. + +## Operating Context + +- Launched locally via `npx lingo-tracker-app`; the API (default port 3030) serves the UI and + reads and writes the developer's working tree. +- The UI is one of three interfaces over the same core library, alongside a CLI (used + interactively and in CI/CD) and a REST API. Behavior must not contradict the CLI. +- Work is organized into **collections** — resources grouped by team, feature, or domain. + Some collections are **read-only** (e.g. component-library translations vendored under + `node_modules`). +- Routes: `/collections` (manager), `/browser/:collectionName` (the main working surface), + `/settings`. + +## Capabilities and Constraints + +Confirmed capabilities in the UI: + +- Folder-tree sidebar over dot-delimited resource keys (`apps.common.buttons.ok`). +- Search across keys and values; filter by locale and by status. +- Compact / expanded density modes; view preferences persisted per collection. +- Translation editor dialog with similar-translation suggestions as you type. +- One-click auto-translation (Google Translate), preserving ICU placeholders and Transloco + variables. +- Collection create/edit; read-only collections are protected across CLI, API, and UI. +- Settings page manages the protected-terms list (chips, backed by a standalone JSON file). +- Comment and tag popovers; indexing overlay during long operations. + +Constraints: + +- **Local-first, no accounts.** Single machine, single user. No auth, no multi-user + presence, no remote sync state to design around. +- **Read-only collections must always be unmistakable** in the UI. +- **The Tracker UI is itself fully localized** with Transloco and dogfoods LingoTracker. + Every user-visible string goes through the resource workflow — no hardcoded copy. Shipped + UI locales: `en`, `de`, `es`, `fr-ca`, `ja`, `ru`. Layouts must survive long German and + Russian strings and Japanese line-breaking. +- Angular 20 standalone components, Angular Material, NgRx Signals, OnPush change detection. +- Terminology is domain vocabulary and is not up for renaming: collection, resource, key, + locale, base locale, bundle, glossary, protected term, and the status lifecycle + `new` → `translated` → `stale` → `verified`. + +## Brand Commitments + +- Name: **LingoTracker**. Tagline: "Effortlessly Track, Validate, and Manage Your + Translations." +- The **watercolor visual identity is binding**, not placeholder: warm parchment + backgrounds, coral/vermillion primary, deep sky-blue secondary, and the watercolor accent + palette in `src/styles/tokens.scss`. Nunito for all text; Grechen Fuemen reserved for the + LingoTracker brand title only. +- Logo assets: `src/assets/logo.png`, `src/assets/logo-dark.png`, + `src/assets/lingo-tracker-favicon-cropped.png`. +- Light and dark themes both ship. + +## Evidence on Hand + +- Real product docs: repo `README.md`, `ROADMAP.md`, `docs/` (auto-translation, glossary, + protected terms, validate, bundle type generation), and a docs site under `docs-site/`. +- Real translation data: the Tracker's own `src/assets/i18n/*.json` across six locales. +- The project is described as stable and "currently being validated in an enterprise + application." There are **no** named customers, testimonials, logos, benchmarks, pricing, + or usage numbers — future work must not invent any. + +## Product Principles + +1. **The files are the truth.** The UI never becomes a source of state the repository cannot + reproduce; every action ends as a reviewable Git diff. +2. **Don't break the developer's flow.** The primary user arrived with a key in mind and + wants to leave; the shortest path from landing to that key wins over browsing elegance. +3. **Status is the spine.** `new` / `translated` / `stale` / `verified` is how every user + decides what to touch next, and it must be legible at a glance at any density. +4. **Never let a destructive or locked state be a surprise.** Read-only collections, + protected terms, and deletions announce themselves before the fact. +5. **Practice what it preaches.** The UI is localized, ICU-correct, and layout-safe in every + locale it ships — it is its own reference implementation. + +## Accessibility & Inclusion + +No product-specific standard has been established yet — recorded as undecided. Existing code +shows deliberate `aria-labelledby` use on settings sections, so the incumbent baseline is +Angular Material semantics plus explicit landmark labeling; future work should not regress +below that. From 0f8542c24c185ccfe847f1c2c4a1f20693ddb597 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Fri, 4 Sep 2026 23:32:50 -0700 Subject: [PATCH 02/13] chore: record tool-written nx and pnpm settings Nx 22 wrote `analytics: false` and reformatted the `translationFixtures` named input; pnpm recorded `@swc/core` and `nx` as ignored built dependencies. Committing so the settings stop reappearing as local noise. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RifJr7xyhZkaTWn4ssV1b4 --- nx.json | 3 ++- package.json | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/nx.json b/nx.json index f2cda4de..b4c26846 100644 --- a/nx.json +++ b/nx.json @@ -116,5 +116,6 @@ "testTargetName": "vitest:test" } } - ] + ], + "analytics": false } diff --git a/package.json b/package.json index 5e7b293a..d54cd7cd 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,11 @@ "sigstore@<=4.1.0": "4.1.1", "@sigstore/core@<=3.2.0": "3.2.1", "@sigstore/verify@3.1.0": "3.1.1" - } + }, + "ignoredBuiltDependencies": [ + "@swc/core", + "nx" + ] }, "nx": {}, "dependencies": { From 2ebe50a582c33154aca201695fcf1e38e7d10002 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 10:12:39 -0700 Subject: [PATCH 03/13] feat(tracker): manage protected terms as an editable list Replace the chip multi-select on /settings with a list that supports adding, deleting and renaming terms, with an explicit save. Interaction: - Deleting keeps the row visible, struck through and marked, with Undo; it is written only on Save, so a pending removal is always reviewable and reversible. - Renaming happens inline on the row; Enter commits, Escape cancels, and a collision is reported in place. - Re-adding a term that is marked for removal takes the removal back instead of reporting a duplicate. - A filter appears past eight terms, mounted inside the panel so it survives a no-match state. Adding or renaming a term the filter would hide drops the filter, and the affected row is scrolled into view. Layout: - The save controls live in the page header, so they cannot scroll out of view. - The page is a fixed-height column: the list grows with its rows until the viewport runs out, then scrolls internally, keeping the add field and the filter on screen. - Below 500px of viewport height the hint and the file path give up their rows. Terms are listed and saved alphabetically: the list is a set, and a sorted file keeps its Git diffs minimal. Fixes found while building this: - Angular Material's typography painted the page title black in both themes, making it invisible in dark mode. - Six text and control colours fell below their contrast floors; badge, error and icon colours are now mixed toward the foreground so they invert with the theme. - Material's 48px icon-button touch target overflowed the 45px rows and made the scroller report phantom overflow. It is dropped on fine pointers and restored at 44px under `pointer: coarse`. - `.settings-page` needs an explicit `box-sizing: border-box`; the app has no global reset, so `height: 100%` plus padding overflowed the viewport. Adds 20 translated resources across all six locales; the two ICU plurals carry per-locale plural categories. The protected-terms hint changed because the "press Enter or comma" mechanic no longer exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RifJr7xyhZkaTWn4ssV1b4 --- apps/tracker/PRODUCT.md | 3 +- apps/tracker/src/app/settings/settings.html | 282 ++++++-- apps/tracker/src/app/settings/settings.scss | 656 +++++++++++++++++- .../tracker/src/app/settings/settings.spec.ts | 358 ++++++++-- apps/tracker/src/app/settings/settings.ts | 276 +++++++- .../src/i18n/settings/resource_entries.json | 222 +++++- .../src/i18n/settings/tracker_meta.json | 652 ++++++++++++++++- 7 files changed, 2305 insertions(+), 144 deletions(-) diff --git a/apps/tracker/PRODUCT.md b/apps/tracker/PRODUCT.md index 22118207..a159cd9f 100644 --- a/apps/tracker/PRODUCT.md +++ b/apps/tracker/PRODUCT.md @@ -58,7 +58,8 @@ Confirmed capabilities in the UI: - One-click auto-translation (Google Translate), preserving ICU placeholders and Transloco variables. - Collection create/edit; read-only collections are protected across CLI, API, and UI. -- Settings page manages the protected-terms list (chips, backed by a standalone JSON file). +- Settings page manages the protected-terms list: an alphabetical list with inline rename, + staged add/remove, and an explicit save, backed by a standalone JSON file. - Comment and tag popovers; indexing overlay during long operations. Constraints: diff --git a/apps/tracker/src/app/settings/settings.html b/apps/tracker/src/app/settings/settings.html index 5b16fa8d..a844ba34 100644 --- a/apps/tracker/src/app/settings/settings.html +++ b/apps/tracker/src/app/settings/settings.html @@ -1,6 +1,36 @@

{{ TOKENS.SETTINGS.TITLE | transloco }}

+ + + @if (!isEmpty() || hasChanges()) { +
+

+ @if (hasChanges()) { + edit_note + {{ TOKENS.SETTINGS.PROTECTEDTERMSUNSAVEDCHANGESX | transloco : { count: changeCount() } }} + } @else { + check_circle + {{ TOKENS.SETTINGS.PROTECTEDTERMSALLSAVED | transloco }} + } +

+ +
+ + +
+
+ }
@if (store.isLoading()) { @@ -8,51 +38,225 @@

{{ TOKENS.SETTINGS.TITLE | transloco }}

} @if (store.error(); as error) { - + } -
-

- {{ TOKENS.SETTINGS.PROTECTEDTERMSLABEL | transloco }} -

- - - - @for (term of protectedTermsList(); track term) { - - {{ term }} - - - } - - - {{ TOKENS.SETTINGS.PROTECTEDTERMSHINT | transloco }} - +
+
+

+ {{ TOKENS.SETTINGS.PROTECTEDTERMSLABEL | transloco }} +

+ {{ TOKENS.SETTINGS.PROTECTEDTERMSCOUNTX | transloco : { count: termCount() } }} +
+ +

{{ TOKENS.SETTINGS.PROTECTEDTERMSHINT | transloco }}

@if (protectedTermsFilePath(); as filePath) { -

- {{ TOKENS.SETTINGS.PROTECTEDTERMSFILELABEL | transloco : { file: filePath } }} +

+ description + + {{ TOKENS.SETTINGS.PROTECTEDTERMSFILELABEL | transloco : { file: filePath } }}

} -
- -
+ + + @if (addError(); as duplicate) { + + } + + @if (isEmpty()) { +
+ shield +

{{ TOKENS.SETTINGS.PROTECTEDTERMSEMPTYTITLE | transloco }}

+

{{ TOKENS.SETTINGS.PROTECTEDTERMSEMPTYMESSAGE | transloco }}

+
+ } @else { +
+ @if (showFilter()) { +
+ search + + @if (isFiltering()) { + + } +
+ } + +
+ @if (hasNoMatches()) { +

+ {{ TOKENS.SETTINGS.PROTECTEDTERMSNOMATCHESX | transloco : { query: filter().trim() } }} +

+ } @else { +
    + @for (entry of visibleEntries(); track entry.id) { +
  • + @if (editingId() === entry.id) { +
    + +
    + + +
    +
    + @if (editError(); as duplicate) { + + } + } @else { +
    + {{ entry.value }} + + @switch (statusOf(entry)) { + @case ('removed') { + + {{ TOKENS.SETTINGS.PROTECTEDTERMSBADGEREMOVING | transloco }} + + } + @case ('added') { + + {{ TOKENS.SETTINGS.PROTECTEDTERMSBADGENEW | transloco }} + + } + @case ('edited') { + + {{ TOKENS.SETTINGS.PROTECTEDTERMSBADGEEDITED | transloco }} + + } + } + + @if (statusOf(entry) === 'edited' && entry.original) { + + {{ TOKENS.SETTINGS.PROTECTEDTERMSWASX | transloco : { term: entry.original } }} + + } +
    + +
    + @if (entry.removed) { + + } @else { + + + } +
    + } +
  • + } +
+ } +
+
+ } +
-
\ No newline at end of file + diff --git a/apps/tracker/src/app/settings/settings.scss b/apps/tracker/src/app/settings/settings.scss index 57934ab6..1128cde4 100644 --- a/apps/tracker/src/app/settings/settings.scss +++ b/apps/tracker/src/app/settings/settings.scss @@ -1,54 +1,670 @@ +:host { + display: block; + height: 100%; +} + .settings-page { display: flex; flex-direction: column; - gap: 1.5rem; - padding: 1.5rem; + gap: var(--spacing-5); + // The app has no global border-box reset, so the padding below would otherwise + // be added on top of the full height and push the column past the viewport. + box-sizing: border-box; + height: 100%; + padding: var(--spacing-6) var(--spacing-6) var(--spacing-6); max-width: 720px; margin: 0 auto; + overflow: hidden; } .settings-header { display: flex; align-items: center; + justify-content: space-between; + gap: var(--spacing-4); + flex: none; + flex-wrap: wrap; } .settings-title { margin: 0; + color: var(--color-text-primary); } .settings-progress { - margin-bottom: 1rem; + flex: none; + margin: 0; } .settings-error { - padding: 0.75rem 1rem; - border: 1px solid var(--mat-snack-bar-button-color, #f44336); - border-radius: 4px; - color: #f44336; - background: rgba(244, 67, 54, 0.08); + flex: none; + display: flex; + align-items: flex-start; + gap: var(--spacing-2); + padding: var(--spacing-3) var(--spacing-4); + border: 1px solid color-mix(in srgb, var(--color-error) 35%, transparent); + border-radius: var(--border-radius-lg); + color: color-mix(in srgb, var(--color-error) 65%, var(--color-text-primary)); + background: color-mix(in srgb, var(--color-error) 10%, transparent); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); +} + +.settings-error-icon { + flex: none; + font-size: 20px; + width: 20px; + height: 20px; } -.settings-section { +// ── Protected terms ────────────────────────────────────────────────────────── + +.terms { display: flex; flex-direction: column; - gap: 0.5rem; + flex: 1 1 auto; + min-height: 0; + + // An opaque twin of the panel fill, so the scroll shadows below have something + // solid to fade into. color-mix with `transparent` cannot be used for that. + --terms-surface: color-mix(in srgb, var(--color-background) 88%, var(--color-background-subtle)); + --terms-scroll-shadow: rgb(44 36 28 / 0.18); + + // Material's default disabled fill is a heavy grey slab — the loudest thing on + // the page while doing nothing. Quiet it down to a token tint. + --mdc-filled-button-disabled-container-color: color-mix(in srgb, var(--color-border) 45%, transparent); + --mdc-filled-button-disabled-label-text-color: var(--color-text-tertiary); +} + +.terms-heading { + display: flex; + align-items: baseline; + gap: var(--spacing-3); + flex-wrap: wrap; } -.settings-section-title { +.terms-title { margin: 0; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-snug); + color: var(--color-text-primary); } -.settings-field { - width: 100%; +.terms-count { + padding: 2px var(--spacing-2); + border: 1px solid color-mix(in srgb, var(--color-secondary) 30%, transparent); + border-radius: var(--border-radius-full); + background: color-mix(in srgb, var(--color-secondary) 12%, transparent); + color: var(--color-secondary-active); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + font-variant-numeric: tabular-nums; + white-space: nowrap; } -.settings-actions { - display: flex; - justify-content: flex-end; +.terms-hint { + margin: var(--spacing-2) 0 0; + max-width: 62ch; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); } -.settings-protected-terms-file { - margin: 0 0 8px; - font-size: 12px; - opacity: 0.7; + +.terms-file { + display: flex; + align-items: flex-start; + gap: var(--spacing-1); + margin: var(--spacing-2) 0 0; + color: var(--color-text-secondary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); word-break: break-all; } + +.terms-file span { + min-width: 0; +} + +.terms-file-icon { + flex: none; + margin-top: 1px; + font-size: 14px; + width: 14px; + height: 14px; +} + +// ── Add row ────────────────────────────────────────────────────────────────── + +.terms-add { + display: flex; + align-items: flex-start; + gap: var(--spacing-2); + flex: none; + margin-top: var(--spacing-5); +} + +.terms-add-field { + flex: 1 1 auto; + min-width: 0; +} + +.terms-add-icon, +.terms-filter-icon { + margin-inline-end: var(--spacing-2); + color: var(--color-text-tertiary); +} + +.terms-add-button { + flex: none; + height: 56px; + min-width: 88px; + border-radius: var(--border-radius-lg); +} + +.terms-inline-error { + display: flex; + align-items: center; + gap: var(--spacing-1); + margin: var(--spacing-2) 0 0; + color: color-mix(in srgb, var(--color-error) 65%, var(--color-text-primary)); + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); +} + +.terms-inline-error-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; +} + +.terms-panel { + display: flex; + flex-direction: column; + flex: 0 1 auto; + min-height: 0; + margin-top: var(--spacing-5); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-xl); + background: var(--terms-surface); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.terms-scroll { + flex: 1 1 auto; + min-height: 120px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-color: var(--color-border-strong) transparent; + scrollbar-width: thin; + + // Classic scroll shadows: the two `local` gradients ride the content and hide + // the fixed ones at the extremes, so a shadow shows only where more rows are. + background: + linear-gradient(var(--terms-surface) 30%, transparent) top / 100% 20px no-repeat local, + linear-gradient(transparent, var(--terms-surface) 70%) bottom / 100% 20px no-repeat local, + radial-gradient(farthest-side at 50% 0, var(--terms-scroll-shadow), transparent) top / 100% 8px no-repeat scroll, + radial-gradient(farthest-side at 50% 100%, var(--terms-scroll-shadow), transparent) bottom / 100% 8px no-repeat scroll; +} + +[data-theme='dark'] .terms { + --terms-scroll-shadow: rgb(0 0 0 / 0.55); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']):not([data-theme='dark']) .terms { + --terms-scroll-shadow: rgb(0 0 0 / 0.55); + } +} + +.terms-filter { + display: flex; + align-items: center; + gap: var(--spacing-2); + flex: none; + padding: var(--spacing-1) var(--spacing-2) var(--spacing-1) var(--spacing-4); + border-bottom: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-background-muted) 55%, transparent); +} + +.terms-filter-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); +} + +.terms-filter-input { + flex: 1 1 auto; + min-width: 0; + padding: var(--spacing-2) 0; + border: 0; + background: none; + color: var(--color-text-primary); + font-family: inherit; + font-size: var(--font-size-sm); + + &::placeholder { + color: var(--color-text-secondary); + } + + &:focus { + outline: none; + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.terms-filter:focus-within { + background: color-mix(in srgb, var(--color-secondary) 8%, transparent); +} + +.terms-filter-clear { + flex: none; + --mdc-icon-button-state-layer-size: 32px; + + .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } +} + +.terms-no-matches { + margin: 0; + padding: var(--spacing-8) var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + text-align: center; +} + +// ── List ───────────────────────────────────────────────────────────────────── + +.terms-list { + list-style: none; + margin: 0; + padding: 0; +} + +.term { + display: flex; + align-items: center; + gap: var(--spacing-3); + min-height: 40px; + padding: 0 var(--spacing-2) 0 var(--spacing-4); + border-top: 1px solid var(--color-border-subtle); + transition: background var(--transition-fast); + + &:first-child { + border-top: 0; + } + + &:hover, + &:focus-within { + background: color-mix(in srgb, var(--color-primary) 5%, transparent); + } +} + +.term-body { + display: flex; + align-items: baseline; + gap: var(--spacing-2); + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; +} + +.term-value { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + word-break: break-word; + transition: color var(--transition-fast); +} + +.term-was { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + text-decoration: line-through; + word-break: break-word; +} + +.term-badge { + flex: none; + padding: 1px var(--spacing-2); + border-radius: var(--border-radius-full); + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.02em; + line-height: 1.5; + text-transform: uppercase; +} + +.term-badge--added { + background: color-mix(in srgb, var(--color-success) 18%, transparent); + color: color-mix(in srgb, var(--color-success) 42%, var(--color-text-primary)); +} + +.term-badge--edited { + background: color-mix(in srgb, var(--color-warning) 22%, transparent); + color: color-mix(in srgb, var(--color-warning) 42%, var(--color-text-primary)); +} + +.term-badge--removed { + background: color-mix(in srgb, var(--color-error) 18%, transparent); + color: color-mix(in srgb, var(--color-error) 42%, var(--color-text-primary)); +} + +.term-actions { + display: flex; + align-items: center; + gap: 2px; + flex: none; + margin-inline-start: auto; +} + +// Row controls stay out of the way until the row is reached, but never hide from +// keyboard or touch — a pointer-less visitor gets no hover to reveal them. +.term-action { + --mdc-icon-button-state-layer-size: 34px; + // Material's 48px touch target overflows a 45px row and makes the scroller + // report 2px of phantom overflow. Drop it here and restore a real one below, + // on the pointers that actually need it. + --mat-icon-button-touch-target-display: none; + color: var(--color-text-secondary); + transition: color var(--transition-fast); + + .term:hover &, + .term:focus-within &, + &:focus-visible { + color: var(--color-text-primary); + } + + .mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } +} + +// Coarse pointers get back a 44px target, and rows tall enough to hold it. +@media (pointer: coarse) { + .term { + min-height: 48px; + } + + .term-action { + --mdc-icon-button-state-layer-size: 44px; + } +} + +.term-action--remove:hover, +.term:hover .term-action--remove:hover { + color: color-mix(in srgb, var(--color-error) 70%, var(--color-text-primary)); +} + +.term-action--confirm, +.term:hover .term-action--confirm { + color: color-mix(in srgb, var(--color-success) 55%, var(--color-text-primary)); +} + +.term-undo { + --mdc-text-button-label-text-color: color-mix(in srgb, var(--color-secondary) 70%, var(--color-text-primary)); + --mdc-text-button-container-height: 32px; + flex: none; + font-size: var(--font-size-xs); + + .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-inline-end: var(--spacing-1); + } +} + +// ── Pending row states ─────────────────────────────────────────────────────── + +.term[data-status='added'] { + background: color-mix(in srgb, var(--color-success) 12%, transparent); +} + +.term[data-status='edited'] { + background: color-mix(in srgb, var(--color-warning) 13%, transparent); +} + +.term[data-status='removed'] { + background: color-mix(in srgb, var(--color-error) 11%, transparent); + + .term-value { + color: var(--color-text-secondary); + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--color-error) 70%, transparent); + text-decoration-thickness: 1.5px; + } +} + +// ── Inline rename ──────────────────────────────────────────────────────────── + +.term--editing { + flex-direction: column; + align-items: stretch; + gap: var(--spacing-1); + padding-block: var(--spacing-2); + padding-inline-end: var(--spacing-2); + background: color-mix(in srgb, var(--color-secondary) 9%, transparent); +} + +.term-edit { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.term-edit-input { + flex: 1 1 auto; + min-width: 0; + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius-md); + background: var(--color-background); + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } + + &[aria-invalid='true'] { + border-color: var(--color-error); + } +} + +.term-edit-error { + margin: 0; + padding-inline-start: var(--spacing-1); +} + +// ── Empty states ───────────────────────────────────────────────────────────── + +.terms-empty { + flex: none; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-2); + margin-top: var(--spacing-5); + padding: var(--spacing-10) var(--spacing-6); + border: 1px dashed var(--color-border-strong); + border-radius: var(--border-radius-xl); + background: color-mix(in srgb, var(--color-background-subtle) 70%, transparent); + text-align: center; +} + +.terms-empty-icon { + font-size: 40px; + width: 40px; + height: 40px; + color: color-mix(in srgb, var(--color-primary) 45%, transparent); +} + +.terms-empty-title { + margin: 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.terms-empty-message { + margin: 0; + max-width: 44ch; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); +} + +// ── Save bar ───────────────────────────────────────────────────────────────── +// Pinned in the page header: the list scrolls beneath it, these controls never do. + +.terms-bar { + display: flex; + align-items: center; + gap: var(--spacing-2); + flex: none; +} + +.terms-bar-status { + display: flex; + align-items: center; + gap: var(--spacing-1); + margin: 0; + padding: var(--spacing-1) var(--spacing-3); + border: 1px solid transparent; + border-radius: var(--border-radius-full); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; + white-space: nowrap; + transition: color var(--transition-normal), background var(--transition-normal), + border-color var(--transition-normal); +} + +// The one authored moment on this page: the pill arming itself the instant the +// list stops matching the file, so a pending change is impossible to walk past. +.terms-bar--dirty .terms-bar-status { + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: color-mix(in srgb, var(--color-warning) 42%, var(--color-text-primary)); + font-weight: var(--font-weight-semibold); + animation: status-arm 420ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes status-arm { + from { + border-color: transparent; + background: color-mix(in srgb, var(--color-warning) 34%, transparent); + box-shadow: 0 6px 18px -8px color-mix(in srgb, var(--color-warning) 70%, transparent); + transform: translateY(-3px); + } + to { + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + box-shadow: 0 0 0 transparent; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .terms-bar--dirty .terms-bar-status { + animation: none; + } +} + +.terms-bar-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; +} + +.terms-bar-actions { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +// ── Short viewports ────────────────────────────────────────────────────────── +// Landscape phones and short desktop windows. The section title already says what +// this list is, so the explanatory hint and the file path give up their rows to +// keep the filter and a useful number of terms on screen. + +@media (max-height: 500px) { + .settings-page { + gap: var(--spacing-3); + padding-block: var(--spacing-4); + } + + .terms-hint, + .terms-file { + display: none; + } + + .terms-add { + margin-top: var(--spacing-3); + } + + .terms-panel { + margin-top: var(--spacing-3); + } +} + +// ── Narrow screens ─────────────────────────────────────────────────────────── + +@media (max-width: 599px) { + .settings-page { + padding: var(--spacing-4) var(--spacing-4) var(--spacing-5); + } + + .terms-add-button { + min-width: 64px; + padding-inline: var(--spacing-3); + } + + .terms-file span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .terms-bar { + width: 100%; + } + + .terms-bar-actions { + margin-inline-start: auto; + } +} + +// ── Browser surfaces ───────────────────────────────────────────────────────── +// The parts nobody draws still belong to the palette. + +.terms { + ::selection { + background: color-mix(in srgb, var(--color-primary) 28%, transparent); + color: var(--color-text-primary); + } +} + +.terms-add-field input, +.term-edit-input { + caret-color: var(--color-primary); +} + diff --git a/apps/tracker/src/app/settings/settings.spec.ts b/apps/tracker/src/app/settings/settings.spec.ts index 3a5aa77c..744ef86e 100644 --- a/apps/tracker/src/app/settings/settings.spec.ts +++ b/apps/tracker/src/app/settings/settings.spec.ts @@ -18,7 +18,7 @@ describe('Settings', () => { baseLocale: 'en', locales: ['en'], collections: {}, - protectedTerms: ['iPhone'], + protectedTerms: ['iPhone', 'Node.js'], protectedTermsFilePath: '/project/.lingo-tracker-protected-terms.json', }; @@ -29,6 +29,22 @@ describe('Settings', () => { updateGlobalConfig: updateGlobalConfigMock, }); + /** Creates the component against a store, and returns it after a first render. */ + const render = (config: LingoTrackerConfigDto | null, error: string | null = null) => { + TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(config, error) }); + fixture = TestBed.createComponent(Settings); + component = fixture.componentInstance; + fixture.detectChanges(); + return component; + }; + + const termValues = () => component.entries().map((entry) => entry.value); + const entryFor = (value: string) => { + const entry = component.entries().find((candidate) => candidate.value === value); + expect(entry).toBeDefined(); + return entry; + }; + beforeEach(async () => { vi.clearAllMocks(); await TestBed.configureTestingModule({ @@ -37,74 +53,326 @@ describe('Settings', () => { }).compileComponents(); }); - it('renders global protected terms seeded from the store config', async () => { - TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(baseConfig) }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); + it('renders global protected terms seeded from the store config', () => { + render(baseConfig); - expect(component.protectedTermsList()).toEqual(['iPhone']); + expect(termValues()).toEqual(['iPhone', 'Node.js']); + expect(fixture.nativeElement.textContent).toContain('iPhone'); + expect(fixture.nativeElement.querySelectorAll('.term')).toHaveLength(2); + }); - const text = fixture.nativeElement.textContent; - expect(text).toContain('iPhone'); + it('lists terms alphabetically regardless of the order the file stored them in', () => { + render({ ...baseConfig, protectedTerms: ['zulu', 'Alpha', 'mike'] }); + + expect(component.sortedEntries().map((entry) => entry.value)).toEqual(['Alpha', 'mike', 'zulu']); }); it('names the file the terms are stored in', () => { - TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(baseConfig) }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); + render(baseConfig); expect(component.protectedTermsFilePath()).toBe('/project/.lingo-tracker-protected-terms.json'); - expect(fixture.nativeElement.querySelector('.settings-protected-terms-file')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.terms-file')).not.toBeNull(); }); it('omits the file line when the API reports no path', () => { - TestBed.overrideProvider(CollectionsStore, { - useValue: buildStore({ ...baseConfig, protectedTermsFilePath: undefined }), + render({ ...baseConfig, protectedTermsFilePath: undefined }); + + expect(fixture.nativeElement.querySelector('.terms-file')).toBeNull(); + }); + + it('shows the empty state when there are no terms', () => { + render({ ...baseConfig, protectedTerms: [] }); + + expect(component.isEmpty()).toBe(true); + expect(fixture.nativeElement.querySelector('.terms-empty')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.terms-list')).toBeNull(); + }); + + describe('adding', () => { + it('adds a trimmed term and clears the draft', () => { + render(baseConfig); + + component.onAddDraftChange(' C++ '); + component.addTerm(); + + expect(termValues()).toContain('C++'); + expect(component.addDraft()).toBe(''); + expect(component.changeCount()).toBe(1); }); - fixture = TestBed.createComponent(Settings); - fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('.settings-protected-terms-file')).toBeNull(); + it('refuses a duplicate and reports which term collided', () => { + render(baseConfig); + + component.onAddDraftChange('iPhone'); + component.addTerm(); + + expect(component.addError()).toBe('iPhone'); + expect(termValues()).toEqual(['iPhone', 'Node.js']); + }); + + it('clears the duplicate error as soon as the draft changes', () => { + render(baseConfig); + component.onAddDraftChange('iPhone'); + component.addTerm(); + + component.onAddDraftChange('iPhon'); + + expect(component.addError()).toBeNull(); + }); + + it('re-adding a term that is marked for removal takes the removal back', () => { + render(baseConfig); + const removed = entryFor('iPhone'); + if (!removed) return; + component.removeTerm(removed); + + component.onAddDraftChange('iPhone'); + component.addTerm(); + + expect(component.addError()).toBeNull(); + expect(entryFor('iPhone')?.removed).toBe(false); + expect(component.changeCount()).toBe(0); + }); }); - it('save invokes the store updateGlobalConfig with the current terms', () => { - TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(baseConfig) }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); + describe('removing', () => { + it('keeps a saved term visible and marked until the change is saved', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + + component.removeTerm(entry); + + expect(termValues()).toContain('iPhone'); + expect(component.statusOf(entryFor('iPhone') ?? entry)).toBe('removed'); + expect(component.termsToSave()).toEqual(['Node.js']); + expect(component.changeCount()).toBe(1); + }); - component.addProtectedTerm({ value: 'C++', chipInput: { clear: () => undefined } } as never); - component.save(); + it('restores a term marked for removal', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + component.removeTerm(entry); - expect(updateGlobalConfigMock).toHaveBeenCalledWith({ protectedTerms: ['iPhone', 'C++'] }); + component.restoreTerm(entryFor('iPhone') ?? entry); + + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + expect(component.hasChanges()).toBe(false); + }); + + it('drops a term added in this session outright rather than marking it', () => { + render(baseConfig); + component.onAddDraftChange('C++'); + component.addTerm(); + + const added = entryFor('C++'); + if (!added) return; + component.removeTerm(added); + + expect(termValues()).toEqual(['iPhone', 'Node.js']); + expect(component.hasChanges()).toBe(false); + }); }); - it('surfaces a failed save error to the user', () => { - TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(null, 'update failed') }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); + describe('renaming', () => { + it('commits an edit and remembers the previous spelling', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + + component.beginEdit(entry); + component.onEditDraftChange('iPad'); + component.commitEdit(); + + expect(component.editingId()).toBeNull(); + expect(component.termsToSave()).toEqual(['iPad', 'Node.js']); + expect(entryFor('iPad')?.original).toBe('iPhone'); + expect(component.statusOf(entryFor('iPad') ?? entry)).toBe('edited'); + }); + + it('refuses an edit that collides with another term', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; - const text = fixture.nativeElement.textContent; - expect(text).toContain('update failed'); + component.beginEdit(entry); + component.onEditDraftChange('Node.js'); + component.commitEdit(); + + expect(component.editError()).toBe('Node.js'); + expect(component.editingId()).toBe(entry.id); + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + }); + + it('treats an emptied edit as a cancel', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + + component.beginEdit(entry); + component.onEditDraftChange(' '); + component.commitEdit(); + + expect(component.editingId()).toBeNull(); + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + }); + + it('cancel leaves the term untouched', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + + component.beginEdit(entry); + component.onEditDraftChange('iPad'); + component.cancelEdit(); + + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + expect(component.hasChanges()).toBe(false); + }); }); - it('does not clobber in-progress edits on a config refetch', () => { - const config = signal(baseConfig); - TestBed.overrideProvider(CollectionsStore, { - useValue: { config, error: signal(null), isLoading: signal(false), updateGlobalConfig: updateGlobalConfigMock }, + describe('filtering', () => { + const manyTerms = { ...baseConfig, protectedTerms: ['a1', 'b2', 'c3', 'd4', 'e5', 'f6', 'g7', 'h8', 'iPhone'] }; + + it('offers no filter on a list short enough to scan', () => { + render(baseConfig); + + expect(component.showFilter()).toBe(false); + expect(fixture.nativeElement.querySelector('.terms-filter')).toBeNull(); }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); - component.addProtectedTerm({ value: 'Node.js', chipInput: { clear: () => undefined } } as never); + it('offers a filter once the list is long enough to need one', () => { + render(manyTerms); + + expect(component.showFilter()).toBe(true); + expect(fixture.nativeElement.querySelector('.terms-filter')).not.toBeNull(); + }); + + it('drops a filter that would hide a term the user just added', () => { + render(manyTerms); + component.filter.set('a1'); + + component.onAddDraftChange('Zod'); + component.addTerm(); + + expect(component.filter()).toBe(''); + expect(component.visibleEntries().map((entry) => entry.value)).toContain('Zod'); + }); + + it('keeps a filter the newly added term still matches', () => { + render(manyTerms); + component.filter.set('zo'); + + component.onAddDraftChange('Zod'); + component.addTerm(); + + expect(component.filter()).toBe('zo'); + expect(component.visibleEntries().map((entry) => entry.value)).toEqual(['Zod']); + }); - // Simulate a successful save's config refetch arriving with the previous terms. - config.set({ ...baseConfig, protectedTerms: ['iPhone'] }); + it('drops a filter that would hide a term the user just renamed', () => { + render(manyTerms); + component.filter.set('iph'); + const entry = component.visibleEntries()[0]; + expect(entry).toBeDefined(); + if (!entry) return; + + component.beginEdit(entry); + component.onEditDraftChange('Zod'); + component.commitEdit(); + + expect(component.filter()).toBe(''); + expect(component.visibleEntries().map((entry) => entry.value)).toContain('Zod'); + }); - expect(component.protectedTermsList()).toEqual(['iPhone', 'Node.js']); + it('matches case-insensitively and reports when nothing matches', () => { + render(manyTerms); + + component.filter.set('iphone'); + expect(component.visibleEntries().map((entry) => entry.value)).toEqual(['iPhone']); + + component.filter.set('nothing here'); + expect(component.hasNoMatches()).toBe(true); + }); + }); + + describe('saving', () => { + it('sends the surviving terms in display order', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + component.removeTerm(entry); + component.onAddDraftChange('C++'); + component.addTerm(); + + component.save(); + + expect(updateGlobalConfigMock).toHaveBeenCalledWith({ protectedTerms: ['C++', 'Node.js'] }); + }); + + it('does nothing when there is nothing to save', () => { + render(baseConfig); + + component.save(); + + expect(updateGlobalConfigMock).not.toHaveBeenCalled(); + }); + + it('revert all restores the saved list', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + component.removeTerm(entry); + component.onAddDraftChange('C++'); + component.addTerm(); + + component.revertAll(); + + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + expect(component.hasChanges()).toBe(false); + }); + + it('surfaces a failed save error to the user', () => { + render(null, 'update failed'); + + expect(fixture.nativeElement.textContent).toContain('update failed'); + }); + + it('does not clobber in-progress edits on a config refetch we did not ask for', () => { + const config = signal(baseConfig); + TestBed.overrideProvider(CollectionsStore, { + useValue: { config, error: signal(null), isLoading: signal(false), updateGlobalConfig: updateGlobalConfigMock }, + }); + fixture = TestBed.createComponent(Settings); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.onAddDraftChange('C++'); + component.addTerm(); + config.set({ ...baseConfig, protectedTerms: ['iPhone', 'Node.js'] }); + fixture.detectChanges(); + + expect(component.termsToSave()).toEqual(['C++', 'iPhone', 'Node.js']); + }); + + it('adopts the refetched config as the new baseline after a save', () => { + const config = signal(baseConfig); + TestBed.overrideProvider(CollectionsStore, { + useValue: { config, error: signal(null), isLoading: signal(false), updateGlobalConfig: updateGlobalConfigMock }, + }); + fixture = TestBed.createComponent(Settings); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.onAddDraftChange('C++'); + component.addTerm(); + component.save(); + config.set({ ...baseConfig, protectedTerms: ['C++', 'iPhone', 'Node.js'] }); + fixture.detectChanges(); + + expect(component.hasChanges()).toBe(false); + expect(component.termsToSave()).toEqual(['C++', 'iPhone', 'Node.js']); + }); }); }); diff --git a/apps/tracker/src/app/settings/settings.ts b/apps/tracker/src/app/settings/settings.ts index 25b4a5a9..e7fb2723 100644 --- a/apps/tracker/src/app/settings/settings.ts +++ b/apps/tracker/src/app/settings/settings.ts @@ -1,20 +1,50 @@ -import { Component, ChangeDetectionStrategy, inject, effect, signal, computed } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + type ElementRef, + inject, + effect, + signal, + computed, + untracked, + viewChildren, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatChipsModule, type MatChipInputEvent } from '@angular/material/chips'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslocoModule } from '@jsverse/transloco'; import { normalizeProtectedTerms } from '@simoncodes-ca/domain'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { CollectionsStore } from '../collections/store/collections.store'; import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; +/** + * One row of the protected-terms editor. `original` is the value as last saved — + * absent for a term added in this session — so a row can describe itself as + * added, renamed or removed without diffing against the whole saved list. + */ +interface TermEntry { + readonly id: number; + readonly value: string; + readonly original?: string; + readonly removed: boolean; +} + +/** Above this many terms the list gets a filter field; below it, scanning is faster than typing. */ +const FILTER_THRESHOLD = 8; + +const compareTerms = (a: string, b: string): number => a.localeCompare(b, undefined, { sensitivity: 'base' }); + /** * Settings view. Currently exposes the global protected-terms list, written * through `PUT /api/config`. Structured so other global fields can be added * later without rework. Never exposes collections/locales/baseLocale editing. + * + * Edits are staged: adds, renames and removals are held as pending row state and + * only reach the API on Save, so every change is visible and reversible first. */ @Component({ selector: 'app-settings', @@ -26,8 +56,9 @@ import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; MatButtonModule, MatIconModule, MatProgressBarModule, - MatChipsModule, MatFormFieldModule, + MatInputModule, + MatTooltipModule, ], templateUrl: './settings.html', styleUrl: './settings.scss', @@ -38,39 +69,240 @@ export class Settings { readonly TOKENS = TRACKER_TOKENS; - readonly termSeparatorKeyCodes = [ENTER, COMMA] as const; - readonly protectedTermsList = signal([]); + private readonly editInputs = viewChildren>('editInput'); + private readonly termRows = viewChildren>('termRow'); + + readonly entries = signal([]); + readonly addDraft = signal(''); + readonly filter = signal(''); + readonly editingId = signal(null); + readonly editDraft = signal(''); + /** Term that a failed add or rename collided with; cleared as soon as the input changes. */ + readonly addError = signal(null); + readonly editError = signal(null); + /** Path of the file the terms are stored in, surfaced read-only so the source of a diff is obvious. */ readonly protectedTermsFilePath = computed(() => this.store.config()?.protectedTermsFilePath); - /** True once the chip list has been seeded; prevents a later config refetch from clobbering edits. */ + + /** True once the list has been seeded; prevents a later config refetch from clobbering edits. */ readonly #seeded = signal(false); + /** Set while a save is in flight so the next config arrival is treated as the new baseline. */ + readonly #awaitingSave = signal(false); + #nextId = 0; + /** Row to reveal once it has rendered, so an added term is never added off-screen. */ + readonly #scrollToId = signal(null); + + /** Display order is alphabetical: the list is a set, and a sorted file keeps its Git diffs minimal. */ + readonly sortedEntries = computed(() => [...this.entries()].sort((a, b) => compareTerms(a.value, b.value))); + + readonly visibleEntries = computed(() => { + const query = this.filter().trim().toLowerCase(); + if (!query) return this.sortedEntries(); + return this.sortedEntries().filter( + (entry) => entry.value.toLowerCase().includes(query) || (entry.original?.toLowerCase().includes(query) ?? false), + ); + }); + + /** The terms a save would write: everything not marked for removal, in display order. */ + readonly termsToSave = computed(() => + normalizeProtectedTerms( + this.sortedEntries() + .filter((entry) => !entry.removed) + .map((entry) => entry.value), + ), + ); + + readonly termCount = computed(() => this.termsToSave().length); + + readonly changeCount = computed( + () => + this.entries().filter((entry) => entry.removed || entry.original === undefined || entry.original !== entry.value) + .length, + ); + + readonly hasChanges = computed(() => this.changeCount() > 0); + readonly isEmpty = computed(() => this.entries().length === 0); + readonly showFilter = computed(() => this.entries().length > FILTER_THRESHOLD); + readonly isFiltering = computed(() => this.filter().trim().length > 0); + readonly hasNoMatches = computed(() => !this.isEmpty() && this.isFiltering() && this.visibleEntries().length === 0); + readonly canAdd = computed(() => this.addDraft().trim().length > 0); constructor() { - // Seed the chip list from the store config as soon as it loads (App loads config on boot). - // Only seeds once, so a successful save that refetches config cannot overwrite in-progress edits. + // Seed from the store config as soon as it loads (App loads config on boot), and again + // after a save's refetch. A refetch that is not ours must not overwrite in-progress edits. + // The latches are read untracked so that arming one on save cannot itself trigger a + // reseed from the config still on screen — only a config arrival may reseed. + effect(() => { + const config = this.store.config(); + if (!config) return; + untracked(() => { + if (!this.#seeded() || this.#awaitingSave()) this.#seed(config.protectedTerms ?? []); + }); + }); + + // A failed save never refetches, so release the save latch on the error instead. + effect(() => { + if (!this.store.error()) return; + untracked(() => this.#awaitingSave.set(false)); + }); + effect(() => { - if (this.#seeded()) return; - const cfg = this.store.config(); - if (!cfg) return; - this.protectedTermsList.set(cfg.protectedTerms ?? []); - this.#seeded.set(true); + const input = this.editInputs()[0]; + if (this.editingId() === null || !input) return; + input.nativeElement.focus(); + input.nativeElement.select(); }); + + // The row may not be in the query yet when the id is set, so the target is + // held — not cleared — until it is found. Clearing early is what made an + // added term land silently below the fold of the scrolling list. + effect(() => { + const id = this.#scrollToId(); + const rows = this.termRows(); + if (id === null) return; + const row = rows.find((ref) => ref.nativeElement.getAttribute('data-term-id') === String(id)); + if (!row) return; + // Optional call: not every environment implements scrollIntoView (jsdom does not). + row.nativeElement.scrollIntoView?.({ block: 'nearest' }); + this.#scrollToId.set(null); + }); + } + + /** + * Brings the row for `value` into view. A filter that would hide it is dropped + * first: a term you just added or renamed must never vanish behind a filter. + */ + #revealTerm(id: number, value: string): void { + const query = this.filter().trim().toLowerCase(); + if (query && !value.toLowerCase().includes(query)) this.filter.set(''); + this.#scrollToId.set(id); + } + + #seed(terms: readonly string[]): void { + this.entries.set( + normalizeProtectedTerms([...terms]).map((value) => ({ + id: this.#nextId++, + value, + original: value, + removed: false, + })), + ); + this.#scrollToId.set(null); + this.editingId.set(null); + this.addError.set(null); + this.editError.set(null); + this.#seeded.set(true); + this.#awaitingSave.set(false); } - addProtectedTerm(event: MatChipInputEvent): void { - const [term] = normalizeProtectedTerms([event.value]); - if (term && !this.protectedTermsList().includes(term)) { - this.protectedTermsList.update((terms) => [...terms, term]); + /** The pending state of a row, used for its badge and tint. */ + statusOf(entry: TermEntry): 'removed' | 'added' | 'edited' | 'unchanged' { + if (entry.removed) return 'removed'; + if (entry.original === undefined) return 'added'; + return entry.original === entry.value ? 'unchanged' : 'edited'; + } + + addTerm(): void { + const [term] = normalizeProtectedTerms([this.addDraft()]); + if (!term) return; + + const existing = this.entries().find((entry) => entry.value === term); + if (existing) { + // A term the user is re-adding was only marked for removal — take that back rather than refuse. + if (existing.removed) { + this.restoreTerm(existing); + this.addDraft.set(''); + this.addError.set(null); + this.#revealTerm(existing.id, existing.value); + return; + } + this.addError.set(term); + this.#revealTerm(existing.id, existing.value); + return; + } + + const id = this.#nextId++; + this.entries.update((entries) => [...entries, { id, value: term, removed: false }]); + this.addDraft.set(''); + this.addError.set(null); + this.#revealTerm(id, term); + } + + onAddDraftChange(value: string): void { + this.addDraft.set(value); + this.addError.set(null); + } + + removeTerm(entry: TermEntry): void { + // A term added in this session has nothing to remove from the file — drop the row outright. + if (entry.original === undefined) { + this.entries.update((entries) => entries.filter((candidate) => candidate.id !== entry.id)); + return; + } + this.#patch(entry.id, { removed: true }); + } + + restoreTerm(entry: TermEntry): void { + this.#patch(entry.id, { removed: false }); + } + + beginEdit(entry: TermEntry): void { + this.editingId.set(entry.id); + this.editDraft.set(entry.value); + this.editError.set(null); + } + + onEditDraftChange(value: string): void { + this.editDraft.set(value); + this.editError.set(null); + } + + commitEdit(): void { + const id = this.editingId(); + if (id === null) return; + const entry = this.entries().find((candidate) => candidate.id === id); + if (!entry) return; + + const [term] = normalizeProtectedTerms([this.editDraft()]); + if (!term || term === entry.value) { + this.cancelEdit(); + return; + } + + if (this.entries().some((candidate) => candidate.id !== id && candidate.value === term && !candidate.removed)) { + this.editError.set(term); + return; } - event.chipInput?.clear(); + + this.#patch(id, { value: term }); + this.editingId.set(null); + this.editError.set(null); + this.#revealTerm(id, term); + } + + cancelEdit(): void { + this.editingId.set(null); + this.editError.set(null); } - removeProtectedTerm(term: string): void { - this.protectedTermsList.update((terms) => terms.filter((t) => t !== term)); + revertAll(): void { + this.#seed(this.store.config()?.protectedTerms ?? []); + this.filter.set(''); + } + + clearFilter(): void { + this.filter.set(''); } save(): void { + if (!this.hasChanges()) return; + this.cancelEdit(); + this.#awaitingSave.set(true); // Errors from a failed save surface via the store error signal (rendered in the template). - this.store.updateGlobalConfig({ protectedTerms: this.protectedTermsList() }); + this.store.updateGlobalConfig({ protectedTerms: this.termsToSave() }); + } + + #patch(id: number, patch: Partial>): void { + this.entries.update((entries) => entries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry))); } } diff --git a/apps/tracker/src/i18n/settings/resource_entries.json b/apps/tracker/src/i18n/settings/resource_entries.json index ac50f091..1c1b6933 100644 --- a/apps/tracker/src/i18n/settings/resource_entries.json +++ b/apps/tracker/src/i18n/settings/resource_entries.json @@ -20,14 +20,14 @@ "de": "Globale Schutzbestimmungen" }, "protectedTermsHint": { - "source": "Brand names or jargon kept verbatim and never translated across all collections. Press Enter or comma to add.", + "source": "Brand names and jargon kept verbatim by auto-translation across every collection.", "comment": "Hint text under the global protected terms field in settings", "tags": ["settings"], - "es": "Las marcas o la jerga se conservan tal cual y nunca se traducen en todas las colecciones. Pulse Intro o una coma para añadir.", - "fr-ca": "Les marques et le jargon sont conservés tels quels et ne sont jamais traduits dans toutes les collections. Appuyez sur Entrée ou sur la virgule pour ajouter.", - "ru": "Названия брендов или терминология сохраняются дословно и никогда не переводятся во все коллекции. Нажмите Enter или запятую для добавления.", - "ja": "ブランド名や専門用語は、すべてのコレクションでそのまま使用され、翻訳されることはありません。Enterキーまたはカンマを押して追加してください。", - "de": "Markennamen und Fachbegriffe werden unverändert übernommen und nicht in alle Kollektionen übersetzt. Drücken Sie die Eingabetaste oder ein Komma, um etwas hinzuzufügen." + "es": "Las marcas y la jerga se conservan textualmente mediante la traducción automática en todas las colecciones.", + "fr-ca": "Les noms de marques et le jargon sont conservés textuellement par traduction automatique dans toutes les collections.", + "ru": "Названия брендов и терминология сохраняются дословно благодаря автоматическому переводу во всех коллекциях.", + "ja": "ブランド名や専門用語は、すべてのコレクションにおいて自動翻訳によって原文のまま維持されます。", + "de": "Markennamen und Fachbegriffe werden durch automatische Übersetzung in jeder Kollektion wortgetreu beibehalten." }, "protectedTermsAddPlaceholder": { "source": "Add protected term…", @@ -68,5 +68,215 @@ "ru": "Хранится в {file}", "ja": "{file} に保存されています", "de": "Gespeichert in {file}" + }, + "protectedTermsCountX": { + "source": "{ count, plural, =0 {No terms} =1 {1 term} other {{count} terms} }", + "comment": "Count badge beside the protected terms section title", + "tags": ["settings"], + "es": "{ count, plural, =0 {Sin términos} =1 {1 término} other {{count} términos} }", + "fr-ca": "{ count, plural, =0 {Aucun terme} =1 {1 terme} other {{count} termes} }", + "de": "{ count, plural, =0 {Keine Begriffe} =1 {1 Begriff} other {{count} Begriffe} }", + "ja": "{ count, plural, =0 {用語なし} other {{count} 件の用語} }", + "ru": "{ count, plural, =0 {Нет терминов} one {{count} термин} few {{count} термина} many {{count} терминов} other {{count} термина} }" + }, + "protectedTermsAddButton": { + "source": "Add", + "comment": "Button that adds the typed protected term to the list", + "tags": ["settings"], + "es": "Agregar", + "fr-ca": "Ajouter", + "ru": "Добавлять", + "ja": "追加", + "de": "Hinzufügen" + }, + "protectedTermsDuplicateX": { + "source": "Already on the list: {term}", + "comment": "Inline error when the typed protected term already exists. {term} is the term.", + "tags": ["settings"], + "es": "Ya está en la lista: {term}", + "fr-ca": "Déjà sur la liste : {term}", + "ru": "Уже в списке: {term}", + "ja": "既にリストに登録済み:{term}", + "de": "Bereits auf der Liste: {term}" + }, + "protectedTermsFilterPlaceholder": { + "source": "Filter terms", + "comment": "Placeholder of the input that filters the protected terms list", + "tags": ["settings"], + "es": "Términos de filtro", + "fr-ca": "Termes de filtrage", + "ru": "Фильтр по критериям", + "ja": "フィルター条件", + "de": "Filterbegriffe" + }, + "protectedTermsEmptyTitle": { + "source": "No protected terms yet", + "comment": "Title of the empty state when no protected terms exist", + "tags": ["settings"], + "es": "Aún no hay términos protegidos", + "fr-ca": "Aucun terme protégé pour le moment", + "ru": "Пока нет защищенных условий.", + "ja": "保護された条項はまだありません", + "de": "Noch keine geschützten Bedingungen" + }, + "protectedTermsEmptyMessage": { + "source": "Add a brand name or piece of jargon and auto-translation will keep it verbatim in every locale.", + "comment": "Body of the empty state when no protected terms exist", + "tags": ["settings"], + "es": "Añade una marca o una expresión técnica y la traducción automática la mantendrá idéntica en todos los idiomas.", + "fr-ca": "Ajoutez une marque ou un terme technique, et la traduction automatique le restituera mot à mot dans toutes les langues.", + "ru": "Добавьте название бренда или профессиональный жаргон, и автоматический перевод сохранит его дословно во всех языковых версиях.", + "ja": "ブランド名や専門用語を追加すると、自動翻訳機能がどの地域でも原文をそのまま再現します。", + "de": "Fügen Sie einen Markennamen oder einen Fachbegriff hinzu, und die automatische Übersetzung behält ihn in jeder Region wortgetreu bei." + }, + "protectedTermsNoMatchesX": { + "source": "No terms match “{query}”", + "comment": "Message when the filter matches no protected terms. {query} is the filter text.", + "tags": ["settings"], + "es": "No se encontraron términos que coincidan con “{query}”", + "fr-ca": "Aucun terme ne correspond à « {query} »", + "ru": "Нет терминов, соответствующих \"{query}\"", + "ja": "「{query}」に一致する用語はありません。", + "de": "Es wurden keine passenden Begriffe für „{query}“ gefunden." + }, + "protectedTermsEditAriaX": { + "source": "Edit protected term {term}", + "comment": "Accessible label of the edit button on a protected term row. {term} is the term.", + "tags": ["settings"], + "es": "Editar término protegido {term}", + "fr-ca": "Modifier le terme protégé {term}", + "ru": "Редактировать защищенный термин {term}", + "ja": "保護された用語を編集 {term}", + "de": "Bearbeiten Sie den geschützten Begriff {term}" + }, + "protectedTermsRemoveAriaX": { + "source": "Remove protected term {term}", + "comment": "Accessible label of the remove button on a protected term row. {term} is the term.", + "tags": ["settings"], + "es": "Eliminar término protegido {term}", + "fr-ca": "Supprimer le terme protégé {term}", + "ru": "Удалить защищенный термин {term}", + "ja": "保護された用語を削除します {term}", + "de": "Entferne den geschützten Begriff {term}" + }, + "protectedTermsRestoreAriaX": { + "source": "Keep protected term {term}", + "comment": "Accessible label of the undo button on a protected term marked for removal. {term} is the term.", + "tags": ["settings"], + "es": "Mantener el término protegido {term}", + "fr-ca": "Conserver le terme protégé {term}", + "ru": "Сохраняйте защищенный термин {term}", + "ja": "保護された用語を保持します {term}", + "de": "Schutzbegriff beibehalten {term}" + }, + "protectedTermsRenameAriaX": { + "source": "Rename protected term {term}", + "comment": "Accessible label of the text field shown while renaming a protected term. {term} is the term.", + "tags": ["settings"], + "es": "Cambiar el nombre del término protegido {term}", + "fr-ca": "Renommer le terme protégé {term}", + "ru": "Переименовать защищенный термин {term}", + "ja": "保護された用語の名前を変更します {term}", + "de": "geschützten Begriff umbenennen {term}" + }, + "protectedTermsUndo": { + "source": "Undo", + "comment": "Button that restores a protected term marked for removal", + "tags": ["settings"], + "es": "Deshacer", + "fr-ca": "Défaire", + "ru": "Отменить", + "ja": "元に戻す", + "de": "Rückgängig machen" + }, + "protectedTermsBadgeRemoving": { + "source": "Removing", + "comment": "Badge on a protected term that will be deleted when changes are saved", + "tags": ["settings"], + "es": "Eliminando", + "fr-ca": "Suppression", + "ru": "Удаление", + "ja": "削除", + "de": "Entfernen" + }, + "protectedTermsBadgeNew": { + "source": "New", + "comment": "Badge on a protected term that has been added but not yet saved", + "tags": ["settings"], + "es": "Nuevo", + "fr-ca": "Nouveau", + "ru": "Новый", + "ja": "新しい", + "de": "Neu" + }, + "protectedTermsBadgeEdited": { + "source": "Edited", + "comment": "Badge on a protected term that has been renamed but not yet saved", + "tags": ["settings"], + "es": "Editado", + "fr-ca": "Édité", + "ru": "Отредактировано", + "ja": "編集済み", + "de": "Bearbeitet" + }, + "protectedTermsWasX": { + "source": "was {term}", + "comment": "Small line under a renamed protected term showing its previous spelling. {term} is the previous term.", + "tags": ["settings"], + "es": "era {term}", + "fr-ca": "était {term}", + "ru": "был {term}", + "ja": "{term} でした", + "de": "war {term}" + }, + "protectedTermsUnsavedChangesX": { + "source": "{ count, plural, =1 {1 unsaved change} other {{count} unsaved changes} }", + "comment": "Count of pending protected term changes shown in the save bar", + "tags": ["settings"], + "es": "{ count, plural, =1 {1 cambio sin guardar} other {{count} cambios sin guardar} }", + "fr-ca": "{ count, plural, =1 {1 modification non enregistrée} other {{count} modifications non enregistrées} }", + "de": "{ count, plural, =1 {1 nicht gespeicherte Änderung} other {{count} nicht gespeicherte Änderungen} }", + "ja": "{ count, plural, other {{count} 件の未保存の変更} }", + "ru": "{ count, plural, one {{count} несохранённое изменение} few {{count} несохранённых изменения} many {{count} несохранённых изменений} other {{count} несохранённых изменения} }" + }, + "protectedTermsRevertAll": { + "source": "Revert all", + "comment": "Button that discards every pending protected term change", + "tags": ["settings"], + "es": "Revertir todo", + "fr-ca": "Annuler tout", + "ru": "Отменить все", + "ja": "すべて元に戻す", + "de": "Alle zurücksetzen" + }, + "protectedTermsAllSaved": { + "source": "All changes saved", + "comment": "Save bar message shown when there are no pending protected term changes", + "tags": ["settings"], + "es": "Todos los cambios guardados", + "fr-ca": "Toutes les modifications ont été enregistrées.", + "ru": "Все изменения сохранены", + "ja": "すべての変更が保存されました", + "de": "Alle Änderungen gespeichert" + }, + "protectedTermsListAriaLabel": { + "source": "Protected terms", + "comment": "Accessible label of the protected terms list", + "tags": ["settings"], + "es": "Términos protegidos", + "fr-ca": "Conditions protégées", + "ru": "Защищенные условия", + "ja": "保護された用語", + "de": "Geschützte Begriffe" + }, + "protectedTermsAddAriaLabel": { + "source": "New protected term", + "comment": "Accessible label of the field used to type a new protected term", + "tags": ["settings"], + "es": "Nuevo término protegido", + "fr-ca": "Nouvelle clause protégée", + "ru": "Новый защищенный срок", + "ja": "新しい保護用語", + "de": "Neuer geschützter Begriff" } } diff --git a/apps/tracker/src/i18n/settings/tracker_meta.json b/apps/tracker/src/i18n/settings/tracker_meta.json index b6d0d446..f8f5bf7c 100644 --- a/apps/tracker/src/i18n/settings/tracker_meta.json +++ b/apps/tracker/src/i18n/settings/tracker_meta.json @@ -61,31 +61,31 @@ }, "protectedTermsHint": { "en": { - "checksum": "7b0f1cfbc415f1a64eaf35a4a5130a50" + "checksum": "968aca8bac2e6f5f8cbe50e8ef58e22e" }, "es": { - "checksum": "e8ae57fe27deade658614c2c09d9f1dd", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "5fb6e5471328ac939a6b26f12c467d4d", + "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", "status": "translated" }, "fr-ca": { - "checksum": "5ba3989fd1f31b74c5eed31e22c8f69a", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "2fd9f7137bf836c3334f258394609cb3", + "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", "status": "translated" }, "ru": { - "checksum": "7b0cbdaa4fff83c47405d97127771c27", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "3b1c5ee967304a4d88255fb08e3b4be8", + "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", "status": "translated" }, "ja": { - "checksum": "ce9dbaf5023d184004dee184413a23bb", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "b62d501f709081baccb4c8a2e9be2791", + "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", "status": "translated" }, "de": { - "checksum": "8084d14e9feaf288c2a91f7ebd1fd80c", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "0c0901590a26aeb05745cbe766d6c09b", + "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", "status": "translated" } }, @@ -208,5 +208,635 @@ "baseChecksum": "1c3e994701dcd061ab663085ba97e538", "status": "translated" } + }, + "protectedTermsCountX": { + "en": { + "checksum": "103d531d99cbbd653aaaf16027aa465a" + }, + "es": { + "checksum": "3c06262053d19f678c456218ef348a20", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + }, + "fr-ca": { + "checksum": "b4cb9608b2be5815c25efc09b17d4636", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + }, + "de": { + "checksum": "ae0ac2bc17916492fd2cdff0f9c85ba0", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + }, + "ja": { + "checksum": "92c1c28e02d335afa605c80d9aa5b144", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + }, + "ru": { + "checksum": "ff9ea924a428bcd8bc91fdb5de3d2528", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + } + }, + "protectedTermsAddButton": { + "en": { + "checksum": "ec211f7c20af43e742bf2570c3cb84f9" + }, + "es": { + "checksum": "4e7a04a766cfe5d932335d492d006872", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + }, + "fr-ca": { + "checksum": "18d925e4d5b1b569288d119420bbcb42", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + }, + "ru": { + "checksum": "9e81b1bf2b8240b4eed3a702faa1e2ec", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + }, + "ja": { + "checksum": "7dc3a57449726219ac9d989688b2b575", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + }, + "de": { + "checksum": "5f4110486d00f1ceafd93d33f82c55a8", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + } + }, + "protectedTermsDuplicateX": { + "en": { + "checksum": "b0a44661c5b4620b8efc3d9e2bb8a127" + }, + "es": { + "checksum": "bb71f5fe098a8ce292242b19feb100f3", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + }, + "fr-ca": { + "checksum": "e1c8aaecf24431b7bccb85b01ae44194", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + }, + "ru": { + "checksum": "28082050a806ab48c3dbbfe5d365483c", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + }, + "ja": { + "checksum": "8a1626df277c858b9714337a79fe4efd", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + }, + "de": { + "checksum": "17f97ed0bb18a8d4dff0597553dab478", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + } + }, + "protectedTermsFilterPlaceholder": { + "en": { + "checksum": "19509c13ac5ba205bf0524f194a3a19a" + }, + "es": { + "checksum": "ed571e31fad73ef9005eca9b6e246dc4", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + }, + "fr-ca": { + "checksum": "0cb31e5a5d706f338ad3f29b3124efb8", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + }, + "ru": { + "checksum": "6194f9e0784d85af2d9f6f55ab8610a1", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + }, + "ja": { + "checksum": "92d14aec500f44d2aa0dced0fed13411", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + }, + "de": { + "checksum": "c56ff48f071f70f608bc0aeceb468df0", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + } + }, + "protectedTermsEmptyTitle": { + "en": { + "checksum": "fb5eddf274a4854bcdde3812d9febb1f" + }, + "es": { + "checksum": "8759be0ad356e93c7ebcc13446c6cdae", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + }, + "fr-ca": { + "checksum": "3c336bb4dba4d1cde3f90e570e44c00e", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + }, + "ru": { + "checksum": "b51a0762c73271de46c6245481a3aa26", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + }, + "ja": { + "checksum": "c1e84163322aed67ec3d5b708960bf79", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + }, + "de": { + "checksum": "3e716b68fdbc63ef690bbaeb560290ee", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + } + }, + "protectedTermsEmptyMessage": { + "en": { + "checksum": "2054727e7c07981cd5cc8b00e0796342" + }, + "es": { + "checksum": "4b8608f156a1eaaf06a65dc153d4bbfb", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + }, + "fr-ca": { + "checksum": "815879e91a4c331c6d39eefcf77a0504", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + }, + "ru": { + "checksum": "8df854d1a83f0cfccd20c98ee8569892", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + }, + "ja": { + "checksum": "c976c3be7077f149ce33edb8a44d3847", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + }, + "de": { + "checksum": "ad324dc85a37832a250683c094417cfa", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + } + }, + "protectedTermsNoMatchesX": { + "en": { + "checksum": "f8d65fdf243d39da9eb4373e321d31a3" + }, + "es": { + "checksum": "c26ce12b2f82c03dffd2779b720085b4", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + }, + "fr-ca": { + "checksum": "013ed834cd45fb019dcb9b9bf666e230", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + }, + "ru": { + "checksum": "ac3135e8742a33fd4d2c6798c5af757c", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + }, + "ja": { + "checksum": "0b41abe5f029cd9003e76fa539297341", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + }, + "de": { + "checksum": "a966c8c0ab5191966a859a36c1c68cd6", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + } + }, + "protectedTermsEditAriaX": { + "en": { + "checksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12" + }, + "es": { + "checksum": "3cba7311c10e25deed91fa1e0bac0f4b", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + }, + "fr-ca": { + "checksum": "0a83a13cd3d77eebdd65f77b813882e8", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + }, + "ru": { + "checksum": "0af635d12f42c25f7ddf2e5f1ef2b2f2", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + }, + "ja": { + "checksum": "af13a67f4913d762de27c4d80553a702", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + }, + "de": { + "checksum": "143124fd3ab37fde8a228351a3c6faf2", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + } + }, + "protectedTermsRemoveAriaX": { + "en": { + "checksum": "5850d7de12e5338b64ff9bfb97b7c6d6" + }, + "es": { + "checksum": "a2f49020ff75536ffc41ef06df9dfb9b", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + }, + "fr-ca": { + "checksum": "798fd87aed9ea72bc42d87bfdc03b604", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + }, + "ru": { + "checksum": "36dd7fafc9d38ced373e4e91520fc9dd", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + }, + "ja": { + "checksum": "f3c75789aed134eb096821606629cc4d", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + }, + "de": { + "checksum": "ca3a7989e6ef9c4c0a6f9a0d17edb47d", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + } + }, + "protectedTermsRestoreAriaX": { + "en": { + "checksum": "5063a8939aed3a15e52f762f3691b3d3" + }, + "es": { + "checksum": "ac3a2d154e36a19adfc007db5cd229be", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + }, + "fr-ca": { + "checksum": "86eef7070c5a2c851e17992716c45e80", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + }, + "ru": { + "checksum": "8305e0b9a50ae18a0265456ecd8330f6", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + }, + "ja": { + "checksum": "ed0b76f69c21db23267623bb4196df75", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + }, + "de": { + "checksum": "1a2436f81b0b527e255274a93e31c0cf", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + } + }, + "protectedTermsRenameAriaX": { + "en": { + "checksum": "c61e91e4b0f0d5536633a0baa32af4a7" + }, + "es": { + "checksum": "d233e171be7c077cf1cef799601e95f0", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + }, + "fr-ca": { + "checksum": "fa9b94b51e4cb73bfa0e9c9ae5cd6eda", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + }, + "ru": { + "checksum": "7ccfe1325fbbe9729ac1924738aaa0cf", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + }, + "ja": { + "checksum": "750290c239359bc07aa4102ab15325f2", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + }, + "de": { + "checksum": "d40721a8b889055a8b5d0b47208e7197", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + } + }, + "protectedTermsUndo": { + "en": { + "checksum": "1cdc076b28f70afac5fcedadf99fa119" + }, + "es": { + "checksum": "b1e81ba81d0718201c2dd96b43366155", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + }, + "fr-ca": { + "checksum": "3ccb172ac335692c04c8eb9546bb487b", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + }, + "ru": { + "checksum": "ecdbdc8b4c68acdb3b84d57d23d884f9", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + }, + "ja": { + "checksum": "db693ad58922866eea5dbe3db55fe1ff", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + }, + "de": { + "checksum": "476e09b415eb690ae4e982070cd8e303", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + } + }, + "protectedTermsBadgeRemoving": { + "en": { + "checksum": "e44b66d27df57c4a36aa02b0f04ba8a2" + }, + "es": { + "checksum": "e5bd69d491631430a225e9fd1250e233", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + }, + "fr-ca": { + "checksum": "d1e49fa8ab5698d3ad1a0fe4aeb14675", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + }, + "ru": { + "checksum": "dd62fe0c29f765b5f064f2fff1daf99f", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + }, + "ja": { + "checksum": "c6577c2e908d63fa62c44381b35e9a16", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + }, + "de": { + "checksum": "513d306cc83aa63d5a3801c0cac5f739", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + } + }, + "protectedTermsBadgeNew": { + "en": { + "checksum": "03c2e7e41ffc181a4e84080b4710e81e" + }, + "es": { + "checksum": "73fa04be34aef055a1c1706edd6daba9", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + }, + "fr-ca": { + "checksum": "9ea17b10f506b881fed906aab0a50f8d", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + }, + "ru": { + "checksum": "97ae6e0bca16b37dbdc5e324d0ecaf60", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + }, + "ja": { + "checksum": "ad4b0bd55882d4f7164630d4158d3c39", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + }, + "de": { + "checksum": "e85a2f1d6de3e3b0e71b1a2e19952659", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + } + }, + "protectedTermsBadgeEdited": { + "en": { + "checksum": "67ed6ddb7d890b23166c1bef771e9451" + }, + "es": { + "checksum": "575dc6f05a827ced18fac29778c50faf", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + }, + "fr-ca": { + "checksum": "3520b26fbb7c5d356a083b69c8db81c8", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + }, + "ru": { + "checksum": "56824532ff3b0dc93c4ea4a7929fc8ba", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + }, + "ja": { + "checksum": "34f36d4263422ad6096cbf4366fc2232", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + }, + "de": { + "checksum": "293530933fdaba02627e1b9b7f8cf4e6", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + } + }, + "protectedTermsWasX": { + "en": { + "checksum": "0bf9c129d85ee90084a2cf2f6bad4f45" + }, + "es": { + "checksum": "58af3e2a685c21b8b99619a43d56b2ac", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + }, + "fr-ca": { + "checksum": "9e7b91f671a0917b206d23ca21bbe1ff", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + }, + "ru": { + "checksum": "37c4b90e4d47f9f731eee6061ef0026b", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + }, + "ja": { + "checksum": "43a1c12a0eba259d440033ebdcd97b62", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + }, + "de": { + "checksum": "8654f7986c9d3957f30862d1edcce16b", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + } + }, + "protectedTermsUnsavedChangesX": { + "en": { + "checksum": "1f02081aa13bcd00e32e274c50ba54dc" + }, + "es": { + "checksum": "b2c67ff6bc9a5d1615804c709a2413f1", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + }, + "fr-ca": { + "checksum": "12e05bdd3eb292629840b36214efae0c", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + }, + "de": { + "checksum": "fb529b436b98914836bdcb1dcc12b8b4", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + }, + "ja": { + "checksum": "5a3336f92d7f7a8acd4a934b249d884a", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + }, + "ru": { + "checksum": "c3f3d18e533f989b7609601b1c138009", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + } + }, + "protectedTermsRevertAll": { + "en": { + "checksum": "1d4f0db2c59dfe8150f1fc62522605d2" + }, + "es": { + "checksum": "1e35e6710190249416bfbb6947ff8f3c", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + }, + "fr-ca": { + "checksum": "475cc08097d9a1b948c3c2e7fcd7ed98", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + }, + "ru": { + "checksum": "401fbf31c252e96097d8dfdb6e0a9270", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + }, + "ja": { + "checksum": "b31a396d0f71bd6b10a2b617d4440ec6", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + }, + "de": { + "checksum": "6ac4668ac57d4c7ad2231c485dbc9863", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + } + }, + "protectedTermsAllSaved": { + "en": { + "checksum": "2d7d860c5dcec1aecdb4a28c6247855b" + }, + "es": { + "checksum": "6343db6e5130179a80b5424a26bed6d6", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + }, + "fr-ca": { + "checksum": "d2134ed60da9e91ac1db46b7371bb85e", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + }, + "ru": { + "checksum": "4c49936fffd41286e57fc4e7bd246d19", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + }, + "ja": { + "checksum": "e66dc56d8d0b80ed807f67b3f52815d9", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + }, + "de": { + "checksum": "c544d24db6b1f8f7d8ea72ea3aed3b1c", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + } + }, + "protectedTermsListAriaLabel": { + "en": { + "checksum": "7379385eb22635589c818baf74aafb16" + }, + "es": { + "checksum": "ef79bfcabae0a7fd47891498cb82dd50", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + }, + "fr-ca": { + "checksum": "a50915aa6a8c65095d8e1bb8590758ca", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + }, + "ru": { + "checksum": "fdc1cb883ff12828b862ea564c1ae69c", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + }, + "ja": { + "checksum": "8d5b245534b279f6cadd6973cdc1b7ca", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + }, + "de": { + "checksum": "2e5a9fe78011711360bba52d9aee8ef2", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + } + }, + "protectedTermsAddAriaLabel": { + "en": { + "checksum": "3c2da865e46ae607bdb569d1563ff1a5" + }, + "es": { + "checksum": "290b36fa9f055ad5db18cc6e71ad36b2", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + }, + "fr-ca": { + "checksum": "2c706e85bbd6309bd2091c4c0f80ddb3", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + }, + "ru": { + "checksum": "96b27e2d8fe3f812b82ecd133d1ce68b", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + }, + "ja": { + "checksum": "239c52d001448c4c6dfc3b11fa381a56", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + }, + "de": { + "checksum": "06d0a24f4105369fa21550295d1f6dc6", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + } } } From 80152e35e0f5b393e7c6fcf804bcecd15d61a00a Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 10:21:44 -0700 Subject: [PATCH 04/13] fix(tracker): describe where protected terms are actually enforced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings hint credited auto-translation with keeping protected terms verbatim. Auto-translation does not read the list at all — the protection it applies is placeholder protection, which swaps ICU placeholders for markers and is unrelated. The list is enforced in export and import instead: export marks each string with the protected terms found in its source so translators and MT services know what to leave alone, and import rejects an incoming translation that altered one. The hint now names both, matching docs/features/protected-terms.md. The Russian translation is hand-corrected: the machine rendered "exports" and "imports" as "export systems" and "import systems", which are not things. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RifJr7xyhZkaTWn4ssV1b4 --- .../src/i18n/settings/resource_entries.json | 14 ++++++------ .../src/i18n/settings/tracker_meta.json | 22 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/tracker/src/i18n/settings/resource_entries.json b/apps/tracker/src/i18n/settings/resource_entries.json index 1c1b6933..ce4e52d9 100644 --- a/apps/tracker/src/i18n/settings/resource_entries.json +++ b/apps/tracker/src/i18n/settings/resource_entries.json @@ -20,14 +20,14 @@ "de": "Globale Schutzbestimmungen" }, "protectedTermsHint": { - "source": "Brand names and jargon kept verbatim by auto-translation across every collection.", - "comment": "Hint text under the global protected terms field in settings", + "source": "Terms that must survive translation unchanged, in every collection. Exports flag them for translators; imports reject a translation that altered one.", + "comment": "Hint under the global protected terms heading in settings. Names where the list is enforced: export marks the terms for translators, import rejects an altered translation.", "tags": ["settings"], - "es": "Las marcas y la jerga se conservan textualmente mediante la traducción automática en todas las colecciones.", - "fr-ca": "Les noms de marques et le jargon sont conservés textuellement par traduction automatique dans toutes les collections.", - "ru": "Названия брендов и терминология сохраняются дословно благодаря автоматическому переводу во всех коллекциях.", - "ja": "ブランド名や専門用語は、すべてのコレクションにおいて自動翻訳によって原文のまま維持されます。", - "de": "Markennamen und Fachbegriffe werden durch automatische Übersetzung in jeder Kollektion wortgetreu beibehalten." + "es": "Términos que deben conservarse sin cambios en todas las colecciones tras la traducción. Las exportaciones los marcan para los traductores; las importaciones rechazan las traducciones que los hayan modificado.", + "fr-ca": "Les termes qui doivent absolument demeurer inchangés lors de la traduction, dans chaque collection, sont signalés aux traducteurs lors des exportations ; les importations rejettent toute traduction qui en aurait modifié un.", + "ru": "Термины, которые должны оставаться неизменными при переводе в любой коллекции. При экспорте они помечаются для переводчиков; при импорте перевод с изменённым термином отклоняется.", + "ja": "翻訳後も変更せずに、すべてのコレクションに必ず含めなければならない用語。エクスポート時には翻訳者向けにこれらの用語がマークされ、インポート時にはこれらの用語が変更された翻訳は拒否される。", + "de": "Begriffe, die in jeder Sammlung unverändert übersetzt werden müssen. Exporte kennzeichnen diese Begriffe für Übersetzer; Importe lehnen Übersetzungen ab, die bestehende Begriffe verändert haben." }, "protectedTermsAddPlaceholder": { "source": "Add protected term…", diff --git a/apps/tracker/src/i18n/settings/tracker_meta.json b/apps/tracker/src/i18n/settings/tracker_meta.json index f8f5bf7c..3e335430 100644 --- a/apps/tracker/src/i18n/settings/tracker_meta.json +++ b/apps/tracker/src/i18n/settings/tracker_meta.json @@ -61,31 +61,31 @@ }, "protectedTermsHint": { "en": { - "checksum": "968aca8bac2e6f5f8cbe50e8ef58e22e" + "checksum": "3f5b59832ab9049570811b872cf06fc4" }, "es": { - "checksum": "5fb6e5471328ac939a6b26f12c467d4d", - "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", + "checksum": "9259c3a862a39abfc888818fce8a5559", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" }, "fr-ca": { - "checksum": "2fd9f7137bf836c3334f258394609cb3", - "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", + "checksum": "6904863d656c2928c310d58874361718", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" }, "ru": { - "checksum": "3b1c5ee967304a4d88255fb08e3b4be8", - "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", + "checksum": "dfddb5ec05872576434860b867fb62a5", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" }, "ja": { - "checksum": "b62d501f709081baccb4c8a2e9be2791", - "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", + "checksum": "a5014608f7b336279db2dabd6496b2e7", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" }, "de": { - "checksum": "0c0901590a26aeb05745cbe766d6c09b", - "baseChecksum": "968aca8bac2e6f5f8cbe50e8ef58e22e", + "checksum": "a243fcd6fcb012d338cdd43a90d06082", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" } }, From 354e47fe29d51b8125c58a9390adb93e5af15453 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 10:21:54 -0700 Subject: [PATCH 05/13] fix(tracker): keep Save reachable in the settings header on narrow screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The save controls moved into the page header, where a single non-wrapping row put German's "Alle zurücksetzen" and "Speichern" 84px past a 390px viewport. `.settings-page` clips its overflow, so the Save button was invisible and unreachable rather than merely off-grid — and a horizontal-overflow check on the document reported clean. The row now wraps and the buttons never shrink; the status pill gives up width instead and truncates its label. A flex child defaults to `min-width: auto`, so the pill needed that cleared on both itself and its span before it would yield. Verified with both controls enabled across en, de, es, fr-ca, ja and ru at 390, 768 and 1440px. Below roughly 397px the app shell itself overflows horizontally, on /collections equally, which is a pre-existing limit and untouched here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RifJr7xyhZkaTWn4ssV1b4 --- apps/tracker/src/app/settings/settings.scss | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/tracker/src/app/settings/settings.scss b/apps/tracker/src/app/settings/settings.scss index 1128cde4..489140a4 100644 --- a/apps/tracker/src/app/settings/settings.scss +++ b/apps/tracker/src/app/settings/settings.scss @@ -537,8 +537,11 @@ .terms-bar { display: flex; align-items: center; + justify-content: flex-end; gap: var(--spacing-2); flex: none; + flex-wrap: wrap; + min-width: 0; } .terms-bar-status { @@ -546,6 +549,7 @@ align-items: center; gap: var(--spacing-1); margin: 0; + min-width: 0; padding: var(--spacing-1) var(--spacing-3); border: 1px solid transparent; border-radius: var(--border-radius-full); @@ -553,6 +557,14 @@ font-size: var(--font-size-sm); font-variant-numeric: tabular-nums; white-space: nowrap; + + // A flex child defaults to min-width: auto, which is what let the pill push + // the Save button off a 320px viewport instead of truncating. + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } transition: color var(--transition-normal), background var(--transition-normal), border-color var(--transition-normal); } @@ -599,6 +611,12 @@ display: flex; align-items: center; gap: var(--spacing-2); + flex: none; + margin-inline-start: auto; + + button { + white-space: nowrap; + } } // ── Short viewports ────────────────────────────────────────────────────────── From e569e3edd65c854dc1a647f06d8949049ce41467 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 10:25:15 -0700 Subject: [PATCH 06/13] feat(tracker): present the protected-terms explanation as an info callout The hint was a loose paragraph between the heading and the file path, reading as overflow from the heading rather than as its own thing. It becomes a bordered, tinted block with an info icon. Built from the existing `--color-info` token, so it inverts with the theme. The text takes its cast from that hue rather than staying grey, which a coloured surface calls for. No accent rule down the side: the tint and the icon already say what kind of block this is. Measured at 8.8:1 light and 11.4:1 dark for the text, 4.0:1 and 7.7:1 for the icon. The short-viewport rule that reclaims these rows below 500px now targets the callout. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RifJr7xyhZkaTWn4ssV1b4 --- apps/tracker/src/app/settings/settings.html | 5 +++- apps/tracker/src/app/settings/settings.scss | 33 +++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/tracker/src/app/settings/settings.html b/apps/tracker/src/app/settings/settings.html index a844ba34..c59a033a 100644 --- a/apps/tracker/src/app/settings/settings.html +++ b/apps/tracker/src/app/settings/settings.html @@ -52,7 +52,10 @@

{{ TOKENS.SETTINGS.PROTECTEDTERMSCOUNTX | transloco : { count: termCount() } }} -

{{ TOKENS.SETTINGS.PROTECTEDTERMSHINT | transloco }}

+
+ info +

{{ TOKENS.SETTINGS.PROTECTEDTERMSHINT | transloco }}

+
@if (protectedTermsFilePath(); as filePath) {

diff --git a/apps/tracker/src/app/settings/settings.scss b/apps/tracker/src/app/settings/settings.scss index 489140a4..bc013cba 100644 --- a/apps/tracker/src/app/settings/settings.scss +++ b/apps/tracker/src/app/settings/settings.scss @@ -103,10 +103,33 @@ white-space: nowrap; } -.terms-hint { - margin: var(--spacing-2) 0 0; - max-width: 62ch; - color: var(--color-text-secondary); +// Information callout. One flat panel, no accent rule down the side: the tint +// and the icon already say what kind of block this is. +.terms-note { + display: flex; + align-items: flex-start; + gap: var(--spacing-2); + margin: var(--spacing-3) 0 0; + padding: var(--spacing-3) var(--spacing-4); + border: 1px solid color-mix(in srgb, var(--color-info) 28%, transparent); + border-radius: var(--border-radius-lg); + background: color-mix(in srgb, var(--color-info) 10%, transparent); +} + +.terms-note-icon { + flex: none; + // Nudged onto the first line's optical centre rather than its box top. + margin-top: 1px; + font-size: 18px; + width: 18px; + height: 18px; + color: color-mix(in srgb, var(--color-info) 75%, var(--color-text-primary)); +} + +.terms-note-text { + margin: 0; + // Secondary text on a tinted surface takes its cast from that hue, not grey. + color: color-mix(in srgb, var(--color-info) 20%, var(--color-text-primary)); font-size: var(--font-size-sm); line-height: var(--line-height-normal); } @@ -630,7 +653,7 @@ padding-block: var(--spacing-4); } - .terms-hint, + .terms-note, .terms-file { display: none; } From 5e77b006f33810370c3b9b5c6ed91032b6f2b4be Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 10:39:33 -0700 Subject: [PATCH 07/13] fix(tracker): rebuild the app header control group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three app controls read as three floating items rather than one group, and two of them wore each other's icon. - Gaps were 16px / 8px / 16px between peers. They are now one 4px gap set once on a `.toolbar-actions` container, with the per-button margins removed. - The gear opened the theme menu and a sliders icon went to Settings. The gear now goes to Settings, which is what a gear means everywhere else. - The theme switcher wears the theme you are looking at — light_mode or dark_mode — instead of a fixed glyph. On `system` it shows the resolved theme; which mode is selected stays legible through the label and the menu check. - The locale picker was a mat-icon-button holding an icon *and* a text label, so the globe was clipped to a sliver by the button's fixed square. It is a text button sized to its content, matched to the icon buttons' 40px height. - Order is language, appearance, settings, with Settings as the right anchor and the only route among the three. DOM order follows, so focus order does too. The label resolves through the transloco pipe rather than a computed calling `translate`: a computed caches whatever it reads first, and first read happens before the translation file loads, so it served the raw key forever after. Not fixed here, and pre-existing: on /browser the app shell renders wider than the viewport below about 1050px — `.app-header` measures 983px inside a 900px `.app-container` — because that route's content forces the grid column open and drags the header with it. It pushes Settings off the right edge. The control group itself is byte-identical in width to before (172px from the divider), so this is a shell issue, not a header one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RifJr7xyhZkaTWn4ssV1b4 --- apps/tracker/src/app/header/app-header.html | 44 ++++++++-------- apps/tracker/src/app/header/app-header.scss | 16 ++++-- apps/tracker/src/app/header/app-header.ts | 22 ++++++++ .../header/locale-picker/locale-picker.html | 2 +- .../header/locale-picker/locale-picker.scss | 21 +++++++- .../src/i18n/theme/resource_entries.json | 14 +++++- apps/tracker/src/i18n/theme/tracker_meta.json | 50 +++++++++++++++---- 7 files changed, 130 insertions(+), 39 deletions(-) diff --git a/apps/tracker/src/app/header/app-header.html b/apps/tracker/src/app/header/app-header.html index e795a35c..e72130ad 100644 --- a/apps/tracker/src/app/header/app-header.html +++ b/apps/tracker/src/app/header/app-header.html @@ -39,30 +39,32 @@

LingoTracker

} - + - - - +
+ - - + + + +
+ + } + + } + + - @@ -37,7 +72,7 @@

{{ TOKENS.COLLECTIONS.TITLE | transloco }}

{{ store.error() }}

+ +} + -@if (store.hasCollections() && !store.isLoading()) { +@if (cards().length > 0 && !store.isLoading()) {
- @for (item of store.collectionEntriesWithLocales(); track item.name; let i = $index) { -
- -
-
-
-
- folder -
-

{{ item.name }}

- @if (item.config.readOnly) { - - lock - {{ TOKENS.COLLECTIONS.CARD.READONLYBADGE | transloco }} - - } -
-
- - - - - - -
+
+ -
-

- subdirectory_arrow_right - {{ item.config.translationsFolder }} + +

+

{{ card.name }}

+

+ {{ card.translationsFolder }}

-
- -
- -
+ + @if (card.readOnly) { +

+ + {{ TOKENS.COLLECTIONS.CARD.READONLYTOOLTIP | transloco }} +

+ } + + + }
} diff --git a/apps/tracker/src/app/collections/collections-manager.scss b/apps/tracker/src/app/collections/collections-manager.scss index fa586508..a08652ed 100644 --- a/apps/tracker/src/app/collections/collections-manager.scss +++ b/apps/tracker/src/app/collections/collections-manager.scss @@ -9,14 +9,17 @@ padding: var(--spacing-8) var(--spacing-6); position: relative; min-height: 100%; -} + // Card surface sits one step above the page (which uses --color-background-subtle). + --card-surface: var(--color-background); + --card-surface-hover: color-mix(in srgb, var(--color-primary) 3%, var(--color-background)); +} // --------------------------------------------------------------------------- // Page header // --------------------------------------------------------------------------- .page-header { - margin-bottom: var(--spacing-8); + margin-bottom: var(--spacing-6); position: relative; z-index: 1; } @@ -33,18 +36,33 @@ display: flex; flex-direction: column; gap: var(--spacing-1); + min-width: 0; } .header-title { font-family: var(--font-family-body); color: var(--color-text-primary); - font-size: clamp(2.5rem, 6vw, 4rem); + font-size: clamp(1.75rem, 2.6vw, 2.25rem); font-weight: var(--font-weight-bold); - letter-spacing: -0.01em; - line-height: var(--line-height-display); + letter-spacing: -0.02em; + line-height: var(--line-height-tight); margin: 0; } +.header-count { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; + margin: 0; +} + +.header-tools { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--spacing-3); +} + .header-rule { background: linear-gradient( 90deg, @@ -55,17 +73,84 @@ transparent 100% ); border-radius: var(--border-radius-full); - height: 3px; + height: 2px; margin-top: var(--spacing-4); width: 100%; - opacity: 0.6; + opacity: 0.7; +} + +// --------------------------------------------------------------------------- +// Filter field +// --------------------------------------------------------------------------- +.filter-field { + align-items: center; + background: var(--card-surface); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + display: flex; + gap: var(--spacing-2); + min-width: 0; + padding: 0 var(--spacing-1) 0 var(--spacing-3); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + width: 260px; + + &:focus-within { + border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 14%, transparent); + } +} + +.filter-icon { + color: var(--color-text-secondary); + flex: none; + font-size: 18px; + height: 18px; + width: 18px; +} + +.filter-input { + background: none; + border: 0; + color: var(--color-text-primary); + flex: 1 1 auto; + font-family: inherit; + font-size: var(--font-size-sm); + min-width: 0; + padding: var(--spacing-2) 0; + + &::placeholder { + color: var(--color-text-secondary); + } + + &:focus { + outline: none; + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.filter-clear { + flex: none; + height: 32px !important; + line-height: 32px !important; + width: 32px !important; + + .mat-icon { + color: var(--color-text-secondary); + font-size: 18px; + height: 18px; + width: 18px; + } } .add-btn { - border-radius: var(--border-radius-xl) !important; + border-radius: var(--border-radius-lg) !important; + flex: none; font-weight: var(--font-weight-semibold) !important; - letter-spacing: 0.02em; - padding: var(--spacing-2) var(--spacing-6) !important; + letter-spacing: 0.01em; + padding: var(--spacing-2) var(--spacing-5) !important; transition: box-shadow var(--transition-normal), transform var(--transition-normal) !important; mat-icon, @@ -75,7 +160,7 @@ } &:hover:not([disabled]) { - box-shadow: 0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent) !important; + box-shadow: 0 6px 16px -4px color-mix(in srgb, var(--color-primary) 45%, transparent) !important; transform: translateY(-1px); } } @@ -229,268 +314,326 @@ .empty-message { color: var(--color-text-secondary); font-family: var(--font-family-body); - font-size: var(--font-size-2xl); + font-size: var(--font-size-xl); margin: 0; - max-width: 400px; + max-width: 42ch; text-align: center; } // --------------------------------------------------------------------------- -// Collections grid +// No matches state // --------------------------------------------------------------------------- -.collections-grid { - display: grid; - gap: var(--spacing-6); +.no-matches { + align-items: center; + display: flex; + flex-direction: column; + gap: var(--spacing-3); + justify-content: center; + min-height: 320px; position: relative; z-index: 1; +} - @media (max-width: 768px) { - grid-template-columns: 1fr; - } +.no-matches-icon { + color: var(--color-text-secondary); + font-size: 32px; + height: 32px; + opacity: 0.7; + width: 32px; +} - @media (min-width: 769px) and (max-width: 1200px) { - grid-template-columns: repeat(2, 1fr); - } +.no-matches-message { + color: var(--color-text-secondary); + font-size: var(--font-size-base); + margin: 0; + max-width: 42ch; + overflow-wrap: anywhere; + text-align: center; +} - @media (min-width: 1201px) { - grid-template-columns: repeat(3, 1fr); - } +// --------------------------------------------------------------------------- +// Collections grid +// --------------------------------------------------------------------------- +.collections-grid { + align-items: start; + display: grid; + gap: var(--spacing-5); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); + position: relative; + z-index: 1; } // --------------------------------------------------------------------------- -// Collection card — watercolor style +// Collection card // --------------------------------------------------------------------------- .collection-card { - - animation: card-enter 0.4s cubic-bezier(0.22, 1, 0.36, 1) backwards; - animation-delay: calc(var(--card-index, 0) * 60ms); + animation: card-enter 0.45s cubic-bezier(0.16, 1, 0.3, 1) backwards; + animation-delay: calc(var(--card-index, 0) * 50ms); + background: var(--card-surface); + border: 1px solid var(--color-border); border-radius: var(--border-radius-xl); + box-shadow: var(--shadow-sm); cursor: pointer; - min-height: 160px; + display: flex; + flex-direction: column; outline-offset: 3px; + padding: var(--spacing-5); position: relative; - transition: transform var(--transition-normal), box-shadow var(--transition-normal); + transition: + transform var(--transition-normal), + box-shadow var(--transition-normal), + border-color var(--transition-normal), + background var(--transition-normal); &:hover { - box-shadow: var(--shadow-lg); - transform: translateY(-4px); - - .card-accent { - opacity: 1; - } + background: var(--card-surface-hover); + border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border)); + box-shadow: var(--shadow-md); + transform: translateY(-2px); .card-arrow { + color: var(--color-primary); opacity: 1; transform: translateX(0); } .card-icon-wrap { - background: var(--color-primary); - border-color: transparent; + background: color-mix(in srgb, var(--color-primary) 20%, transparent); + border-color: color-mix(in srgb, var(--color-primary) 45%, transparent); + } + } + + &--readonly .card-icon-wrap { + background: color-mix(in srgb, var(--color-warning) 16%, transparent); + border-color: color-mix(in srgb, var(--color-warning) 35%, transparent); - mat-icon { - color: var(--color-text-inverse); - } + mat-icon { + color: color-mix(in srgb, var(--color-warning) 72%, var(--color-text-primary)); } } - &:focus-visible { - outline: 2px solid var(--color-primary); + &--readonly:hover .card-icon-wrap { + background: color-mix(in srgb, var(--color-warning) 26%, transparent); + border-color: color-mix(in srgb, var(--color-warning) 55%, transparent); } } @keyframes card-enter { from { opacity: 0; - transform: translateY(16px); - } -} - -.card-accent { - background: var(--color-primary); - border-radius: var(--border-radius-xl); - inset: 0; - opacity: 0; - padding: 2px; - position: absolute; - transition: opacity var(--transition-normal); - z-index: 0; - - // Inner mask to create border-only gradient effect - &::after { - background: var(--color-background-subtle); - border-radius: calc(var(--border-radius-xl) - 2px); - content: ''; - inset: 2px; - position: absolute; - } -} - -.card-inner { - backdrop-filter: blur(12px); - background: color-mix(in srgb, var(--color-background) 88%, transparent); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-xl); - display: flex; - flex-direction: column; - height: 100%; - padding: var(--spacing-5); - position: relative; - transition: border-color var(--transition-normal), background var(--transition-normal); - z-index: 1; - overflow: hidden; - - .collection-card:hover & { - border-color: color-mix(in srgb, var(--color-primary) 30%, var(--color-border)); + transform: translateY(14px); } } .card-header { - align-items: flex-start; - display: flex; - gap: var(--spacing-1); - justify-content: space-between; - margin-bottom: var(--spacing-3); - position: relative; - z-index: 1; -} - -.card-title { - align-items: center; - display: flex; - flex: 1; + align-items: start; + display: grid; gap: var(--spacing-3); - min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; } .card-icon-wrap { align-items: center; background: color-mix(in srgb, var(--color-primary) 12%, transparent); - border: 1.5px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent); border-radius: var(--border-radius-lg); display: flex; flex-shrink: 0; - height: 40px; + height: 38px; justify-content: center; transition: background var(--transition-normal), border-color var(--transition-normal); - width: 40px; + width: 38px; mat-icon { color: var(--color-primary); - font-size: 20px; - height: 20px; - transition: color var(--transition-normal); - width: 20px; + font-size: 19px; + height: 19px; + width: 19px; } } +.card-heading { + min-width: 0; + padding-top: 1px; +} + .collection-name { color: var(--color-text-primary); - flex: 1; font-family: var(--font-family-body); - font-size: clamp(1.25rem, 2vw, 1.5rem); + font-size: var(--font-size-lg); font-weight: var(--font-weight-bold); letter-spacing: -0.01em; line-height: var(--line-height-tight); margin: 0; - word-break: break-word; - position: relative; - z-index: 1; + overflow-wrap: anywhere; +} + +// Two lines reserved whether the path needs them or not, so cards in a row stay flush. +.folder-path { + --path-line-height: 1.45; + + color: var(--color-text-secondary); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: var(--path-line-height); + margin: var(--spacing-1) 0 0; + min-height: calc(2 * var(--path-line-height) * var(--font-size-xs)); + overflow: hidden; + overflow-wrap: anywhere; } .card-actions { flex-shrink: 0; - margin: calc(-1 * var(--spacing-1)) calc(-1 * var(--spacing-2)) 0 0; - position: relative; - z-index: 1; + margin: calc(-1 * var(--spacing-2)) calc(-1 * var(--spacing-2)) 0 0; button { - color: var(--color-text-tertiary) !important; + color: var(--color-text-secondary) !important; transition: color var(--transition-fast) !important; &:hover { - color: var(--color-text-secondary) !important; + color: var(--color-text-primary) !important; } } } -.card-content { +.readonly-note { + align-items: center; + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + border-radius: var(--border-radius-md); + color: color-mix(in srgb, var(--color-warning) 40%, var(--color-text-primary)); display: flex; - flex: 1; - flex-direction: column; - gap: var(--spacing-3); - position: relative; - z-index: 1; + font-size: var(--font-size-xs); + gap: var(--spacing-2); + line-height: var(--line-height-snug); + margin: var(--spacing-4) 0 0; + padding: var(--spacing-2) var(--spacing-3); } -.folder-path { +.readonly-note-icon { + flex: none; + font-size: 14px; + height: 14px; + width: 14px; +} + +.card-footer { align-items: center; - color: var(--color-text-tertiary); + border-top: 1px solid var(--color-border-subtle); display: flex; - font-family: var(--font-family-mono); - font-size: var(--font-size-sm); + gap: var(--spacing-3); + justify-content: space-between; + margin-top: var(--spacing-4); + padding-top: var(--spacing-3); +} + +.locale-chips { + display: flex; + flex-wrap: wrap; + flex-wrap: nowrap; gap: var(--spacing-1); + list-style: none; margin: 0; - padding-top: var(--spacing-2); - word-break: break-all; - - .path-icon { - color: var(--color-text-tertiary); - flex-shrink: 0; - font-size: 14px; - height: 14px; - opacity: 0.6; - width: 14px; + min-width: 0; + padding: 0; +} + +.locale-chip { + align-items: baseline; + background: var(--color-background-muted); + border: 1px solid transparent; + border-radius: var(--border-radius-full); + color: var(--color-text-secondary); + display: inline-flex; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + gap: var(--spacing-1); + line-height: 1.5; + padding: 1px var(--spacing-2); + + &--base { + background: color-mix(in srgb, var(--color-secondary) 16%, transparent); + border-color: color-mix(in srgb, var(--color-secondary) 35%, transparent); + color: color-mix(in srgb, var(--color-secondary) 45%, var(--color-text-primary)); + } + + &--more { + background: none; + border-color: var(--color-border-strong); + border-style: dashed; + cursor: help; } } -.card-locales { - margin-top: auto; - position: relative; - z-index: 1; +.locale-chip-tag { + font-family: var(--font-family-body); + font-size: 0.625rem; + font-weight: var(--font-weight-medium); + letter-spacing: 0.04em; + opacity: 0.85; + text-transform: uppercase; } .card-arrow { - align-items: center; - bottom: var(--spacing-4); - display: flex; - opacity: 0; - position: absolute; - right: var(--spacing-4); - transform: translateX(-8px); - transition: opacity var(--transition-normal), transform var(--transition-normal); - z-index: 2; + color: var(--color-text-secondary); + flex: none; + font-size: 18px; + height: 18px; + opacity: 0.75; + transform: translateX(-4px); + transition: + color var(--transition-normal), + opacity var(--transition-normal), + transform var(--transition-normal); + width: 18px; +} - mat-icon { - color: var(--color-primary); - font-size: 18px; - height: 18px; - width: 18px; +// --------------------------------------------------------------------------- +// Narrow screens +// --------------------------------------------------------------------------- +@media (max-width: 600px) { + :host { + padding: var(--spacing-6) var(--spacing-4); + } + + .header-content { + align-items: stretch; + flex-direction: column; + } + + .header-tools { + flex-wrap: nowrap; + } + + .filter-field { + width: auto; + flex: 1 1 auto; + } + + .collection-card { + padding: var(--spacing-4); } } // --------------------------------------------------------------------------- // Dark theme adjustments // --------------------------------------------------------------------------- -:host-context([data-theme='dark']) { - .card-inner { - background: color-mix(in srgb, var(--color-background) 70%, transparent); - } +@mixin collections-dark { + --card-surface: color-mix(in srgb, var(--color-text-primary) 5%, var(--color-background-subtle)); + --card-surface-hover: color-mix(in srgb, var(--color-primary) 8%, var(--color-background-subtle)); +} - .card-accent::after { - background: var(--color-background); - } +:host-context([data-theme='dark']) { + @include collections-dark; } @media (prefers-color-scheme: dark) { :host-context(:root:not([data-theme='light']):not([data-theme='dark'])) { - .card-inner { - background: color-mix(in srgb, var(--color-background) 70%, transparent); - } - - .card-accent::after { - background: var(--color-background); - } + @include collections-dark; } } @@ -499,28 +642,16 @@ // --------------------------------------------------------------------------- @media (prefers-reduced-motion: reduce) { .ring { animation: none; } - .collection-card { animation: none; } -} -// --------------------------------------------------------------------------- -// Read-only badge -// --------------------------------------------------------------------------- -.readonly-badge { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: var(--spacing-2); - padding: 2px 8px; - border-radius: var(--border-radius-sm, 4px); - background-color: color-mix(in srgb, var(--color-warn, #b26a00) 12%, transparent); - color: var(--color-warn, #b26a00); - font-size: var(--font-size-xs, 0.75rem); - font-weight: var(--font-weight-medium); - white-space: nowrap; + .collection-card { + animation: none; + + &:hover { + transform: none; + } + } - .readonly-badge-icon { - font-size: 14px; - width: 14px; - height: 14px; + .card-arrow { + transform: none; } } diff --git a/apps/tracker/src/app/collections/collections-manager.spec.ts b/apps/tracker/src/app/collections/collections-manager.spec.ts new file mode 100644 index 00000000..3a3bc038 --- /dev/null +++ b/apps/tracker/src/app/collections/collections-manager.spec.ts @@ -0,0 +1,125 @@ +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; +import { of } from 'rxjs'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { LingoTrackerConfigDto } from '@simoncodes-ca/data-transfer'; +import { CollectionsManager } from './collections-manager'; +import { CollectionsStore } from './store/collections.store'; +import { CollectionsApiService } from './services/collections-api.service'; +import { getTranslocoTestingModule } from '../../testing/transloco-testing.module'; + +const config: LingoTrackerConfigDto = { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr-ca'], + collections: { + zulu: { translationsFolder: 'sample/zulu', locales: ['en', 'fr-ca'] }, + alpha: { + translationsFolder: 'sample/alpha', + locales: ['fr-ca', 'es', 'en', 'de', 'ja', 'ru'], + }, + Bravo: { translationsFolder: 'vendor/bravo', locales: ['de', 'ja'], baseLocale: 'de', readOnly: true }, + }, +}; + +const api = { + getConfig: vi.fn(), + updateConfig: vi.fn(), + createCollection: vi.fn(), + updateCollection: vi.fn(), + deleteCollection: vi.fn(), +}; + +describe('CollectionsManager', () => { + let fixture: ComponentFixture; + let component: CollectionsManager; + let store: InstanceType; + + beforeEach(async () => { + vi.resetAllMocks(); + api.getConfig.mockReturnValue(of(config)); + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [CollectionsManager, NoopAnimationsModule, getTranslocoTestingModule()], + providers: [ + CollectionsStore, + { provide: CollectionsApiService, useValue: api }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: Router, useValue: { navigate: vi.fn() } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CollectionsManager); + component = fixture.componentInstance; + store = TestBed.inject(CollectionsStore); + store.loadCollections(); + fixture.detectChanges(); + }); + + it('sorts collections by name, ignoring case', () => { + expect(component.cards().map((c) => c.name)).toEqual(['alpha', 'Bravo', 'zulu']); + }); + + it('lists the base locale first, whether it comes from the collection or the global config', () => { + const alpha = component.cards().find((c) => c.name === 'alpha'); + const bravo = component.cards().find((c) => c.name === 'Bravo'); + + expect(alpha?.baseLocale).toBe('en'); + expect(alpha?.visibleLocales[0]).toBe('en'); + expect(bravo?.baseLocale).toBe('de'); + expect(bravo?.visibleLocales[0]).toBe('de'); + }); + + it('caps locale chips at four, overflow chip included, so the chip row never wraps', () => { + const alpha = component.cards().find((c) => c.name === 'alpha'); + + expect(alpha?.visibleLocales).toEqual(['en', 'fr-ca', 'es']); + expect(alpha?.overflowLocales).toEqual(['de', 'ja', 'ru']); + }); + + it('shows every locale when they all fit', () => { + const zulu = component.cards().find((c) => c.name === 'zulu'); + + expect(zulu?.visibleLocales).toEqual(['en', 'fr-ca']); + expect(zulu?.overflowLocales).toEqual([]); + }); + + it('marks read-only collections', () => { + expect(component.cards().find((c) => c.name === 'Bravo')?.readOnly).toBe(true); + expect(component.cards().find((c) => c.name === 'zulu')?.readOnly).toBe(false); + }); + + it('filters on name and on translations folder', () => { + component.filter.set('BRAV'); + expect(component.cards().map((c) => c.name)).toEqual(['Bravo']); + + component.filter.set('sample/'); + expect(component.cards().map((c) => c.name)).toEqual(['alpha', 'zulu']); + }); + + it('reports no matches only while a filter excludes everything', () => { + expect(component.hasNoMatches()).toBe(false); + + component.filter.set('nothing-matches-this'); + expect(component.hasNoMatches()).toBe(true); + + component.clearFilter(); + expect(component.filter()).toBe(''); + expect(component.hasNoMatches()).toBe(false); + }); + + it('hides the filter until the list outgrows a handful of collections', () => { + expect(component.showFilter()).toBe(false); + }); + + it('navigates to the browser with the collection name encoded', () => { + const router = TestBed.inject(Router); + component.navigateToBrowser('a b'); + + expect(router.navigate).toHaveBeenCalledWith(['/browser', 'a%20b']); + }); +}); diff --git a/apps/tracker/src/app/collections/collections-manager.ts b/apps/tracker/src/app/collections/collections-manager.ts index 295b4a23..f57e0d07 100644 --- a/apps/tracker/src/app/collections/collections-manager.ts +++ b/apps/tracker/src/app/collections/collections-manager.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; @@ -10,17 +10,36 @@ import { Router } from '@angular/router'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; import { CollectionsStore } from './store/collections.store'; import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; -import { TagList } from '../shared/tag-list/tag-list.component'; import { NotificationService } from '../shared/notification'; +/** + * Total locale chips a card shows, overflow chip included. Capped so every card keeps a + * single chip row and rows of cards stay flush with each other. + */ +const MAX_LOCALE_CHIPS = 4; + +/** Below this many collections the name filter is more clutter than help. */ +const FILTER_THRESHOLD = 6; + +/** A collection prepared for display: chips resolved, overflow already split off. */ +export interface CollectionCardView { + readonly name: string; + readonly translationsFolder: string; + readonly readOnly: boolean; + readonly baseLocale: string | undefined; + readonly visibleLocales: readonly string[]; + readonly overflowLocales: readonly string[]; +} + /** * Collections Manager component for viewing and managing translation collections. * * Features: - * - Displays all collections in a responsive grid + * - Displays all collections in a responsive grid, sorted by name + * - Filters by name or translations folder once the list grows past a handful * - Create, edit, and delete collections * - Navigate to translation browser for each collection - * - Loading and empty states + * - Loading, empty, and no-matches states * - Success/error notifications via snackbar */ @Component({ @@ -36,7 +55,6 @@ import { NotificationService } from '../shared/notification'; MatMenuModule, MatTooltipModule, TranslocoModule, - TagList, ], templateUrl: './collections-manager.html', styleUrl: './collections-manager.scss', @@ -51,6 +69,57 @@ export class CollectionsManager { readonly TOKENS = TRACKER_TOKENS; + /** Current text typed into the name/folder filter. */ + readonly filter = signal(''); + + /** Collections sorted by name and prepared for the card template. */ + readonly cards = computed(() => { + const query = this.filter().trim().toLowerCase(); + + return this.store + .collectionEntriesWithLocales() + .filter( + (item) => + query.length === 0 || + item.name.toLowerCase().includes(query) || + item.config.translationsFolder.toLowerCase().includes(query), + ) + .map((item) => this.#toCardView(item)) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + }); + + readonly showFilter = computed(() => this.store.collectionEntriesWithLocales().length > FILTER_THRESHOLD); + readonly isFiltering = computed(() => this.filter().trim().length > 0); + readonly hasNoMatches = computed(() => this.store.hasCollections() && this.cards().length === 0); + + /** + * Orders a collection's locales with the base locale first, then splits off the tail beyond + * MAX_LOCALE_CHIPS so the chip row never wraps and cards in a row share a height. + */ + #toCardView(item: { + name: string; + config: { translationsFolder: string; readOnly?: boolean }; + locales: string[] | undefined; + baseLocale: string | undefined; + }): CollectionCardView { + const locales = item.locales ?? []; + const base = item.baseLocale; + const ordered = base && locales.includes(base) ? [base, ...locales.filter((l) => l !== base)] : [...locales]; + + return { + name: item.name, + translationsFolder: item.config.translationsFolder, + readOnly: item.config.readOnly === true, + baseLocale: base, + visibleLocales: ordered.length > MAX_LOCALE_CHIPS ? ordered.slice(0, MAX_LOCALE_CHIPS - 1) : ordered, + overflowLocales: ordered.length > MAX_LOCALE_CHIPS ? ordered.slice(MAX_LOCALE_CHIPS - 1) : [], + }; + } + + clearFilter(): void { + this.filter.set(''); + } + /** * Opens the create collection dialog. */ diff --git a/apps/tracker/src/i18n/collections/card/resource_entries.json b/apps/tracker/src/i18n/collections/card/resource_entries.json index fe701155..ede53c7f 100644 --- a/apps/tracker/src/i18n/collections/card/resource_entries.json +++ b/apps/tracker/src/i18n/collections/card/resource_entries.json @@ -43,10 +43,70 @@ "source": "This collection is read-only. Its resources can't be modified.", "comment": "Tooltip on the read-only badge on a collection card.", "tags": ["collections"], - "es": "This collection is read-only. Its resources can't be modified.", - "fr-ca": "This collection is read-only. Its resources can't be modified.", - "ru": "This collection is read-only. Its resources can't be modified.", - "ja": "This collection is read-only. Its resources can't be modified.", - "de": "This collection is read-only. Its resources can't be modified." + "es": "Esta colección es de solo lectura. Sus recursos no se pueden modificar.", + "fr-ca": "Cette collection est en lecture seule. Ses ressources ne peuvent pas être modifiées.", + "ru": "Эта коллекция доступна только для чтения. Её ресурсы нельзя изменить.", + "ja": "このコレクションは読み取り専用です。リソースを変更することはできません。", + "de": "Diese Sammlung ist schreibgeschützt. Ihre Ressourcen können nicht geändert werden." + }, + "baseLocaleLabel": { + "source": "base", + "comment": "Short marker on the locale chip that is the collection's base (source) locale", + "tags": ["collections"], + "es": "base", + "fr-ca": "base", + "ru": "база", + "ja": "基準", + "de": "Basis" + }, + "baseLocaleTooltipX": { + "source": "{locale} is the base locale every translation is compared against", + "comment": "Tooltip on the base-locale chip of a collection card", + "tags": ["collections"], + "es": "{locale} es el idioma base con el que se compara cada traducción", + "fr-ca": "{locale} est la langue de base à laquelle chaque traduction est comparée", + "ru": "{locale} — базовая локаль, с которой сравнивается каждый перевод", + "ja": "{locale} はすべての翻訳の比較元となるベースロケールです", + "de": "{locale} ist die Basissprache, mit der jede Übersetzung verglichen wird" + }, + "moreLocalesX": { + "source": "+{count}", + "comment": "Overflow chip showing how many further locales a collection has beyond those listed", + "tags": ["collections"], + "es": "+{count}", + "fr-ca": "+{count}", + "ru": "+{count}", + "ja": "+{count}", + "de": "+{count}" + }, + "moreLocalesTooltip": { + "source": "Also configured", + "comment": "Tooltip heading on the locale overflow chip, followed by the remaining locale codes", + "tags": ["collections"], + "es": "También configurados", + "fr-ca": "Également configurées", + "ru": "Также настроены", + "ja": "その他の設定済みロケール", + "de": "Ebenfalls konfiguriert" + }, + "openAriaLabelX": { + "source": "Open collection {name}", + "comment": "Accessible label for the clickable collection card", + "tags": ["collections"], + "es": "Abrir la colección {name}", + "fr-ca": "Ouvrir la collection {name}", + "ru": "Открыть коллекцию {name}", + "ja": "コレクション {name} を開く", + "de": "Sammlung {name} öffnen" + }, + "localesAriaLabel": { + "source": "Locales", + "comment": "Accessible label for the row of locale chips on a collection card", + "tags": ["collections"], + "es": "Idiomas", + "fr-ca": "Langues", + "ru": "Локали", + "ja": "ロケール", + "de": "Regionen" } } diff --git a/apps/tracker/src/i18n/collections/card/tracker_meta.json b/apps/tracker/src/i18n/collections/card/tracker_meta.json index 478ddc5f..5e5558eb 100644 --- a/apps/tracker/src/i18n/collections/card/tracker_meta.json +++ b/apps/tracker/src/i18n/collections/card/tracker_meta.json @@ -124,29 +124,209 @@ "checksum": "ee180bd986e2dab87c25b037339bfd5c" }, "es": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", + "checksum": "9ab3e7da32a3797f94a03f07ad416964", "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "status": "translated" }, "fr-ca": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", + "checksum": "449b54dbb579009b7563d01c9072bf71", "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", + "checksum": "6dae80a1f2af0ac523f354e974162140", "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "status": "translated" }, "ja": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", + "checksum": "fde6e356574e6b13978a8b8d0334b64c", "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "status": "translated" }, "de": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", + "checksum": "212c3bf554cdac7de3579d2e2fe9f728", "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "status": "translated" + } + }, + "baseLocaleLabel": { + "en": { + "checksum": "593616de15330c0fb2d55e55410bf994" + }, + "es": { + "checksum": "593616de15330c0fb2d55e55410bf994", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" + }, + "fr-ca": { + "checksum": "593616de15330c0fb2d55e55410bf994", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" + }, + "ru": { + "checksum": "ea94cac2e669680d3816e6d3e3efbf48", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" + }, + "ja": { + "checksum": "7f0ee9f183a641021fbcc0cb7720dafa", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" + }, + "de": { + "checksum": "9bc05d0fdf5b16984c37b23709d0e7b4", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" + } + }, + "baseLocaleTooltipX": { + "en": { + "checksum": "aa50f117b9a413b85ee6b70fd4aab937" + }, + "es": { + "checksum": "d2e6cc6f6c5df4e3d078b350d801ea99", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + }, + "fr-ca": { + "checksum": "30e4bca1044b7a46e1b73554dcf20a1a", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + }, + "ru": { + "checksum": "bed874d8caf3cd092038db4dea3d7243", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + }, + "ja": { + "checksum": "6c4a1bf8221361adc29ffeaa7d167096", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + }, + "de": { + "checksum": "6092ee063d77ea1f277f5a9d6ed3ae59", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + } + }, + "moreLocalesX": { + "en": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b" + }, + "es": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + }, + "fr-ca": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + }, + "ru": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + }, + "ja": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + }, + "de": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + } + }, + "moreLocalesTooltip": { + "en": { + "checksum": "0cb41eec7fc3fe0478756010f943782a" + }, + "es": { + "checksum": "f29f8e3bd39a757035ca0b7d037e1cbf", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + }, + "fr-ca": { + "checksum": "b2e8149e0175aabd4c1fd3bf9e053746", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + }, + "ru": { + "checksum": "1748322463dc903c3591ac104a5f8ce6", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + }, + "ja": { + "checksum": "e772efd6baa8c3fda0288fc0d2443fd6", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + }, + "de": { + "checksum": "516331ec9e6c72e1fbd2a5be1cf1327f", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + } + }, + "openAriaLabelX": { + "en": { + "checksum": "136ea657741ea6cf7762371a4527a123" + }, + "es": { + "checksum": "daaefd02d760a6ad78126d15ab74bd71", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + }, + "fr-ca": { + "checksum": "23ccdd0d1adffbbe18a2aa8143c1660b", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + }, + "ru": { + "checksum": "65d88bf86c657dd15f7da4c9471f5245", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + }, + "ja": { + "checksum": "8c972737c6851d6c4af0a9a7dc0f135f", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + }, + "de": { + "checksum": "2a56aae72caa193e6d1590274852407d", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + } + }, + "localesAriaLabel": { + "en": { + "checksum": "52ec7aab68a6cdc237d9db8133bc815f" + }, + "es": { + "checksum": "c88b33a3250b4e28f34e1ececba69206", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "fr-ca": { + "checksum": "1ea21417b4fc870a18d6a244983be72b", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "ru": { + "checksum": "c98fc8570c98ecc0926915d287884bd2", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "ja": { + "checksum": "6740922e3a31d08d75cc90d75875e3ce", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "de": { + "checksum": "e4594206ae15aba60a0884e4a5d1c220", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" } } } diff --git a/apps/tracker/src/i18n/collections/filter/resource_entries.json b/apps/tracker/src/i18n/collections/filter/resource_entries.json new file mode 100644 index 00000000..aeecfa0b --- /dev/null +++ b/apps/tracker/src/i18n/collections/filter/resource_entries.json @@ -0,0 +1,32 @@ +{ + "placeholder": { + "source": "Filter collections", + "comment": "Placeholder for the input that narrows the collection list by name or folder", + "tags": ["collections"], + "es": "Filtrar colecciones", + "fr-ca": "Filtrer les collections", + "ru": "Фильтровать коллекции", + "ja": "コレクションをフィルタリング", + "de": "Sammlungen filtern" + }, + "clear": { + "source": "Clear filter", + "comment": "Accessible label and tooltip for the button that clears the collection filter", + "tags": ["collections"], + "es": "Borrar filtro", + "fr-ca": "Effacer le filtre", + "ru": "Очистить фильтр", + "ja": "フィルタをクリア", + "de": "Filter löschen" + }, + "noResultsX": { + "source": "No collections match \"{query}\".", + "comment": "Message shown when the collection filter excludes every collection", + "tags": ["collections"], + "es": "Ninguna colección coincide con «{query}».", + "fr-ca": "Aucune collection ne correspond à « {query} ».", + "ru": "Нет коллекций, соответствующих «{query}».", + "ja": "「{query}」に一致するコレクションはありません。", + "de": "Keine Sammlung stimmt mit „{query}“ überein." + } +} diff --git a/apps/tracker/src/i18n/collections/filter/tracker_meta.json b/apps/tracker/src/i18n/collections/filter/tracker_meta.json new file mode 100644 index 00000000..4baf4bca --- /dev/null +++ b/apps/tracker/src/i18n/collections/filter/tracker_meta.json @@ -0,0 +1,92 @@ +{ + "placeholder": { + "en": { + "checksum": "2dc6583e4f47458fc0ef102aab719d35" + }, + "es": { + "checksum": "7a77971a7da243014f0b3f1593abc171", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + }, + "fr-ca": { + "checksum": "2dd06fda5cbba2f62274026cb4e5377f", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + }, + "ru": { + "checksum": "9e1ca64cc0134fdc5c27e097e19d121e", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + }, + "ja": { + "checksum": "d1d4b7cd299310ae205952e59d08c7e5", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + }, + "de": { + "checksum": "7067809f2475ec77ba0971d201efdf77", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + } + }, + "clear": { + "en": { + "checksum": "f53b6f6ba5a4c2c190041fe59d8926a0" + }, + "es": { + "checksum": "0334740e2efb300d578023c59f7a709c", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + }, + "fr-ca": { + "checksum": "e1e5c1cc57d60909661127ffcd4bf906", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + }, + "ru": { + "checksum": "550eb221497e0941e1a788cb01df7535", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + }, + "ja": { + "checksum": "4a29efe214d4afe10b3949dce437c666", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + }, + "de": { + "checksum": "981c2e7c4ec0ac90560d4115b472dd58", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + } + }, + "noResultsX": { + "en": { + "checksum": "7d537c46c249207a1fb0541dbcff6ec8" + }, + "es": { + "checksum": "c15a83af1adc1247f6222c0db028e310", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + }, + "fr-ca": { + "checksum": "698e154817ec999d8570d449f10d99d8", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + }, + "ru": { + "checksum": "d3ec995edc2046c0af8adba8b966f2bf", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + }, + "ja": { + "checksum": "14b8048c2d539979dce03a49c426e8bb", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + }, + "de": { + "checksum": "1b3bd7ce0bc2fa2ddb6f7453787b1f27", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + } + } +} diff --git a/apps/tracker/src/i18n/collections/resource_entries.json b/apps/tracker/src/i18n/collections/resource_entries.json index 1621ba8d..1588feef 100644 --- a/apps/tracker/src/i18n/collections/resource_entries.json +++ b/apps/tracker/src/i18n/collections/resource_entries.json @@ -16,5 +16,15 @@ "ru": "Добавить коллекцию", "ja": "コレクションを追加", "de": "Sammlung hinzufügen" + }, + "subtitleX": { + "source": "{ count, plural, =1 {1 collection} other {{count} collections} }", + "comment": "Pluralized count of collections shown under the Collections page title", + "tags": ["collections"], + "es": "{ count, plural, =1 {1 colección} other {{count} colecciones} }", + "fr-ca": "{ count, plural, =1 {1 collection} other {{count} collections} }", + "ru": "{ count, plural, one {{count} коллекция} few {{count} коллекции} many {{count} коллекций} other {{count} коллекции} }", + "de": "{ count, plural, =1 {1 Sammlung} other {{count} Sammlungen} }", + "ja": "{ count, plural, other {{count} 件のコレクション} }" } } diff --git a/apps/tracker/src/i18n/collections/tracker_meta.json b/apps/tracker/src/i18n/collections/tracker_meta.json index b3add7e3..38a4f2f1 100644 --- a/apps/tracker/src/i18n/collections/tracker_meta.json +++ b/apps/tracker/src/i18n/collections/tracker_meta.json @@ -58,5 +58,35 @@ "baseChecksum": "10ac958235fe83036f8e5244454f7c78", "status": "translated" } + }, + "subtitleX": { + "en": { + "checksum": "8c4d7b9e54cbc1e6049c46ddbf30c213" + }, + "es": { + "checksum": "1234b8c5b91e16b2f35311e285ee776e", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + }, + "fr-ca": { + "checksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + }, + "ru": { + "checksum": "13854dbc38e315d4e5093ae77d3cafbc", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + }, + "de": { + "checksum": "456b16cfd00063d3a48eea67deea50fa", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + }, + "ja": { + "checksum": "2ee3e27eac4719379b664c5b482b8918", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + } } } diff --git a/apps/tracker/src/styles.scss b/apps/tracker/src/styles.scss index 627c97c5..2218256d 100644 --- a/apps/tracker/src/styles.scss +++ b/apps/tracker/src/styles.scss @@ -46,6 +46,19 @@ pre { outline-offset: 2px; } +// Browser-supplied surfaces theming: selection, form accents, scrollbars +::selection { + background-color: color-mix(in srgb, var(--color-primary) 28%, transparent); + color: var(--color-text-primary); +} + +:root { + accent-color: var(--color-primary); + caret-color: var(--color-primary); + scrollbar-color: var(--color-border-strong) transparent; + scrollbar-width: thin; +} + // Search term highlighting mark.search-highlight { background-color: var(--color-highlight); From f74dddbab1ed0bc04bf60e6133f7f71790ab7875 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 14:05:20 -0700 Subject: [PATCH 11/13] fix(tracker): complete the collections translations readOnlyBadge shipped the English "Read-only" in all five non-base locales. The four fr-ca entries already held correct French but carried no metadata, so they still counted as untranslated. - Translate collections.card.readOnlyBadge into es, fr-ca, ru, de and ja - Re-apply the existing fr-ca values for collections.title, addButton, card.edit and card.delete so the CLI records their checksums and status No collection resource under collections/ is left new or stale. Co-Authored-By: Claude Opus 5 --- .../collections/card/resource_entries.json | 10 ++++---- .../i18n/collections/card/tracker_meta.json | 24 +++++++++---------- .../src/i18n/collections/tracker_meta.json | 4 ++-- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/tracker/src/i18n/collections/card/resource_entries.json b/apps/tracker/src/i18n/collections/card/resource_entries.json index ede53c7f..b1d0ee5e 100644 --- a/apps/tracker/src/i18n/collections/card/resource_entries.json +++ b/apps/tracker/src/i18n/collections/card/resource_entries.json @@ -33,11 +33,11 @@ "source": "Read-only", "comment": "Badge on a collection card indicating the collection is read-only.", "tags": ["collections"], - "es": "Read-only", - "fr-ca": "Read-only", - "ru": "Read-only", - "ja": "Read-only", - "de": "Read-only" + "es": "Solo lectura", + "fr-ca": "Lecture seule", + "ru": "Только чтение", + "ja": "読み取り専用", + "de": "Schreibgeschützt" }, "readOnlyTooltip": { "source": "This collection is read-only. Its resources can't be modified.", diff --git a/apps/tracker/src/i18n/collections/card/tracker_meta.json b/apps/tracker/src/i18n/collections/card/tracker_meta.json index 5e5558eb..3cb66faa 100644 --- a/apps/tracker/src/i18n/collections/card/tracker_meta.json +++ b/apps/tracker/src/i18n/collections/card/tracker_meta.json @@ -11,7 +11,7 @@ "fr-ca": { "checksum": "29ab3c9f022d5d28e6d5ea87be010057", "baseChecksum": "2687006ffa30f69f72187cbb690d6cc0", - "status": "new" + "status": "translated" }, "ru": { "checksum": "fc542666f0b02b7b83ef1c93f694726c", @@ -41,7 +41,7 @@ "fr-ca": { "checksum": "66a9a5432f50dc163fec6cb64d2c88b3", "baseChecksum": "611307b7abf62c8f6ffc374701393f29", - "status": "new" + "status": "translated" }, "ru": { "checksum": "0f440420a40b59ebe5a8eae593b4bc31", @@ -94,29 +94,29 @@ "checksum": "d8bd6e60adcc01ce20f509ea6800de8f" }, "es": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", + "checksum": "957e52714a21b0fd5e08372c7b28950a", "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "status": "translated" }, "fr-ca": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", + "checksum": "bb19345031fd6613d15757ee42515004", "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", + "checksum": "c474199cafa80ac74e409f1a7e73369d", "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "status": "translated" }, "ja": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", + "checksum": "15bef6c340528605f23d0f6a227c325a", "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "status": "translated" }, "de": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", + "checksum": "1040a40dd432b3aa79a8b80494ee3f18", "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "status": "translated" } }, "readOnlyTooltip": { diff --git a/apps/tracker/src/i18n/collections/tracker_meta.json b/apps/tracker/src/i18n/collections/tracker_meta.json index 38a4f2f1..493102d6 100644 --- a/apps/tracker/src/i18n/collections/tracker_meta.json +++ b/apps/tracker/src/i18n/collections/tracker_meta.json @@ -11,7 +11,7 @@ "fr-ca": { "checksum": "a9fc91939a389c7c73e7a3f3cbf411cd", "baseChecksum": "a9fc91939a389c7c73e7a3f3cbf411cd", - "status": "new" + "status": "translated" }, "ru": { "checksum": "306e41fce3bc9de4911363df9a80a5ba", @@ -41,7 +41,7 @@ "fr-ca": { "checksum": "8415e183eef3d657a530bbd6c1fb5ef0", "baseChecksum": "10ac958235fe83036f8e5244454f7c78", - "status": "new" + "status": "translated" }, "ru": { "checksum": "8e290a022048d2cf84f456d1170ce6aa", From 1c8bfa8a2a49536b764a84ebeac1eb93b15aa190 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 14:06:16 -0700 Subject: [PATCH 12/13] chore(tracker): drop the unused collections.card.readOnlyBadge resource The collections card redesign replaced the read-only badge with a note row that uses readOnlyTooltip, leaving readOnlyBadge referenced nowhere. Co-Authored-By: Claude Opus 5 --- .../collections/card/resource_entries.json | 10 ------- .../i18n/collections/card/tracker_meta.json | 30 ------------------- 2 files changed, 40 deletions(-) diff --git a/apps/tracker/src/i18n/collections/card/resource_entries.json b/apps/tracker/src/i18n/collections/card/resource_entries.json index b1d0ee5e..b1e22c9b 100644 --- a/apps/tracker/src/i18n/collections/card/resource_entries.json +++ b/apps/tracker/src/i18n/collections/card/resource_entries.json @@ -29,16 +29,6 @@ "ja": "収集のためのその他のアクション", "de": "Weitere Aktionen zur Sammlung" }, - "readOnlyBadge": { - "source": "Read-only", - "comment": "Badge on a collection card indicating the collection is read-only.", - "tags": ["collections"], - "es": "Solo lectura", - "fr-ca": "Lecture seule", - "ru": "Только чтение", - "ja": "読み取り専用", - "de": "Schreibgeschützt" - }, "readOnlyTooltip": { "source": "This collection is read-only. Its resources can't be modified.", "comment": "Tooltip on the read-only badge on a collection card.", diff --git a/apps/tracker/src/i18n/collections/card/tracker_meta.json b/apps/tracker/src/i18n/collections/card/tracker_meta.json index 3cb66faa..ebfd9f8b 100644 --- a/apps/tracker/src/i18n/collections/card/tracker_meta.json +++ b/apps/tracker/src/i18n/collections/card/tracker_meta.json @@ -89,36 +89,6 @@ "status": "translated" } }, - "readOnlyBadge": { - "en": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f" - }, - "es": { - "checksum": "957e52714a21b0fd5e08372c7b28950a", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "translated" - }, - "fr-ca": { - "checksum": "bb19345031fd6613d15757ee42515004", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "translated" - }, - "ru": { - "checksum": "c474199cafa80ac74e409f1a7e73369d", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "translated" - }, - "ja": { - "checksum": "15bef6c340528605f23d0f6a227c325a", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "translated" - }, - "de": { - "checksum": "1040a40dd432b3aa79a8b80494ee3f18", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "translated" - } - }, "readOnlyTooltip": { "en": { "checksum": "ee180bd986e2dab87c25b037339bfd5c" From 51fa9e3b0c8ca9214f539f01462c58bd96e110bb Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 14:13:15 -0700 Subject: [PATCH 13/13] fix(tracker): show the system glyph when the theme follows the OS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme button previously wore the *resolved* theme, so selecting System looked identical to pinning light or dark. It now wears the selected mode, giving System its own monitor icon — matching the glyph in the menu. Co-Authored-By: Claude Opus 5 --- apps/tracker/src/app/header/app-header.spec.ts | 9 ++++----- apps/tracker/src/app/header/app-header.ts | 15 +++++++++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/tracker/src/app/header/app-header.spec.ts b/apps/tracker/src/app/header/app-header.spec.ts index 736cf151..40388fc7 100644 --- a/apps/tracker/src/app/header/app-header.spec.ts +++ b/apps/tracker/src/app/header/app-header.spec.ts @@ -100,26 +100,25 @@ describe('AppHeader', () => { }); describe('theme button', () => { - it('wears the light icon in the light theme', () => { + it('wears the light icon when light is pinned', () => { theme.setTheme('light'); fixture.detectChanges(); expect(iconOf(themeButton())).toBe('light_mode'); }); - it('wears the dark icon in the dark theme', () => { + it('wears the dark icon when dark is pinned', () => { theme.setTheme('dark'); fixture.detectChanges(); expect(iconOf(themeButton())).toBe('dark_mode'); }); - it('shows the resolved theme on system rather than a third glyph', () => { + it('wears the system icon on system, not the theme it resolves to', () => { theme.setTheme('system'); fixture.detectChanges(); - expect(['light_mode', 'dark_mode']).toContain(iconOf(themeButton())); - expect(iconOf(themeButton())).toBe(theme.effectiveTheme() === 'dark' ? 'dark_mode' : 'light_mode'); + expect(iconOf(themeButton())).toBe('computer'); }); it('names the selected mode, so system stays distinguishable from light', () => { diff --git a/apps/tracker/src/app/header/app-header.ts b/apps/tracker/src/app/header/app-header.ts index 5845be24..64c1c395 100644 --- a/apps/tracker/src/app/header/app-header.ts +++ b/apps/tracker/src/app/header/app-header.ts @@ -62,11 +62,18 @@ export class AppHeader { }); /** - * The switcher wears the theme you are actually looking at, so `system` shows - * light or dark rather than a third, abstract glyph. Which *mode* is selected - * stays legible: the label names it and the menu checks it. + * The switcher wears the *mode* that is selected, not the theme it resolves + * to: `system` gets its own glyph so following the OS is visibly distinct + * from having pinned light or dark. */ - readonly themeIcon = computed(() => (this.#themeService.effectiveTheme() === 'dark' ? 'dark_mode' : 'light_mode')); + readonly themeIcon = computed( + () => + ({ + light: 'light_mode', + dark: 'dark_mode', + system: 'computer', + })[this.#themeService.themeMode()], + ); /** * The token for the active mode's name. Resolved by the transloco pipe in the