Skip to content

Rework the translation browser, and catch placeholder drift in validate - #95

Merged
simoncodes-ca merged 16 commits into
developfrom
feature/improve-navigation-tree
Sep 8, 2026
Merged

simoncodes-ca merged 16 commits into
developfrom
feature/improve-navigation-tree

Conversation

@simoncodes-ca

Copy link
Copy Markdown
Owner

Sixteen commits reworking the Tracker's browsing surface, plus a new validation check and the i18n bugs it uncovered. Each commit is self-contained and carries its own reasoning; this is a summary of the themes.

Navigation and layout

  • Folder tree is collapsible and gains an explicit collection root, so the tree has a home position rather than starting mid-hierarchy.
  • Translation editor dialog reworked.
  • Browser content toolbar unified — search, filters, sort, density and the primary action now read as grouped zones rather than a row of unrelated controls.

Reading a translation at a glance

  • The compact row leads with the source string rather than the key, which is what a reader scans for.
  • The rollup ring separates two issue states it had been merging, so new and stale are no longer conflated — one is work not started, the other is work gone out of date.
  • Resource comments are set as prose, not fine print.
  • The skip link is raised above the contrast floor.

Status as a first-class control

The status filter reported its state as four 16px glyphs behind a Material menu, so the one control answering "what still needs work?" was the quietest thing in the toolbar, and reading it cost a click.

It is now an always-visible rail: one toggle per status plus a leading Needs work shortcut selecting new + stale together (previously two clicks inside the menu). Each toggle carries the number of resources it would leave on screen.

Counts come from a new statusCounts / needsWorkCount pair routed through the same predicate sortedTranslations filters with, so a count can never promise a row the list then declines to show — there's a test asserting exactly that. They count resources rather than status cells, and needsWorkCount is a union: a resource stale in German and new in Japanese is one row, not two.

A new validation pass, and the bugs it found

lingo-tracker validate now checks that every translation interpolates the same placeholders as its base value.

This defect is silent by construction. ICU renders a missing argument as an empty string rather than raising, so a translation can be reviewer-approved and compile cleanly while interpolating an argument no caller passes. Machine translation produces it routinely, translating the argument name along with the prose.

It found two real classes of bug:

Renamed placeholders in the Tracker's own UI. Four aria-labels never interpolated in any non-English locale — {{ nombre }}, {{ 名前 }}, {{ имя }}. German was broken in the same way ({{ Name }}), since argument names are case-sensitive, which is presumably how it survived review. Fixed by replacing only the placeholder token; translator wording untouched.

ICU quote escaping in the playground fixtures. File '{filename}' was overwritten declares no argument at all — ICU treats '…' as an escape, so it renders the literal text. Here the English source was the broken side; translations using their own quotation marks were correct all along.

Arguments are collected with @messageformat/parser rather than a regex, which matters: the pass counts arguments nested in plural branches and the argument a plural switches on, while excluding branch selectors and #. A Russian one/few/other against an English =1/other is not flagged — categories are ICU vocabulary and are meant to differ.

On by default; --skip-placeholders opts out, independently of --skip-icu.

API

Collections are cached independently rather than one at a time.

Review notes

  • chore: disable auto-translation turns off Google Translate in .lingo-tracker.json. Worth a deliberate look: add-resource and edit-resource --base-value will no longer populate other locales. It's also what would have prevented the fixture churn in the commit before it, where a base-value edit silently regenerated every locale.
  • validate only checks the global config.locales. mockDesignSystem declares 11 locales including ar, ko, nl, pt-br, sv — none of those are validated by any pass. Pre-existing gap, not introduced here.
  • The placeholder check runs on stored source, not the generated bundle, so it won't catch a conversion bug in the bundler itself.
  • The status rail was not visually verified in a browser — no Playwright or Chrome tooling was available in the environment. It is covered by tests, the design detector, and static contrast/flex reasoning, but a look at ~900px in German is worth doing, since that's where the rail is widest.

Verification

lint, test, build and typecheck green across all 8 projects. validate reports zero placeholder failures and zero ICU failures across all four collections.

🤖 Generated with Claude Code

SimonNodel-AI and others added 16 commits September 5, 2026 17:49
…root

The sidebar tree rendered fully expanded and could never be closed, because
`loaded` doubled as the expansion flag and the API indexes at depth Infinity —
so every folder arrived loaded on the first payload. Expansion now has its own
state and the tree starts collapsed.

