diff --git a/CLAUDE.md b/CLAUDE.md
index 23a2c777..e1d12e3c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -102,6 +102,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/
- **Read-only store signals.** Writable signals in a store are private (`_x`) and exposed via `.asReadonly()`; mutation goes through methods. Writes are **not** optimistic: persist, adopt the returned note, reload the view — nothing is applied locally, so there is nothing to roll back. `resource.value()` throws while in error — read it behind a `hasValue()` guard.
- **`NotesQueryStore.view` is a `linkedSignal` that only retains what it is read through.** It keeps the previous view during a reload so the canvas doesn't blank on every keystroke. Everything the store exposes reads it, and `isLoading` reads it _first_ — a `&&` that short-circuits past it would drop the freshly loaded view on the floor.
- **⚠️ Transloco eats `{{…}}`.** An unknown `{{name}}` in a translation is replaced by the **empty string**, not left alone — so no translated string can carry a snippet's `{{fields}}`. The two sample snippet bodies are hard-coded in `core/state/sample-notes.service.ts` (they are code, so they would not be translated anyway), and a chapter of the guide that names a key receives it as an interpolation parameter instead of spelling it out. The application's name is the exception that costs nothing: a translation writes `{{app}}` and `AppTranslocoLoader` adds `app: APP_INFO.name` to every language, because the transpiler resolves an unknown interpolation against a **sibling key** before giving up on it. One line, no call site passes a parameter, and a string with parameters of its own still resolves both. No translated string spells `DevBox` out; a spec on the shipped files holds that.
+- **A count is an ICU plural, and the transpiler that reads it is ours.** `{count, plural, =0 {…} one {# note} other {# notes}}` in `plural-transpiler.ts`, ~60 lines over `Intl.PluralRules`. ⚠️ **Not** `@jsverse/transloco-messageformat`, which was installed, tried and removed: it compiles each message with `new Function`, the CSP is `script-src 'self'`, and the application boots onto an error banner and seeds nothing. The unit suite cannot see that — vitest runs under jsdom, where there is no policy to violate — so only the e2e run caught it. ⚠️ The grammar is deliberately tiny: `plural`, exact `=N`, the categories `Intl.PluralRules` answers, and `#`. No `select`, no `selectordinal`, no nesting. Plain interpolation stays `{{name}}` and is still `DefaultTranspiler`'s, which is what keeps the `{{app}}` sibling-key trick above working. ⚠️ `TranslocoService` asks for the transpiler in its own constructor, so the service is resolved on the first render rather than injected. `=0` is written out wherever zero reads badly: French calls zero `one`, so "0 résultat" would look like a singular. Two specs on the shipped files hold the rest — no string may carry "(s)", and none may carry a brace that is neither an interpolation nor a plural.
- **Translation keys, not strings.** Code that produces user-visible text returns a translation reference (`{ key, params }`) consumed by the `transloco` pipe in the template. Adding a string means adding it to **both** `src/app/core/i18n/translations/fr.json` and `en.json`. No user-visible string belongs in the Rust back-end — a new note gets an empty title, and the UI renders a translated placeholder.
- **Accessibility is enforced by the linter, except the two things it cannot see.** Decorative emoji need `aria-hidden`, toggles need `aria-pressed`, and information shown only graphically needs a `.visually-hidden` text twin — `npm run lint` catches most of it. What it cannot catch is **contrast** (`scripts/palette.test.mjs`), **target size** — a control takes `@include hit-target`, which is 24×24 of box and never a bigger glyph, since growing the type would be a density change — and **whether a destructive control looks like one**: `@include destructive` is red text at rest, not on `:hover`, because a warning that arrives under the pointer plays no part in choosing. ⚠️ Text and border, never a solid fill (one red button beside the single amber accent reads as a second accent), and the pressed state fills with `--bg-3` rather than a red tint, since red text on a tint of itself falls under AA (#191). ⚠️ On a card that box is not free — the card is a fixed 150px and `.card-items` is `overflow: hidden`, so `MAX_VISIBLE_ITEMS` and the "+N autres" badge have to keep agreeing with what is actually drawn. `07-checklists` measures the rendered boxes and the badge together, because the stylesheet alone cannot say either.
- **CSS variables must stay global.** Theme variables live on `:root` in `src/styles/styles.scss`. Angular's emulated encapsulation rewrites a `:root` selector inside a `*.component.scss` into a form that never matches ``, silently invalidating every variable. Shared SCSS patterns are mixins in `src/styles/_mixins.scss`, imported as `@use 'mixins' as *;`.
diff --git a/e2e/specs/06-search-and-filters.e2e.ts b/e2e/specs/06-search-and-filters.e2e.ts
index bb4b1ec5..68d02961 100644
--- a/e2e/specs/06-search-and-filters.e2e.ts
+++ b/e2e/specs/06-search-and-filters.e2e.ts
@@ -1,7 +1,7 @@
import { $, expect } from '@wdio/globals';
import { canvas } from '../pageobjects/canvas.page.js';
-import { blur, cursorOf, press, reloadCanvas, testid } from '../support/app.js';
+import { blur, cursorOf, press, reloadCanvas, testid, waitForCanvas } from '../support/app.js';
import { bridge, draft, homeSpaceId } from '../support/bridge.js';
/**
@@ -80,6 +80,20 @@ describe('Search, filters and facets', () => {
expect(await canvas.noResults().isExisting()).toBe(true);
});
+ /**
+ * ⚠️ An empty state that only reports is a dead end: the one thing to do from here is the
+ * thing that emptied it, and `clearFilters()` was already sitting there unoffered.
+ */
+ it('offers the way out of the state that emptied it', async () => {
+ await canvas.search('nothing matches this');
+
+ await $(testid('canvas-clear-filters')).click();
+ await waitForCanvas();
+
+ expect(await canvas.searchQuery()).toBe('');
+ expect(await canvas.noResults().isExisting()).toBe(false);
+ });
+
/** How big is this result, and why is that card in it. */
describe('what a search says about itself', () => {
/**
@@ -96,8 +110,15 @@ describe('Search, filters and facets', () => {
// Against what is on screen: the corpus is shared with every file that ran before.
expect(await canvas.matchedCount()).toContain(String((await canvas.titles()).length));
+ // ⚠️ Not on the words. The zero case is written out — French calls zero `one`, so
+ // "0 résultat" would read as a singular — but **the suite runs in whatever language
+ // the machine is set to**: French here, English on CI. Asserting "aucun" passed
+ // locally and failed on both runners. What the scenario is about is that the badge
+ // still says something, so that is what it asks.
await canvas.search('nothing matches this');
- expect(await canvas.matchedCount()).toContain('0');
+ const atZero = (await canvas.matchedCount()).replace('✕', '').trim();
+ expect(atZero.length).toBeGreaterThan(0);
+ expect(await canvas.noResults().isExisting()).toBe(true);
});
it('hides the count again once nothing is being filtered', async () => {
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index 43a2c15e..e802e15b 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -7,12 +7,13 @@ import {
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter, withHashLocation } from '@angular/router';
-import { provideTransloco } from '@jsverse/transloco';
+import { TRANSLOCO_TRANSPILER, provideTransloco } from '@jsverse/transloco';
import { routes } from './app.routes';
import { AppErrorHandler } from '@core/services/errors/app-error-handler';
import { APP_LOCALES, DEFAULT_LOCALE } from '@core/services/i18n/locale.model';
import { LocaleService } from '@core/services/i18n/locale.service';
+import { PluralTranspiler } from '@core/services/i18n/plural-transpiler';
import { AppTranslocoLoader } from '@core/services/i18n/transloco-loader';
import { AutostartService } from '@core/services/autostart/autostart.service';
import { PreferencesService } from '@core/services/preferences/preferences.service';
@@ -42,6 +43,15 @@ export const appConfig: ApplicationConfig = {
loader: AppTranslocoLoader,
}),
+ // ⚠️ French keeps the singular at zero where English does not, and four of these
+ // strings carry three independent counts in one sentence, each with its own agreement.
+ // A key per form would have meant eight variants of those alone.
+ //
+ // ⚠️ Ours rather than `@jsverse/transloco-messageformat`, which compiles each message
+ // with `new Function` — the CSP here is `script-src 'self'`, and the application boots
+ // onto an error banner with it. See `plural-transpiler.ts`.
+ { provide: TRANSLOCO_TRANSPILER, useClass: PluralTranspiler },
+
// ⚠️ One initialiser for both steps rather than two chained: Angular starts them
// together and awaits their promises as a block, so `restore()` would read a
// still-empty cache.
diff --git a/src/app/banners/error-banner/error-banner.component.html b/src/app/banners/error-banner/error-banner.component.html
index 793c6cfd..98212b02 100644
--- a/src/app/banners/error-banner/error-banner.component.html
+++ b/src/app/banners/error-banner/error-banner.component.html
@@ -12,6 +12,7 @@
type="button"
class="error-dismiss"
[attr.aria-label]="'errors.dismiss' | transloco"
+ [attr.title]="'errors.dismiss' | transloco"
(click)="notifier.dismiss()"
>
✕
diff --git a/src/app/banners/status-toast/status-toast.component.html b/src/app/banners/status-toast/status-toast.component.html
index 0a953440..4bc825b0 100644
--- a/src/app/banners/status-toast/status-toast.component.html
+++ b/src/app/banners/status-toast/status-toast.component.html
@@ -6,6 +6,7 @@
type="button"
class="status-dismiss"
[attr.aria-label]="'errors.dismiss' | transloco"
+ [attr.title]="'errors.dismiss' | transloco"
(click)="notifier.dismiss()"
>
✕
diff --git a/src/app/banners/status-toast/status-toast.component.spec.ts b/src/app/banners/status-toast/status-toast.component.spec.ts
index ecf64f37..60bc8e0b 100644
--- a/src/app/banners/status-toast/status-toast.component.spec.ts
+++ b/src/app/banners/status-toast/status-toast.component.spec.ts
@@ -29,7 +29,7 @@ describe('StatusToastComponent', () => {
await fixture.whenStable();
const text = fixture.nativeElement.querySelector('.status-text').textContent;
- expect(text).toContain('3 note(s) exportée(s)');
+ expect(text).toContain('3 notes exportées');
expect(text).toContain('devbox.json');
});
diff --git a/src/app/core/services/i18n/plural-transpiler.spec.ts b/src/app/core/services/i18n/plural-transpiler.spec.ts
new file mode 100644
index 00000000..7e8f317d
--- /dev/null
+++ b/src/app/core/services/i18n/plural-transpiler.spec.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest';
+import { expandPlurals } from './plural-transpiler';
+
+/**
+ * ⚠️ Exhaustive on purpose. This is a parser written here rather than taken from a library,
+ * because the library compiles with `new Function` and the CSP forbids it — so the grammar
+ * it accepts is the grammar nobody else is checking.
+ */
+describe('expandPlurals', () => {
+ const fr = (source: string, params: Record = {}) => expandPlurals(source, params, 'fr');
+ const en = (source: string, params: Record = {}) => expandPlurals(source, params, 'en');
+
+ it('picks the singular and the plural from the count', () => {
+ const source = '{count, plural, one {# note} other {# notes}}';
+
+ expect(fr(source, { count: 1 })).toBe('1 note');
+ expect(fr(source, { count: 4 })).toBe('4 notes');
+ });
+
+ /** ⚠️ The whole reason `=0` is written out: French calls zero `one`. */
+ it('lets an exact match win over the category', () => {
+ const source = '{count, plural, =0 {aucune note} one {# note} other {# notes}}';
+
+ expect(fr(source, { count: 0 })).toBe('aucune note');
+ expect(new Intl.PluralRules('fr').select(0)).toBe('one');
+ });
+
+ it('follows the locale rather than a rule of its own', () => {
+ const source = '{count, plural, one {# note} other {# notes}}';
+
+ // French keeps the singular at one *and* at zero; English does not.
+ expect(fr(source, { count: 0 })).toBe('0 note');
+ expect(en(source, { count: 0 })).toBe('0 notes');
+ });
+
+ it('expands several independent counts in one sentence', () => {
+ const source =
+ '{notes, plural, one {# note} other {# notes}} depuis {{path}}, ' +
+ '{skipped, plural, one {# ignorée} other {# ignorées}}.';
+
+ expect(fr(source, { notes: 1, skipped: 3 })).toBe('1 note depuis {{path}}, 3 ignorées.');
+ });
+
+ /** ⚠️ `{{app}}` and the like must pass through untouched: they are the other transpiler's. */
+ it('leaves a double-brace interpolation alone', () => {
+ expect(fr('Bienvenue dans {{app}}')).toBe('Bienvenue dans {{app}}');
+ expect(fr('{{notes}} et {{path}}')).toBe('{{notes}} et {{path}}');
+ });
+
+ it('leaves a hash outside a block alone, which is what a tag is written with', () => {
+ expect(fr('Renommer #{{tag}} sur {count, plural, one {# note} other {# notes}}', { count: 2 })).toBe(
+ 'Renommer #{{tag}} sur 2 notes',
+ );
+ });
+
+ it('falls back to other when the category has no branch', () => {
+ expect(fr('{count, plural, other {# choses}}', { count: 1 })).toBe('1 choses');
+ });
+
+ it('renders nothing rather than "undefined" when the parameter is missing', () => {
+ expect(fr('{count, plural, one {# note} other {# notes}}')).toBe(' notes');
+ });
+
+ /** A half-written block is left as written: visibly wrong beats silently truncated. */
+ it('leaves an unbalanced block exactly as it found it', () => {
+ const broken = 'avant {count, plural, one {# note} other {# notes}';
+
+ expect(fr(broken, { count: 2 })).toBe(broken);
+ });
+
+ it('keeps the text around a block, on both sides', () => {
+ expect(fr('a {count, plural, other {#}} b', { count: 7 })).toBe('a 7 b');
+ });
+
+ it('is unchanged by a string with no block at all', () => {
+ expect(fr('Rien à compter ici.')).toBe('Rien à compter ici.');
+ });
+});
diff --git a/src/app/core/services/i18n/plural-transpiler.ts b/src/app/core/services/i18n/plural-transpiler.ts
new file mode 100644
index 00000000..b85fd938
--- /dev/null
+++ b/src/app/core/services/i18n/plural-transpiler.ts
@@ -0,0 +1,126 @@
+import { Injectable, Injector, inject } from '@angular/core';
+import { DefaultTranspiler, TranslocoService } from '@jsverse/transloco';
+import type { Translation } from '@jsverse/transloco';
+
+/**
+ * Counting, in the one ICU shape these translations use:
+ * `{name, plural, =0 {…} one {# thing} other {# things}}`.
+ *
+ * ⚠️ Written here rather than taken from `@jsverse/transloco-messageformat`, which was tried
+ * and **cannot run in this application**: it compiles each message into a function with
+ * `new Function`, and `tauri.conf.json` locks the WebView down to `script-src 'self'`. The
+ * application booted onto a CSP error banner and seeded nothing. A unit test cannot see
+ * that — vitest runs under jsdom, where there is no policy to violate.
+ *
+ * ⚠️ Deliberately tiny. It knows `plural`, exact `=N` matches, the categories
+ * `Intl.PluralRules` answers, and `#`. It does **not** know `select`, `selectordinal`, or a
+ * plural inside a plural, because nothing here needs them — and a parser that guesses at a
+ * grammar it does not implement is worse than one that refuses it.
+ *
+ * Plain interpolation stays `{{name}}`, which is the rest of the application's convention
+ * and what `DefaultTranspiler` already does. This only ever expands the counted blocks and
+ * then hands the result on.
+ */
+@Injectable()
+export class PluralTranspiler extends DefaultTranspiler {
+ private readonly injector = inject(Injector);
+ private service?: TranslocoService;
+
+ override transpile(payload: {
+ value: unknown;
+ params?: Translation;
+ translation: Translation;
+ key: string;
+ }): unknown {
+ const { value, params } = payload;
+
+ return typeof value === 'string'
+ ? super.transpile({ ...payload, value: expandPlurals(value, params ?? {}, this.locale()) })
+ : super.transpile(payload);
+ }
+
+ /**
+ * ⚠️ Resolved on the first render rather than injected: `TranslocoService` asks for the
+ * transpiler in its own constructor, so taking it here would be a cycle.
+ */
+ private locale(): string {
+ this.service ??= this.injector.get(TranslocoService);
+ return this.service.getActiveLang();
+ }
+}
+
+/** Where a `{name, plural,` block starts. The name is what the parameters are keyed by. */
+const PLURAL_HEAD = /\{\s*([A-Za-z0-9_]+)\s*,\s*plural\s*,/g;
+
+export function expandPlurals(source: string, params: Translation, locale: string): string {
+ PLURAL_HEAD.lastIndex = 0;
+ let out = '';
+ let from = 0;
+ let head: RegExpExecArray | null;
+
+ while ((head = PLURAL_HEAD.exec(source)) !== null) {
+ const closing = matchingBrace(source, head.index);
+ // An unbalanced block is left exactly as written: rendering half a sentence would be
+ // worse than rendering the source of it, which is at least visibly wrong.
+ if (closing < 0) break;
+
+ const count = Number(params[head[1] ?? '']);
+ const branches = source.slice(head.index + head[0].length, closing);
+
+ out += source.slice(from, head.index) + choose(branches, count, locale);
+ from = closing + 1;
+ PLURAL_HEAD.lastIndex = from;
+ }
+
+ return out + source.slice(from);
+}
+
+/** The index of the `}` closing the `{` at `start`, or `-1`. */
+function matchingBrace(source: string, start: number): number {
+ let depth = 0;
+
+ for (let at = start; at < source.length; at += 1) {
+ if (source[at] === '{') depth += 1;
+ else if (source[at] === '}') {
+ depth -= 1;
+ if (depth === 0) return at;
+ }
+ }
+
+ return -1;
+}
+
+/**
+ * ⚠️ An exact `=N` wins over a category, which is the whole reason `=0` is written out:
+ * French calls zero `one`, so "0 note" would otherwise read as a singular where the
+ * sentence wants "aucune note".
+ */
+function choose(branches: string, count: number, locale: string): string {
+ const cases = parseBranches(branches);
+ const category = Number.isFinite(count) ? new Intl.PluralRules(locale).select(count) : 'other';
+ const chosen = cases.get(`=${count}`) ?? cases.get(category) ?? cases.get('other') ?? '';
+
+ return chosen.replaceAll('#', Number.isFinite(count) ? String(count) : '');
+}
+
+/** `selector {text}` pairs, in order, with the braces inside each text left alone. */
+function parseBranches(branches: string): Map {
+ const cases = new Map();
+ let at = 0;
+
+ while (at < branches.length) {
+ const open = branches.indexOf('{', at);
+ if (open < 0) break;
+
+ const selector = branches.slice(at, open).trim();
+ const closing = matchingBrace(branches, open);
+ if (closing < 0) break;
+
+ if (selector.length > 0) {
+ cases.set(selector, branches.slice(open + 1, closing));
+ }
+ at = closing + 1;
+ }
+
+ return cases;
+}
diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json
index 4f2e5593..ebf5c051 100644
--- a/src/app/core/services/i18n/translations/en.json
+++ b/src/app/core/services/i18n/translations/en.json
@@ -32,10 +32,10 @@
"pinnedState": "Pinned note",
"loading": "Loading notes…",
"noResults": "No note matches this search.",
- "searchMatched": "{{count}} result(s)",
+ "searchMatched": "{count, plural, =0 {no results} one {# result} other {# results}}",
"clearFilters": "Show everything",
- "attachmentsCount": "{{count}} attachment(s)",
- "placeholdersCount": "{{count}} field(s) to fill",
+ "attachmentsCount": "{count, plural, =0 {no attachments} one {# attachment} other {# attachments}}",
+ "placeholdersCount": "{count, plural, =0 {no fields to fill} one {# field to fill} other {# fields to fill}}",
"selectNote": "Select note {{title}}",
"keyboardHint": "↑↓←→ move · Enter open · C copy · X select · Del trash",
"trash": "Trash",
@@ -45,7 +45,7 @@
"newChecklist": "New to-do list",
"checklistProgress": "{{done}}/{{total}}",
"checklistToggle": "Tick the task {{text}}",
- "checklistMore": "+{{count}} more",
+ "checklistMore": "+{count, plural, one {# more} other {# more}}",
"samples": {
"space": "Getting started",
"welcome": {
@@ -80,7 +80,8 @@
"snippets": "Snippets",
"startHere": "Start here"
}
- }
+ },
+ "clearFiltersFromEmpty": "Clear the filters"
},
"filters": {
"groupLabel": "Filter notes",
@@ -163,7 +164,7 @@
"expiryLabel": "Note deadline (empty = permanent)",
"footerStats": "{{lines}} lines · {{bytes}} bytes",
"modifiedPrefix": "Updated",
- "checklistStats": "{{done}}/{{total}} task(s)",
+ "checklistStats": "{{done}}/{total, plural, one {# task} other {# tasks}}",
"addItem": "Add a task",
"itemLabel": "Task {{position}}",
"itemPlaceholder": "Describe the task…",
@@ -313,7 +314,7 @@
"close": "Close tag management",
"empty": "No tags yet.",
"loading": "Loading…",
- "noteCount": "{{count}} note(s)",
+ "noteCount": "{count, plural, =0 {no notes} one {# note} other {# notes}}",
"selectLabel": "Select tag {{tag}}",
"targetLabel": "Target name",
"targetPlaceholder": "tag name",
@@ -322,15 +323,15 @@
"delete": "Delete",
"hint": "Renaming onto an existing tag merges the two.",
"selectionHint": "Select the tags to merge, then type their common name.",
- "confirmRename": "Rename #{{tag}} to #{{into}} on {{count}} note(s)?",
- "confirmMerge": "⚠️ Merge {{tags}} tags into #{{into}} across {{count}} note(s)? A merge cannot be undone: once they are one, nothing knows which note carried which.",
- "confirmDelete": "Strip {{tags}} tag(s) from {{count}} note(s)? The notes themselves stay.",
+ "confirmRename": "Rename #{{tag}} to #{{into}} on {count, plural, one {# note} other {# notes}}?",
+ "confirmMerge": "⚠️ Merge {{tags}} tags into #{{into}} across {count, plural, one {# note} other {# notes}}? A merge cannot be undone: once they are one, nothing knows which note carried which.",
+ "confirmDelete": "Strip {tags, plural, one {# tag} other {# tags}} from {count, plural, one {# note} other {# notes}}? The notes themselves stay.",
"confirmAction": "Confirm",
"confirmCancel": "Cancel"
},
"selection": {
"label": "Selection actions",
- "count": "{{count}} selected",
+ "count": "{count, plural, one {# selected} other {# selected}}",
"clear": "Clear selection",
"moveTo": "Move to",
"addTagLabel": "Add a tag to the selection",
@@ -353,14 +354,14 @@
},
"placeholders": {
"title": "Fill the fields",
- "hint": "This snippet expects {{count}} value(s).",
+ "hint": "This snippet expects {count, plural, one {# value} other {# values}}.",
"fieldLabel": "Value for {{name}}",
"copy": "Copy filled",
"copyRaw": "Copy as is",
"cancel": "Cancel",
"panelTitle": "Fields",
"panelHint": "A field left empty keeps the value the text suggests.",
- "filledCount": "{{filled}} of {{total}} field(s) filled",
+ "filledCount": "{{filled}} of {total, plural, one {# field} other {# fields}} filled",
"show": "Show",
"summaryMore": "+{{count}}",
"preview": "Preview",
@@ -386,12 +387,12 @@
"closeZoom": "Close the image"
},
"undo": {
- "deleted": "{{count}} note(s) moved to trash",
+ "deleted": "{count, plural, one {# note} other {# notes}} moved to the trash",
"restore": "Undo",
"dismiss": "Dismiss",
- "moved": "{{count}} note(s) moved",
- "tagged": "{{count}} note(s) tagged",
- "filed": "{{count}} note(s) filed"
+ "moved": "{count, plural, one {# note} other {# notes}} moved",
+ "tagged": "{count, plural, one {# note} other {# notes}} tagged",
+ "filed": "{count, plural, one {# note} other {# notes}} filed"
},
"file": {
"menuLabel": "File",
@@ -402,15 +403,15 @@
"copyMarkdown": "Copy selection as Markdown",
"quit": "Quit {{app}}",
"quitConfirm": "Confirm?",
- "imported": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped.",
- "importedNothing": "Nothing to import: those {{skipped}} note(s) are already in your library.",
- "importedFromNewerVersion": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped. {{degraded}} came from a newer version: their language or kind was brought down to what this version can read.",
- "importedWithAttachments": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped, {{attachments}} attachment(s) restored.",
- "importedWithoutSomeAttachments": "{{notes}} note(s) imported from {{path}}. ⚠️ {{missing}} attachment(s) the file named but did not carry: their preview will stay empty.",
- "exported": "{{notes}} note(s) exported to {{path}}.",
- "exportedWithAttachments": "{{notes}} note(s) and {{attachments}} attachment(s) exported to {{path}}.",
- "exportedProtected": "{{notes}} note(s) exported to {{path}}, sealed with the passphrase you gave it.",
- "exportedProtectedWithAttachments": "{{notes}} note(s) and {{attachments}} attachment(s) exported to {{path}}, sealed with the passphrase you gave it.",
+ "imported": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}}, {skipped, plural, one {# skipped} other {# skipped}}.",
+ "importedNothing": "Nothing to import: {skipped, plural, one {that note is} other {those # notes are}} already in your library.",
+ "importedFromNewerVersion": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}}, {skipped, plural, one {# skipped} other {# skipped}}. {degraded, plural, one {# comes} other {# come}} from a newer version: its language or its kind was brought down to what this one can read.",
+ "importedWithAttachments": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}}, {skipped, plural, one {# skipped} other {# skipped}}, {attachments, plural, one {# attachment} other {# attachments}} restored.",
+ "importedWithoutSomeAttachments": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}}. ⚠️ {missing, plural, one {# attachment} other {# attachments}} named by the file but missing: their preview will stay empty.",
+ "exported": "{notes, plural, one {# note} other {# notes}} exported to {{path}}.",
+ "exportedWithAttachments": "{notes, plural, one {# note} other {# notes}} and {attachments, plural, one {# attachment} other {# attachments}} exported to {{path}}.",
+ "exportedProtected": "{notes, plural, one {# note} other {# notes}} exported to {{path}}, sealed with the passphrase you gave it.",
+ "exportedProtectedWithAttachments": "{notes, plural, one {# note} other {# notes}} and {attachments, plural, one {# attachment} other {# attachments}} exported to {{path}}, sealed with the passphrase you gave it.",
"protectTitle": "Protect this export?",
"protectLead": "{{path}} is about to leave your library. A passphrase seals it with a key of its own, which travels with the file rather than with this machine.",
"protectWarning": "⚠️ Without one, the file carries every note and every attachment in the clear: anyone holding it can read them.",
@@ -421,14 +422,14 @@
"unlockAction": "Import",
"cancel": "Cancel",
"working": "Working…",
- "copied": "{{notes}} note(s) copied as Markdown to the clipboard.",
+ "copied": "{notes, plural, one {# note} other {# notes}} copied as Markdown to the clipboard.",
"needsSelection": "Select at least one note.",
"emptyLibrary": "Nothing to export: this library has no notes.",
"preferences": "Preferences…",
- "importedIntoFolders": "{{notes}} note(s) imported from {{path}} into {{folders}} new folder(s), {{skipped}} skipped."
+ "importedIntoFolders": "{notes, plural, =0 {No notes} one {# note} other {# notes}} imported from {{path}} into {folders, plural, one {# new folder} other {# new folders}}, {skipped, plural, one {# skipped} other {# skipped}}."
},
"shortcuts": {
- "unavailable": "Global shortcut(s) unavailable: {{list}}. Another application is already using them.",
+ "unavailable": "{count, plural, one {Global shortcut unavailable} other {Global shortcuts unavailable}}: {{list}}. Another application already holds {count, plural, one {it} other {them}}.",
"title": "Keyboard shortcuts",
"subtitle": "The keys that are live depending on where you are. Bare letters only act on the canvas, never while you are typing.",
"editHint": "The quick-paste shortcut is set in File → Preferences.",
diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json
index 2cd83719..a925aa85 100644
--- a/src/app/core/services/i18n/translations/fr.json
+++ b/src/app/core/services/i18n/translations/fr.json
@@ -32,10 +32,10 @@
"pinnedState": "Note épinglée",
"loading": "Chargement des notes…",
"noResults": "Aucune note ne correspond à cette recherche.",
- "searchMatched": "{{count}} résultat(s)",
+ "searchMatched": "{count, plural, =0 {aucun résultat} one {# résultat} other {# résultats}}",
"clearFilters": "Tout afficher",
- "attachmentsCount": "{{count}} pièce(s) jointe(s)",
- "placeholdersCount": "{{count}} champ(s) à remplir",
+ "attachmentsCount": "{count, plural, =0 {aucune pièce jointe} one {# pièce jointe} other {# pièces jointes}}",
+ "placeholdersCount": "{count, plural, =0 {aucun champ à remplir} one {# champ à remplir} other {# champs à remplir}}",
"selectNote": "Sélectionner la note {{title}}",
"keyboardHint": "↑↓←→ naviguer · Entrée ouvrir · C copier · X sélectionner · Suppr corbeille",
"trash": "Corbeille",
@@ -45,7 +45,7 @@
"newChecklist": "Nouvelle todolist",
"checklistProgress": "{{done}}/{{total}}",
"checklistToggle": "Cocher la tâche {{text}}",
- "checklistMore": "+{{count}} autre(s)",
+ "checklistMore": "+{count, plural, one {# autre} other {# autres}}",
"samples": {
"space": "Découverte",
"welcome": {
@@ -80,7 +80,8 @@
"snippets": "Snippets",
"startHere": "Prise en main"
}
- }
+ },
+ "clearFiltersFromEmpty": "Effacer les filtres"
},
"filters": {
"groupLabel": "Filtrer les notes",
@@ -163,7 +164,7 @@
"expiryLabel": "Échéance de la note (vide = permanente)",
"footerStats": "{{lines}} lignes · {{bytes}} octets",
"modifiedPrefix": "Modifiée",
- "checklistStats": "{{done}}/{{total}} tâche(s)",
+ "checklistStats": "{{done}}/{total, plural, one {# tâche} other {# tâches}}",
"addItem": "Ajouter une tâche",
"itemLabel": "Tâche {{position}}",
"itemPlaceholder": "Décrire la tâche…",
@@ -313,7 +314,7 @@
"close": "Fermer la gestion des tags",
"empty": "Aucun tag pour le moment.",
"loading": "Chargement…",
- "noteCount": "{{count}} note(s)",
+ "noteCount": "{count, plural, =0 {aucune note} one {# note} other {# notes}}",
"selectLabel": "Sélectionner le tag {{tag}}",
"targetLabel": "Nom de destination",
"targetPlaceholder": "nom du tag",
@@ -322,15 +323,15 @@
"delete": "Supprimer",
"hint": "Renommer vers un tag existant fusionne les deux.",
"selectionHint": "Sélectionnez les tags à fusionner, puis saisissez leur nom commun.",
- "confirmRename": "Renommer #{{tag}} en #{{into}} sur {{count}} note(s) ?",
- "confirmMerge": "⚠️ Fusionner {{tags}} tags en #{{into}} sur {{count}} note(s) ? Une fusion ne s’annule pas : une fois réunis, plus rien ne sait quel tag portait quelle note.",
- "confirmDelete": "Retirer {{tags}} tag(s) de {{count}} note(s) ? Les notes, elles, restent.",
+ "confirmRename": "Renommer #{{tag}} en #{{into}} sur {count, plural, one {# note} other {# notes}} ?",
+ "confirmMerge": "⚠️ Fusionner {{tags}} tags en #{{into}} sur {count, plural, one {# note} other {# notes}} ? Une fusion ne s’annule pas : une fois réunis, plus rien ne sait quel tag portait quelle note.",
+ "confirmDelete": "Retirer {tags, plural, one {# tag} other {# tags}} de {count, plural, one {# note} other {# notes}} ? Les notes, elles, restent.",
"confirmAction": "Confirmer",
"confirmCancel": "Annuler"
},
"selection": {
"label": "Actions sur la sélection",
- "count": "{{count}} sélectionnée(s)",
+ "count": "{count, plural, one {# sélectionnée} other {# sélectionnées}}",
"clear": "Tout désélectionner",
"moveTo": "Déplacer vers",
"addTagLabel": "Ajouter un tag à la sélection",
@@ -353,14 +354,14 @@
},
"placeholders": {
"title": "Remplir les champs",
- "hint": "Ce snippet attend {{count}} valeur(s).",
+ "hint": "Ce snippet attend {count, plural, one {# valeur} other {# valeurs}}.",
"fieldLabel": "Valeur de {{name}}",
"copy": "Copier rempli",
"copyRaw": "Copier tel quel",
"cancel": "Annuler",
"panelTitle": "Champs",
"panelHint": "Un champ laissé vide garde la valeur proposée par le texte.",
- "filledCount": "{{filled}} champ(s) rempli(s) sur {{total}}",
+ "filledCount": "{filled, plural, one {# champ rempli} other {# champs remplis}} sur {{total}}",
"show": "Afficher",
"summaryMore": "+{{count}}",
"preview": "Aperçu",
@@ -386,12 +387,12 @@
"closeZoom": "Fermer l'image"
},
"undo": {
- "deleted": "{{count}} note(s) mise(s) à la corbeille",
+ "deleted": "{count, plural, one {# note mise} other {# notes mises}} à la corbeille",
"restore": "Annuler",
"dismiss": "Masquer",
- "moved": "{{count}} note(s) déplacée(s)",
- "tagged": "{{count}} note(s) étiquetée(s)",
- "filed": "{{count}} note(s) rangée(s)"
+ "moved": "{count, plural, one {# note déplacée} other {# notes déplacées}}",
+ "tagged": "{count, plural, one {# note étiquetée} other {# notes étiquetées}}",
+ "filed": "{count, plural, one {# note rangée} other {# notes rangées}}"
},
"file": {
"menuLabel": "Fichier",
@@ -402,15 +403,15 @@
"copyMarkdown": "Copier la sélection en Markdown",
"quit": "Quitter {{app}}",
"quitConfirm": "Confirmer ?",
- "imported": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s).",
- "importedNothing": "Rien à importer : ces {{skipped}} note(s) sont déjà dans votre bibliothèque.",
- "importedFromNewerVersion": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s). {{degraded}} vien(nen)t d’une version plus récente : leur langage ou leur type a été ramené à ce que cette version sait lire.",
- "importedWithAttachments": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s), {{attachments}} pièce(s) jointe(s) restaurée(s).",
- "importedWithoutSomeAttachments": "{{notes}} note(s) importée(s) depuis {{path}}. ⚠️ {{missing}} pièce(s) jointe(s) annoncée(s) par le fichier mais absente(s) : leur aperçu restera vide.",
- "exported": "{{notes}} note(s) exportée(s) vers {{path}}.",
- "exportedWithAttachments": "{{notes}} note(s) et {{attachments}} pièce(s) jointe(s) exportée(s) vers {{path}}.",
- "exportedProtected": "{{notes}} note(s) exportée(s) vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.",
- "exportedProtectedWithAttachments": "{{notes}} note(s) et {{attachments}} pièce(s) jointe(s) exportée(s) vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.",
+ "imported": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}}, {skipped, plural, one {# ignorée} other {# ignorées}}.",
+ "importedNothing": "Rien à importer : {skipped, plural, one {cette note est déjà} other {ces # notes sont déjà}} dans votre bibliothèque.",
+ "importedFromNewerVersion": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}}, {skipped, plural, one {# ignorée} other {# ignorées}}. {degraded, plural, one {# vient} other {# viennent}} d’une version plus récente : leur langage ou leur type a été ramené à ce que cette version sait lire.",
+ "importedWithAttachments": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}}, {skipped, plural, one {# ignorée} other {# ignorées}}, {attachments, plural, one {# pièce jointe restaurée} other {# pièces jointes restaurées}}.",
+ "importedWithoutSomeAttachments": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}}. ⚠️ {missing, plural, one {# pièce jointe annoncée} other {# pièces jointes annoncées}} par le fichier mais {missing, plural, one {absente} other {absentes}} : leur aperçu restera vide.",
+ "exported": "{notes, plural, one {# note exportée} other {# notes exportées}} vers {{path}}.",
+ "exportedWithAttachments": "{notes, plural, one {# note} other {# notes}} et {attachments, plural, one {# pièce jointe exportée} other {# pièces jointes exportées}} vers {{path}}.",
+ "exportedProtected": "{notes, plural, one {# note exportée} other {# notes exportées}} vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.",
+ "exportedProtectedWithAttachments": "{notes, plural, one {# note} other {# notes}} et {attachments, plural, one {# pièce jointe exportée} other {# pièces jointes exportées}} vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.",
"protectTitle": "Protéger cet export ?",
"protectLead": "{{path}} va quitter votre bibliothèque. Une phrase de passe le scelle avec une clé qui lui est propre, et qui voyage avec le fichier plutôt qu’avec cette machine.",
"protectWarning": "⚠️ Sans elle, le fichier emporte toutes les notes et toutes les pièces jointes en clair : quiconque le détient peut les lire.",
@@ -421,14 +422,14 @@
"unlockAction": "Importer",
"cancel": "Annuler",
"working": "En cours…",
- "copied": "{{notes}} note(s) copiée(s) en Markdown dans le presse-papier.",
+ "copied": "{notes, plural, one {# note copiée} other {# notes copiées}} en Markdown dans le presse-papier.",
"needsSelection": "Sélectionnez au moins une note.",
"emptyLibrary": "Rien à exporter : cette bibliothèque n'a aucune note.",
"preferences": "Préférences…",
- "importedIntoFolders": "{{notes}} note(s) importée(s) depuis {{path}} dans {{folders}} nouveau(x) dossier(s), {{skipped}} ignorée(s)."
+ "importedIntoFolders": "{notes, plural, =0 {Aucune note importée} one {# note importée} other {# notes importées}} depuis {{path}} dans {folders, plural, one {# nouveau dossier} other {# nouveaux dossiers}}, {skipped, plural, one {# ignorée} other {# ignorées}}."
},
"shortcuts": {
- "unavailable": "Raccourci(s) global(aux) indisponible(s) : {{list}}. Une autre application les utilise déjà.",
+ "unavailable": "{count, plural, one {Raccourci global indisponible} other {Raccourcis globaux indisponibles}} : {{list}}. Une autre application {count, plural, one {l’utilise} other {les utilise}} déjà.",
"title": "Raccourcis clavier",
"subtitle": "Les touches actives selon l'endroit où vous êtes. Les lettres nues n'agissent que sur le canevas, jamais pendant une saisie.",
"editHint": "Le raccourci de collage rapide se règle dans Fichier → Préférences.",
diff --git a/src/app/core/services/i18n/transloco-loader.spec.ts b/src/app/core/services/i18n/transloco-loader.spec.ts
index 41490b80..51da1122 100644
--- a/src/app/core/services/i18n/transloco-loader.spec.ts
+++ b/src/app/core/services/i18n/transloco-loader.spec.ts
@@ -51,4 +51,33 @@ describe('the translation files', () => {
])('writes {{app}} instead (%s)', (_locale, translations) => {
expect(strings(translations).some((value) => value.includes('{{app}}'))).toBe(true);
});
+
+ /**
+ * ⚠️ "(s)" is not a plural, it is a refusal to choose one — and French does not agree
+ * with English about zero, or about where the mark goes on a past participle. Counting
+ * is the transpiler's job now.
+ */
+ it.each([
+ ['fr', fr],
+ ['en', en],
+ ])('counts with a plural rather than an apologetic "(s)" (%s)', (_locale, translations) => {
+ expect(strings(translations).filter((value) => value.includes('(s)'))).toEqual([]);
+ });
+
+ /**
+ * ⚠️ The messageformat transpiler replaces the default one, so `{` is syntax. A literal
+ * brace in a translated string would have to be escaped as `'{'`, and the failure mode is
+ * silent — the string renders as something else entirely rather than throwing.
+ */
+ it.each([
+ ['fr', fr],
+ ['en', en],
+ ])('leaves no brace that is neither an interpolation nor a plural (%s)', (_locale, translations) => {
+ const suspicious = strings(translations).filter((value) =>
+ // `{{name}}` and `{name…}` are both fine; a lone `{` with nothing after it is not.
+ /\{(?!\{)\s*(?:\}|$)/.test(value),
+ );
+
+ expect(suspicious).toEqual([]);
+ });
});
diff --git a/src/app/core/services/shortcuts/global-shortcuts.service.spec.ts b/src/app/core/services/shortcuts/global-shortcuts.service.spec.ts
index 666423ba..0a8df8ff 100644
--- a/src/app/core/services/shortcuts/global-shortcuts.service.spec.ts
+++ b/src/app/core/services/shortcuts/global-shortcuts.service.spec.ts
@@ -52,9 +52,11 @@ describe('GlobalShortcutsService', () => {
TestBed.tick();
await Promise.resolve();
+ // ⚠️ The count travels beside the list: the sentence agrees three times over, and a
+ // list is not something a translation can count.
expect(TestBed.inject(ErrorNotifier).notice()?.ref).toEqual({
key: 'shortcuts.unavailable',
- params: { list: 'Ctrl+Alt+P' },
+ params: { count: 1, list: 'Ctrl+Alt+P' },
});
});
diff --git a/src/app/core/services/shortcuts/global-shortcuts.service.ts b/src/app/core/services/shortcuts/global-shortcuts.service.ts
index 000a86bf..789a3480 100644
--- a/src/app/core/services/shortcuts/global-shortcuts.service.ts
+++ b/src/app/core/services/shortcuts/global-shortcuts.service.ts
@@ -30,8 +30,14 @@ export class GlobalShortcutsService {
// No `Result` on the Rust side: it throws when the bridge is absent.
const taken = await commands.setGlobalShortcuts(bindings);
if (taken.length > 0) {
+ // ⚠️ The count travels beside the list: the sentence agrees three times over —
+ // the noun, the adjective and the participle — and a list cannot be counted by
+ // the translation.
this.notifier.notify({
- ref: { key: 'shortcuts.unavailable', params: { list: taken.join(', ') } },
+ ref: {
+ key: 'shortcuts.unavailable',
+ params: { count: taken.length, list: taken.join(', ') },
+ },
});
}
} catch {
diff --git a/src/app/notes/canvas/board/board-zone/board-zone.component.html b/src/app/notes/canvas/board/board-zone/board-zone.component.html
index 198d3b61..1099e91a 100644
--- a/src/app/notes/canvas/board/board-zone/board-zone.component.html
+++ b/src/app/notes/canvas/board/board-zone/board-zone.component.html
@@ -23,6 +23,7 @@
class="zone-grip"
data-testid="board-zone-grip"
[attr.aria-label]="'board.moveZone' | transloco: { name: folder.name }"
+ [attr.title]="'board.moveZone' | transloco: { name: folder.name }"
(pointerdown)="headGrabbed.emit($event)"
>
⠿
@@ -51,6 +52,7 @@
aria-haspopup="menu"
[attr.aria-expanded]="menuOpen()"
[attr.aria-label]="'folders.optionsLabel' | transloco: { name: folder.name }"
+ [attr.title]="'folders.optionsLabel' | transloco: { name: folder.name }"
(click)="menuOpen.set(!menuOpen())"
>
⋯
@@ -98,6 +100,7 @@
class="zone-resize"
data-testid="board-zone-resize"
[attr.aria-label]="'board.resizeZone' | transloco: { name: folder.name }"
+ [attr.title]="'board.resizeZone' | transloco: { name: folder.name }"
(pointerdown)="resizeGrabbed.emit($event)"
>
}
diff --git a/src/app/notes/canvas/note-card/note-card-menu/note-card-menu.component.html b/src/app/notes/canvas/note-card/note-card-menu/note-card-menu.component.html
index a5effc7e..2ab2fff6 100644
--- a/src/app/notes/canvas/note-card/note-card-menu/note-card-menu.component.html
+++ b/src/app/notes/canvas/note-card/note-card-menu/note-card-menu.component.html
@@ -6,6 +6,7 @@
aria-haspopup="menu"
[attr.aria-expanded]="menu.open()"
[attr.aria-label]="'notes.noteOptionsLabel' | transloco: { title: noteTitle() }"
+ [attr.title]="'notes.noteOptionsLabel' | transloco: { title: noteTitle() }"
(click)="toggle($event)"
>
⋯
diff --git a/src/app/notes/canvas/note-card/note-card.component.html b/src/app/notes/canvas/note-card/note-card.component.html
index dae098b3..497f470d 100644
--- a/src/app/notes/canvas/note-card/note-card.component.html
+++ b/src/app/notes/canvas/note-card/note-card.component.html
@@ -148,6 +148,7 @@
[class.checked]="checked()"
[attr.aria-pressed]="checked()"
[attr.aria-label]="'notes.selectNote' | transloco: { title: menuTitle }"
+ [attr.title]="'notes.selectNote' | transloco: { title: menuTitle }"
(click)="onCheck($event)"
>
{{ checked() ? '☑' : '☐' }}
@@ -161,6 +162,7 @@
class="card-action card-fill"
data-testid="note-card-fill"
[attr.aria-label]="'placeholders.title' | transloco"
+ [attr.title]="'placeholders.title' | transloco"
(click)="onFill($event)"
>
⧉
diff --git a/src/app/notes/canvas/note-card/note-card.component.spec.ts b/src/app/notes/canvas/note-card/note-card.component.spec.ts
index f9b81fd1..24bf616f 100644
--- a/src/app/notes/canvas/note-card/note-card.component.spec.ts
+++ b/src/app/notes/canvas/note-card/note-card.component.spec.ts
@@ -538,7 +538,7 @@ describe('NoteCardComponent', () => {
await fixture.whenStable();
expect(fixture.nativeElement.querySelectorAll('.card-item')).toHaveLength(2);
- expect(text('.card-items-more')).toBe('+3 autre(s)');
+ expect(text('.card-items-more')).toBe('+3 autres');
});
it('shows no language badge, a checklist having no format to announce', () => {
diff --git a/src/app/notes/header/folder-breadcrumb/folder-breadcrumb.component.html b/src/app/notes/header/folder-breadcrumb/folder-breadcrumb.component.html
index 15398abe..a245e201 100644
--- a/src/app/notes/header/folder-breadcrumb/folder-breadcrumb.component.html
+++ b/src/app/notes/header/folder-breadcrumb/folder-breadcrumb.component.html
@@ -23,6 +23,7 @@
aria-haspopup="menu"
[attr.aria-expanded]="menu.open()"
[attr.aria-label]="'folders.optionsLabel' | transloco: { name: folder().name }"
+ [attr.title]="'folders.optionsLabel' | transloco: { name: folder().name }"
(click)="menu.toggle()"
>
⋯
diff --git a/src/app/notes/header/folder-editor/folder-editor.component.html b/src/app/notes/header/folder-editor/folder-editor.component.html
index 5150d79a..909e1f9d 100644
--- a/src/app/notes/header/folder-editor/folder-editor.component.html
+++ b/src/app/notes/header/folder-editor/folder-editor.component.html
@@ -12,6 +12,7 @@
[class.on]="folder().colour === colour"
[attr.aria-checked]="folder().colour === colour"
[attr.aria-label]="'folders.colour.' + colour | transloco"
+ [attr.title]="'folders.colour.' + colour | transloco"
(click)="pickColour(colour)"
>
}
diff --git a/src/app/notes/header/folder-switcher/folder-switcher.component.html b/src/app/notes/header/folder-switcher/folder-switcher.component.html
index 0ab78056..96947963 100644
--- a/src/app/notes/header/folder-switcher/folder-switcher.component.html
+++ b/src/app/notes/header/folder-switcher/folder-switcher.component.html
@@ -83,6 +83,7 @@
data-testid="folder-edit"
[attr.data-folder-id]="folder.id"
[attr.aria-label]="'folders.optionsLabel' | transloco: { name: folder.name }"
+ [attr.title]="'folders.optionsLabel' | transloco: { name: folder.name }"
(click)="startEditing(folder)"
>
⋯
diff --git a/src/app/notes/header/new-note-button/new-note-button.component.html b/src/app/notes/header/new-note-button/new-note-button.component.html
index cbe513f3..ca4eaf1e 100644
--- a/src/app/notes/header/new-note-button/new-note-button.component.html
+++ b/src/app/notes/header/new-note-button/new-note-button.component.html
@@ -12,6 +12,7 @@
aria-haspopup="menu"
[attr.aria-expanded]="menu.open()"
[attr.aria-label]="'notes.newNoteKindLabel' | transloco"
+ [attr.title]="'notes.newNoteKindLabel' | transloco"
(click)="menu.toggle()"
>
▾
diff --git a/src/app/notes/header/search-box/search-box.component.spec.ts b/src/app/notes/header/search-box/search-box.component.spec.ts
index 4cf44dd8..83cc2389 100644
--- a/src/app/notes/header/search-box/search-box.component.spec.ts
+++ b/src/app/notes/header/search-box/search-box.component.spec.ts
@@ -65,7 +65,7 @@ describe('SearchBoxComponent', () => {
fixture.componentRef.setInput('matched', 12);
await fixture.whenStable();
- expect(matchedText()).toBe('12 résultat(s) ✕');
+ expect(matchedText()).toBe('12 résultats ✕');
expect(fixture.nativeElement.querySelector('.kbd')).toBeNull();
});
@@ -74,7 +74,7 @@ describe('SearchBoxComponent', () => {
fixture.componentRef.setInput('matched', 0);
await fixture.whenStable();
- expect(matchedText()).toBe('0 résultat(s) ✕');
+ expect(matchedText()).toBe('aucun résultat ✕');
});
it('shows the hint again when nothing is being filtered', async () => {
diff --git a/src/app/notes/header/selection-bar/selection-bar.component.html b/src/app/notes/header/selection-bar/selection-bar.component.html
index 336264dd..09e92b1d 100644
--- a/src/app/notes/header/selection-bar/selection-bar.component.html
+++ b/src/app/notes/header/selection-bar/selection-bar.component.html
@@ -71,6 +71,7 @@
class="selection-clear"
data-testid="selection-clear"
[attr.aria-label]="'selection.clear' | transloco"
+ [attr.title]="'selection.clear' | transloco"
(click)="cleared.emit()"
>
✕
diff --git a/src/app/notes/header/space-switcher/space-switcher.component.html b/src/app/notes/header/space-switcher/space-switcher.component.html
index d778f4d5..8c194c0d 100644
--- a/src/app/notes/header/space-switcher/space-switcher.component.html
+++ b/src/app/notes/header/space-switcher/space-switcher.component.html
@@ -82,6 +82,7 @@
data-testid="space-edit"
[attr.data-space-id]="space.id"
[attr.aria-label]="'notes.spaceOptionsLabel' | transloco: { name: space.name }"
+ [attr.title]="'notes.spaceOptionsLabel' | transloco: { name: space.name }"
(click)="startEditing(space)"
>
⋯
diff --git a/src/app/notes/notes-page.component.html b/src/app/notes/notes-page.component.html
index d78066b6..93eed77c 100644
--- a/src/app/notes/notes-page.component.html
+++ b/src/app/notes/notes-page.component.html
@@ -28,6 +28,7 @@
data-testid="library-rail-toggle"
[attr.aria-pressed]="settings.showLibraryRail()"
[attr.aria-label]="'sidebar.toggle' | transloco"
+ [attr.title]="'sidebar.toggle' | transloco"
(click)="toggleLibraryRail()"
>
▤
@@ -168,9 +169,24 @@
} @else if (canvas.isLoading()) {
{{ 'trash.title' | transloco }}
class="trash-close"
data-testid="trash-close"
[attr.aria-label]="'trash.close' | transloco"
+ [attr.title]="'trash.close' | transloco"
(click)="closed.emit()"
>
✕
diff --git a/src/app/notes/overlays/trash-panel/trash-panel.component.spec.ts b/src/app/notes/overlays/trash-panel/trash-panel.component.spec.ts
index b95ee8c2..3c5870c5 100644
--- a/src/app/notes/overlays/trash-panel/trash-panel.component.spec.ts
+++ b/src/app/notes/overlays/trash-panel/trash-panel.component.spec.ts
@@ -63,6 +63,27 @@ describe('TrashPanelComponent', () => {
expect(meta).toContain('30');
});
+ /**
+ * ⚠️ The panel says notes are kept 30 days and this said 31 one line under it. The cause
+ * is not arithmetic on the retention — it is that `ClockService` ticks every 30 s, so the
+ * `now` a card renders against can be **behind** an instant Rust has just stamped. The
+ * gap then reads as 30 days *and change*, and rounding up made the change a whole day.
+ */
+ it('agrees with the retention rule stated above it, on a clock that has not ticked yet', async () => {
+ // Deleted 20 seconds after the clock last looked: `purgeAt` is that plus 30 days.
+ fixture.componentRef.setInput('notes', [
+ trashed({
+ deletedAt: new Date('2026-08-27T09:00:20Z'),
+ purgeAt: new Date('2026-09-26T09:00:20Z'),
+ }),
+ ]);
+ await fixture.whenStable();
+
+ const meta = rows()[0].querySelector('.trash-row-meta')?.textContent ?? '';
+ expect(meta).toContain('30');
+ expect(meta).not.toContain('31');
+ });
+
it('counts the last day as still the user’s', async () => {
fixture.componentRef.setInput('notes', [trashed({ purgeAt: new Date('2026-08-27T23:00:00Z') })]);
await fixture.whenStable();
diff --git a/src/app/notes/overlays/trash-panel/trash-panel.component.ts b/src/app/notes/overlays/trash-panel/trash-panel.component.ts
index bca43282..c6818891 100644
--- a/src/app/notes/overlays/trash-panel/trash-panel.component.ts
+++ b/src/app/notes/overlays/trash-panel/trash-panel.component.ts
@@ -80,9 +80,15 @@ export class TrashPanelComponent {
}
}
-/** Rounded up: "erased in 1 d" while there is any time left. */
+/**
+ * ⚠️ Rounded to the nearest day, and neither of the other two will do. Rounding **up** put
+ * "erased in 31 d" one line under a panel saying notes are kept 30 days: `purgeAt` is
+ * `deletedAt + 30 days`, so a note just deleted has a few milliseconds under 30 left and
+ * any fraction became a whole extra day. Rounding **down** says 29 for the same note, which
+ * is the same contradiction the other way round.
+ */
function purgeRef(purgeAt: Date, now: Date): TranslationRef {
- const days = Math.ceil((purgeAt.getTime() - now.getTime()) / MS_PER_DAY);
+ const days = Math.round((purgeAt.getTime() - now.getTime()) / MS_PER_DAY);
return days <= 0 ? { key: 'trash.purgesToday' } : { key: 'trash.purgesIn', params: { count: days } };
}
diff --git a/src/app/notes/overlays/undo-bar/undo-bar.component.html b/src/app/notes/overlays/undo-bar/undo-bar.component.html
index 832000d5..83f2ae30 100644
--- a/src/app/notes/overlays/undo-bar/undo-bar.component.html
+++ b/src/app/notes/overlays/undo-bar/undo-bar.component.html
@@ -8,6 +8,7 @@
class="undo-dismiss"
data-testid="undo-dismiss"
[attr.aria-label]="'undo.dismiss' | transloco"
+ [attr.title]="'undo.dismiss' | transloco"
(click)="dismissed.emit()"
>
✕
diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.html b/src/app/notes/sidebar/library-tree/library-tree.component.html
index 0246a5ff..1761a764 100644
--- a/src/app/notes/sidebar/library-tree/library-tree.component.html
+++ b/src/app/notes/sidebar/library-tree/library-tree.component.html
@@ -6,6 +6,7 @@