Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 3 additions & 0 deletions apps/tracker/.impeccable/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"buildPath": "comp"
}
118 changes: 118 additions & 0 deletions apps/tracker/PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Product

<!-- impeccable:product-schema 1 -->

## 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: 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:

- **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.
209 changes: 143 additions & 66 deletions apps/tracker/src/app/collections/collections-manager.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,53 @@
<div class="header-content">
<div class="header-text">
<h1 class="header-title">{{ TOKENS.COLLECTIONS.TITLE | transloco }}</h1>
@if (store.hasCollections()) {
<p class="header-count">
{{ TOKENS.COLLECTIONS.SUBTITLEX | transloco: { count: store.collectionEntries().length } }}
</p>
}
</div>

<div class="header-tools">
@if (showFilter()) {
<div class="filter-field">
<mat-icon class="filter-icon" aria-hidden="true">search</mat-icon>
<input
class="filter-input"
type="search"
autocomplete="off"
[value]="filter()"
(input)="filter.set($any($event.target).value)"
[placeholder]="TOKENS.COLLECTIONS.FILTER.PLACEHOLDER | transloco"
[attr.aria-label]="TOKENS.COLLECTIONS.FILTER.PLACEHOLDER | transloco"
/>
@if (isFiltering()) {
<button
mat-icon-button
type="button"
class="filter-clear"
(click)="clearFilter()"
[attr.aria-label]="TOKENS.COLLECTIONS.FILTER.CLEAR | transloco"
[matTooltip]="TOKENS.COLLECTIONS.FILTER.CLEAR | transloco"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
}

<button
mat-flat-button
color="primary"
type="button"
class="add-btn"
(click)="openCreateDialog()"
[disabled]="store.isLoading()"
>
<mat-icon>add</mat-icon>
{{ TOKENS.COLLECTIONS.ADDBUTTON | transloco }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
class="add-btn"
(click)="openCreateDialog()"
[disabled]="store.isLoading()"
>
<mat-icon>add</mat-icon>
{{ TOKENS.COLLECTIONS.ADDBUTTON | transloco }}
</button>
</div>
<div class="header-rule" aria-hidden="true"></div>
</header>
Expand All @@ -37,7 +72,7 @@ <h1 class="header-title">{{ TOKENS.COLLECTIONS.TITLE | transloco }}</h1>
</div>
<p>{{ store.error() }}</p>
<button
mat-raised-button
mat-flat-button
color="primary"
type="button"
(click)="store.loadCollections()"
Expand All @@ -60,7 +95,7 @@ <h1 class="header-title">{{ TOKENS.COLLECTIONS.TITLE | transloco }}</h1>
</div>
<p class="empty-message">{{ TOKENS.COLLECTIONS.EMPTYSTATE.MESSAGE | transloco }}</p>
<button
mat-raised-button
mat-flat-button
color="primary"
type="button"
class="add-btn"
Expand All @@ -71,70 +106,112 @@ <h1 class="header-title">{{ TOKENS.COLLECTIONS.TITLE | transloco }}</h1>
</div>
}

<!-- No Matches State -->
@if (hasNoMatches() && !store.isLoading() && !store.error()) {
<div class="no-matches" role="status" aria-live="polite">
<mat-icon class="no-matches-icon" aria-hidden="true">search_off</mat-icon>
<p class="no-matches-message">
{{ TOKENS.COLLECTIONS.FILTER.NORESULTSX | transloco: { query: filter().trim() } }}
</p>
<button mat-stroked-button type="button" (click)="clearFilter()">
{{ TOKENS.COLLECTIONS.FILTER.CLEAR | transloco }}
</button>
</div>
}

<!-- Collections Grid -->
@if (store.hasCollections() && !store.isLoading()) {
@if (cards().length > 0 && !store.isLoading()) {
<div class="collections-grid">
@for (item of store.collectionEntriesWithLocales(); track item.name; let i = $index) {
<div
@for (card of cards(); track card.name; let i = $index) {
<article
class="collection-card"
[class.collection-card--readonly]="card.readOnly"
[style.--card-index]="i"
tabindex="0"
(click)="navigateToBrowser(item.name)"
(keydown.enter)="navigateToBrowser(item.name)"
(keydown.space)="navigateToBrowser(item.name)"
role="link"
[attr.aria-label]="TOKENS.COLLECTIONS.CARD.OPENARIALABELX | transloco: { name: card.name }"
(click)="navigateToBrowser(card.name)"
(keydown.enter)="navigateToBrowser(card.name)"
(keydown.space)="navigateToBrowser(card.name)"
>
<div class="card-accent" aria-hidden="true"></div>
<div class="card-inner">
<div class="card-header">
<div class="card-title">
<div class="card-icon-wrap">
<mat-icon>folder</mat-icon>
</div>
<h3 class="collection-name">{{ item.name }}</h3>
@if (item.config.readOnly) {
<span class="readonly-badge" [matTooltip]="TOKENS.COLLECTIONS.CARD.READONLYTOOLTIP | transloco">
<mat-icon class="readonly-badge-icon">lock</mat-icon>
<span class="readonly-badge-text">{{ TOKENS.COLLECTIONS.CARD.READONLYBADGE | transloco }}</span>
</span>
}
</div>
<div class="card-actions">
<button
mat-icon-button
type="button"
[matMenuTriggerFor]="menu"
[attr.aria-label]="TOKENS.COLLECTIONS.CARD.MOREACTIONSARIALABEL | transloco"
(click)="$event.stopPropagation()"
>
<mat-icon>more_vert</mat-icon>
</button>

<mat-menu #menu="matMenu">
<button mat-menu-item (click)="openEditDialog(item.name)">
<mat-icon>edit</mat-icon>
<span>{{ TOKENS.COMMON.ACTIONS.EDIT | transloco }}</span>
</button>
<button mat-menu-item (click)="openDeleteDialog(item.name)">
<mat-icon>delete</mat-icon>
<span>{{ TOKENS.COMMON.ACTIONS.DELETE | transloco }}</span>
</button>
</mat-menu>
</div>
<div class="card-header">
<div class="card-icon-wrap" aria-hidden="true">
<mat-icon>{{ card.readOnly ? 'lock' : 'folder' }}</mat-icon>
</div>
<div class="card-content">
<p class="folder-path">
<mat-icon class="path-icon">subdirectory_arrow_right</mat-icon>
{{ item.config.translationsFolder }}

<div class="card-heading">
<h3 class="collection-name">{{ card.name }}</h3>
<p class="folder-path" [matTooltip]="card.translationsFolder" matTooltipPosition="below">
{{ card.translationsFolder }}
</p>
<div class="card-locales">
<app-tag-list [tags]="item.locales" />
</div>
</div>
<div class="card-arrow" aria-hidden="true">
<mat-icon>arrow_forward</mat-icon>

<div class="card-actions">
<button
mat-icon-button
type="button"
[matMenuTriggerFor]="menu"
[attr.aria-label]="TOKENS.COLLECTIONS.CARD.MOREACTIONSARIALABEL | transloco"
(click)="$event.stopPropagation()"
>
<mat-icon>more_vert</mat-icon>
</button>

<mat-menu #menu="matMenu">
<button mat-menu-item (click)="openEditDialog(card.name)">
<mat-icon>edit</mat-icon>
<span>{{ TOKENS.COMMON.ACTIONS.EDIT | transloco }}</span>
</button>
<button mat-menu-item (click)="openDeleteDialog(card.name)">
<mat-icon>delete</mat-icon>
<span>{{ TOKENS.COMMON.ACTIONS.DELETE | transloco }}</span>
</button>
</mat-menu>
</div>
</div>
</div>

@if (card.readOnly) {
<p class="readonly-note">
<mat-icon class="readonly-note-icon" aria-hidden="true">lock</mat-icon>
<span>{{ TOKENS.COLLECTIONS.CARD.READONLYTOOLTIP | transloco }}</span>
</p>
}

<div class="card-footer">
<ul class="locale-chips" [attr.aria-label]="TOKENS.COLLECTIONS.CARD.LOCALESARIALABEL | transloco">
@for (locale of card.visibleLocales; track locale) {
<li
class="locale-chip"
[class.locale-chip--base]="locale === card.baseLocale"
[matTooltip]="
locale === card.baseLocale
? (TOKENS.COLLECTIONS.CARD.BASELOCALETOOLTIPX | transloco: { locale: locale })
: ''
"
matTooltipPosition="above"
>
<span class="locale-chip-code">{{ locale }}</span>
@if (locale === card.baseLocale) {
<span class="locale-chip-tag">{{ TOKENS.COLLECTIONS.CARD.BASELOCALELABEL | transloco }}</span>
}
</li>
}
@if (card.overflowLocales.length > 0) {
<li
class="locale-chip locale-chip--more"
[matTooltip]="
(TOKENS.COLLECTIONS.CARD.MORELOCALESTOOLTIP | transloco) + ': ' + card.overflowLocales.join(', ')
"
matTooltipPosition="above"
>
{{ TOKENS.COLLECTIONS.CARD.MORELOCALESX | transloco: { count: card.overflowLocales.length } }}
</li>
}
</ul>

<mat-icon class="card-arrow" aria-hidden="true">arrow_forward</mat-icon>
</div>
</article>
}
</div>
}
Loading