- Chevron toggles a folder; the row selects and opens but never closes, so
  drilling down stays one click while the chevron gives precise control.
- ArrowRight opens the focused row, ArrowLeft closes it. The twisty is not a
  tab stop, per the ARIA tree pattern.
- A root row stands for the collection itself: it lists every resource across
  every folder, owns the expand/collapse-all toggle, and accepts folder drops
  so a nested folder can be moved back out to the top level.
- Filtering opens the branches holding matches and restores the previous
  expansion when cleared.
- Expansion follows its folders: pruned on delete, rebased on move, and the
  ancestors of the selection are always revealed.
- Drops the redundant per-click subtree refetch, which the first payload had
  already delivered in full.

The API's empty-path branch returned early before honouring `includeNested`,
so the root could not list resources recursively. Both branches now share one
mapping path, which also restores `inheritedTags` on nested resources.

An empty `currentFolderPath` used to mean "no folder selected", which the root
row makes a real selection. The content area's "select a folder" card covered
even search results, and the add-translation button stayed disabled; both gates
are gone, along with the now-unused `browser.selectFolderFirst` resource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The key and locale count chips rendered their raw keys in every non-English
locale. `keysText` and `localesText` called `transloco.translate()` inside a
computed; `LocaleService` sets the active language synchronously in its
constructor, so the computed first ran while the locale file was still in
flight, returned the key, and had no remaining dependency to invalidate it.

Resolve both through `TranslocoPipe` in the template instead, which re-renders
on load, and translate the two ICU strings into the five non-base locales with
the plural categories each language actually uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The translation editor now renders the status lifecycle through the localized
resources rather than the raw enum, which exposed that `stale` was translated
in the wrong sense in four locales: de "Abgestanden" (of beer going flat), ru
"Залежалый" (of unsold goods), es "Duro" (of bread) and fr-ca "Vicié" (of foul
air). None is the software sense of an out-of-date translation.

Also corrects, in the same pass:

- `saving` in es and fr-ca, which used the money sense on the submit button
- `error.createFailed` in fr-ca, which said the translation had failed
- `conflict.chooseDifferentKey` in de, which used *Taste*, a keyboard key
- `keyHint` and `keyPatternError` in es and fr-ca, which named a tab label that
  does not exist
- `similarTranslations.showMoreX` in de, fr-ca, ja and ru, one untranslated and
  three with the placeholder stranded by English word order
- `localeTranslationX` in es and fr-ca, which used English adjective order

Drops `otherLocalesToggleX`, referenced by nothing, across all six locales and
its stub in the testing module. Clears the 20 stale and 2 new entries in the
dialog scope, whose values still matched their base.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the gaps a design critique found between the dialog and the promises
the product makes elsewhere.

Editing safety:

- Take ownership of `disableClose` in the component rather than trusting each
  call site, and route Escape through `dialogRef.keydownEvents()` instead of a
  window listener that fired through any dialog stacked above it. Confirm
  before discarding, comparing against captured baselines for the form, the
  selected folder and the tag list, two of which live outside the form.
- Make the key readonly while editing, so a rename is impossible rather than
  rejected after a round trip, and drop the now unreachable rejection.
- Leave Save enabled and diagnose on click: mark the fields touched, jump to
  the offending tab, focus the control, and mark the tab with an error dot.

Keys:

- Accept the dot-delimited syntax the product defines. Pasting or typing
  `apps.common.buttons.ok` now splits at the last dot, sets the folder and
  keeps the leaf, with the location pill flashing and a live region announcing
  where the prefix went. The pattern validator stays as the backstop.

Auto-translate moves out of the dialog. It persisted to disk on click from
inside an unsaved form, so the discard prompt then offered to lose changes that
were already committed. It remains available on the translation list item.

Layout:

- Give the dialog a full-screen treatment below 640px, replacing a 312px
  column, and size the tab content by viewport instead of a fixed 570px that
  left 350px empty under the folder tree.
- Size form-field subscripts dynamically. German hint text overflowed its 22px
  box by up to 60px and painted over the base locale card.
- Keep the tab strip from paginating at narrow widths, where it scrolled the
  active tab out of view with nothing to say which was selected.

Also renames the read-only banner's colour to the token that exists, adds
`--color-warning-text` for the contrast the banner needs on parchment, resolves
locale names through `Intl.DisplayNames` while keeping the raw code visible,
and extends the reduced-motion guards to the folder tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The toolbar's six controls were assembled from two different control
families: the locale and status filters kept Material's own 32px height,
greyscale outline and rgba ink, while the icon buttons beside them used
the token set. A uniform 12px gap ran between all six, so search, filters,
sort, the primary action and the density toggle read as one flat row with
no grouping.

Both filter triggers now share a %toolbar-filter-trigger placeholder on
the same 40px box, warm border and hover/focus treatment as the icon
buttons. They are plain buttons rather than mat-stroked-button, whose host
styles beat the token styles on height and outline. Zones are separated by
a hairline rather than by more of the same gap, and the two filters sit
closer to each other than to anything else.

Also:

- The placeholder in the shared search input cleared only 4.2:1; add
  --color-text-placeholder, dark enough to read in both themes.
- Add --color-text-on-primary for ink on the primary fill. The fill is the
  same light coral in both themes, so tying its label to
  --color-text-primary would drop it to 2:1 under dark.
- Give the locale filter a `translate` icon; it and the status filter both
  carried `filter_list`, which told the user nothing about which menu they
  were opening.

Fixes a horizontal overflow that took the Add Translation button off
screen on narrow windows. The cause was the app shell, not the toolbar:
.app-container's implicit `auto` grid column sized itself to the widest
row's min-content width and stretched the whole page past the viewport.
With minmax(0, 1fr) and min-width: 0 on the content pane, the toolbar
reflows against its real width — search takes its own line under 760px,
and the primary action goes icon-only under 640px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The browser's translation item reported state it could not back up: locales
were hidden with no way to know they existed, the compact row showed a value
without naming its locale, and the status spine was hardcoded English on a
second, unthemed palette.

Data honesty
- Collapsed items render the first four locale rows and the control names the
  remainder ("7 more locales"), replacing a fade gradient over a nested
  scroller that hid rows silently. Removes the observer, sentinel and the extra
  per-item tab stop the scroller created.
- The compact row carries a locale badge (status icon + code), plus a "source"
  tag when it falls back to the base locale, so untranslated and finished rows
  can no longer look alike.

Readability
- The key chip tooltips the untruncated key and names it in its accessible
  name; the old aria-label overrode the chip contents, so screen readers got
  neither the full key nor the truncated one.
- Keys wrap at their own word boundaries via <wbr> at separators and camelCase
  humps, instead of being sliced mid-token by word-break: break-all.
- Narrow items stack each locale's label above its value (container query), so
  the value gets the full card width instead of a 115px column.

Status spine
- Adds --color-status-* tokens with per-surface light/dark values, replacing
  five Tailwind hexes that disagreed with the watercolor palette used by the
  rows beneath them. `new` and `stale` no longer share a color.
- Rollup labels and accessible name go through Transloco; the breakdown is
  built from pluralized resources and joined with Intl.ListFormat.
- The rollup trigger is a real button rather than div[role="button"].

Contrast (light theme, measured against the card)
- base/compact value 2.68:1 -> 5.05:1 via a new --color-primary-text
- status icons 1.84-3.15:1 -> 3.24-3.78:1
- "no translation" placeholder and source tag 2.14:1 -> 6.21:1 / 4.66:1

Also
- statusBreakdown() called translate() inside a computed with no language
  dependency, froze on a language switch, and emitted raw status keys; it now
  shares the reactive helper in shared/i18n.
- Virtual scroll itemSize under-estimated full items by 41px; retuned from
  measurement. It remains an estimate: CDK's fixed-size strategy cannot model
  the conditional comment row or the stacked narrow layout.
- Aligns the tracker's tsconfig lib with its es2022 target for Intl.ListFormat.
- Drops the orphaned browser.toggleExpansion resource and the unused
  TruncateKeyPipe, moving truncateKey to shared/utils.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cache held a single collection, so a request naming a different one
evicted whatever was there. Two Tracker tabs on different collections
therefore evicted each other on every 2s `/cache/status` poll: neither
ever reached READY, so neither poll ever terminated and the server
re-indexed continuously.

Collections now live in a Map capped at LINGO_TRACKER_MAX_CACHED_
COLLECTIONS (default 4) with least-recently-used eviction, which keeps
the memory ceiling explicit. An entry that is still INDEXING is never
evicted — discarding in-flight work would strand the request that
started it — so the map may overflow briefly when every entry is busy.
LRU order uses a monotonic counter rather than a clock, because several
collections can be touched inside the same millisecond.

Cache invalidation is now scoped to the collection that was written.
Previously `clearCache()` wiped whatever was cached, so a write against
one collection forced a re-index of an unrelated one somebody else was
viewing. Cross-collection moves clear the source and every destination
they touched; a cross-collection folder move skips the incremental
update, which only knows how to relocate within a single tree.

Fingerprint, revalidation throttle stamp and the deferred refresh timer
move onto each entry — a single global throttle let a read of one
collection postpone another's staleness check. Indexing results are now
matched by entry identity, so a clear during a load cannot write stale
results onto the replacement entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compact density dropped the base value and led with a translation, which
handed a developer scanning an eleven-locale collection a script they
could not read. The base value is the one string on the row they already
know — they wrote it, and it is what they arrived looking for — so it now
identifies the row and the selected locale's value sits beside it, source
and translation judged against each other in one horizontal glance. Where
there is no base locale at all, the shown locale becomes the identity and
the row says the pick was automatic. The row is one line again: 38px
measured in the browser, 54px where a coarse pointer needs the 44px
control floor.

Full density gains the source as the first row of the locale grid rather
than a heading above it. A translation can only be judged against the
source when the two share a baseline, a measure and a type size.

A value byte-identical to the base is now called out on the row. Status
metadata cannot catch this — copying the source into a locale produces a
perfectly valid `translated` status — so the chip was reporting finished
work on a string nobody had touched.

Read-only collections refuse deletion at every entry point: the store
method, the keyboard shortcut, and the leading glyph, which shows a lock
in place of the grab handle rather than an affordance that lies. The
delete shortcut previously opened a confirmation the API would reject
only after the user had committed to it.

Also guards the icon flip behind prefers-reduced-motion and routes the
inherited-tag tooltip through transloco.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ring's centre reported `priority_high` whenever either `new` or `stale`
was above zero. Those are the two states a translator triages differently,
and the only thing left separating them was two arcs about 20 degrees apart
at a similar brightness — one colour to a reader with deuteranopia.

The centre now names one of five states. `new` and `stale` each take the
glyph the tooltip already uses for them, and the disc border takes the
matching hue, so the distinction is carried twice and neither time by colour
alone. `mixed` keeps `priority_high`, which is the one case where merging
them is honest. The accessible name is unchanged; it already read the full
breakdown.

Two further defects from the same critique:

- A locale column whose rows all share a status collapses to one chip
  carrying the count. Eleven identical `translated` chips are a pattern with
  nothing to find in it. A card with mixed statuses keeps its per-row chips,
  which is the case where the column is worth reading.
- The comment band reserved 49px for a 32px control, because the expand
  button carried a 44px touch floor and sets the row height alone when the
  item has no comment. The floor now applies under a coarse pointer only.
  Measured 37px.

Drops `hasIssues`, `isAllTranslated` and `translatedLike`, all now unused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inverse text on `--color-primary` measured 3.1:1. The floor for body text is
4.5:1, and this link is body text — the fact that it appears only on focus
makes it harder to notice, not exempt.

`--color-primary-active` is the darkest step of the same ramp and is tuned in
both themes: 5.08:1 light, 5.39:1 dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment is the only sentence on a full-density card that a person wrote —
everything above it is derived from the files — and it was set as the least
important thing on it: 14px italic in secondary ink, running unbroken past 130
characters on a wide card, behind a literal "Comment:" prefix repeated on every
row.

It is its own block now. The text is roman, capped at 72ch, and led by a
`sticky_note_2` glyph on the locale-code column, so the note hangs off the same
left edge as the rows above it. No fill and no rule: a fill quiet enough for
this surface is a shade off the surface itself, and a rule adds a second
vertical line to a card whose only other one is the source divider.

The prefix is now `.sr-only` rather than deleted — the glyph names the block for
sighted users, and the word still names it for everyone else, as a translated
token rather than a hardcoded aria-label.

The icon's box is the height of the text's first line with the glyph centred
inside it. Sized to the glyph instead, it top-aligned to the grid row while the
text sat lower in its taller line box, and read as floating above the sentence.

The expand control takes its own row, and the band's top rule goes with the old
layout that needed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status filter reported its state as four 16px glyphs behind a Material
menu, so the one control answering "what still needs work?" was the quietest
thing in the row — and reading it cost a click.

Replace the dropdown with an always-visible rail: one toggle per status plus a
leading "Needs work" shortcut selecting new + stale together, which was
previously two clicks inside the menu. Each toggle carries the number of
resources it would leave on screen, which is the question a user is asking
before they click it. Colour comes from the existing status chip tokens, so the
filter and the rows it filters are painted from one spine and the change costs
no new visual language.

Counts come from a new statusCounts / needsWorkCount pair in the store, routed
through the same matchesAnyStatus predicate that sortedTranslations filters
with, so a count can never promise a row the list then declines to show. They
count resources rather than status cells, and needsWorkCount is a union: a
resource that is stale in German and new in Japanese is one row, not two.

Also:
- Rebalance the toolbar wrap points around the rail, now the widest zone and
  the one that grows most in German and Russian; it must be able to shrink and
  wrap internally or it pushes the primary action off the edge of the pane.
- Drop statusFilterText and isShowingAllStatuses, whose only consumer was the
  removed trigger and which held hardcoded English in a localized UI.
- Unselected toggles use --color-text-placeholder rather than
  --color-text-tertiary, a 2.5:1 decorative tint that is not safe for a label
  with a number in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auto-translation had translated the interpolation placeholder itself along with
the surrounding prose, so four aria-labels silently never interpolated in any
non-English locale:

  folderNode.folderAriaLabelX        {{ Name }} {{ nombre }} {{ nom }} {{ 名前 }} {{ имя }}
  folderNode.deleteFolderAriaLabelX  {{ Name }} {{ nombre }} {{ nom }} {{ 名前 }} {{ имя }}
  folderPicker.selectorAriaLabelX    {{ Pfad }} {{ ruta }} {{ chemin }} {{ パス }} {{ путь }}
  localeFilter.ariaLabelX                       {{ texto }} {{ texte }} {{ テキスト }} {{ текст }}

Nothing raises on this: the placeholder is simply dropped from the rendered
string, so a screen reader announces "Ordner" with no folder name. German was
broken in the same way as Japanese — argument names are case-sensitive, so
{{ Name }} fails exactly as {{ 名前 }} does while looking entirely correct,
which is presumably how it survived review.

Replace only the placeholder token in each value; every translator's wording is
left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aceholders

A translation can be approved by a reviewer and compile cleanly while
interpolating an argument no caller ever passes, because a machine translator
renamed it along with the prose. ICU renders the missing argument as an empty
string rather than raising, so neither the status gate nor the ICU compile pass
sees it and the defect reaches production looking like ordinary text.

Add it as a third pass alongside the two existing ones, which answer genuinely
different questions: status asks whether a human approved the wording, ICU asks
whether the value compiles, and this asks whether it interpolates what the
caller supplies.

Arguments are collected with @messageformat/parser rather than a regex, which
matters for correctness: the pass counts arguments nested inside plural
branches and the argument a plural switches on, while excluding branch
selectors and the octothorpe. A Russian one/few/other against an English
=1/other is therefore not flagged — categories are ICU vocabulary and are meant
to differ between locales.

The comparison distinguishes an unparseable value from one that genuinely has
no arguments; without that, every malformed translation would look as though it
had dropped all its placeholders and would name the wrong repair, when the ICU
pass already reports the real defect.

On by default, with --skip-placeholders to opt out. It is independent of
--skip-icu, and a test asserts that skipping one does not quietly skip the
other.

Pure comparison lives in domain, so it stays free of Node dependencies; the
file-walking pass lives in core; the flag lives in the CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ixtures

ICU treats a single quote as an escape character, so the base value

  File '{filename}' was overwritten

declares no argument at all — it renders the literal text "File {filename} was
overwritten". The English source was the broken side here; translations using
their own quotation marks, as German's „{filename}“ did, were correct all
along. Escape the quotes as '' so the visible text is unchanged and the
placeholder comes back.

Found by the placeholder check added in the previous commit.

Note that editing a base value re-translates every locale, so the translations
for these two entries were regenerated rather than preserved, and the CLI's
writer reformatted the rest of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns auto-translation off and drops the apiKeyEnv entry, so resource edits no
longer call Google Translate.

This also removes the surprise behind the previous commit: editing a base value
with translation enabled regenerates every locale, which silently replaced
existing translations in the playground fixtures rather than leaving them
alone. With this off, a base-value edit changes only the base value and leaves
the translations to be updated deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@simoncodes-ca
simoncodes-ca merged commit cdc9ffe into develop Sep 8, 2026
1 check passed
@simoncodes-ca
simoncodes-ca deleted the feature/improve-navigation-tree branch September 8, 2026 06:33
@lingo-tracker-release

Copy link
Copy Markdown

🎉 This PR is included in version 0.18.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants