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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>`, silently invalidating every variable. Shared SCSS patterns are mixins in `src/styles/_mixins.scss`, imported as `@use 'mixins' as *;`.
Expand Down
25 changes: 23 additions & 2 deletions e2e/specs/06-search-and-filters.e2e.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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', () => {
/**
Expand All @@ -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 () => {
Expand Down
12 changes: 11 additions & 1 deletion src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/app/banners/error-banner/error-banner.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
type="button"
class="error-dismiss"
[attr.aria-label]="'errors.dismiss' | transloco"
[attr.title]="'errors.dismiss' | transloco"
(click)="notifier.dismiss()"
>
<span aria-hidden="true">✕</span>
Expand Down
1 change: 1 addition & 0 deletions src/app/banners/status-toast/status-toast.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
type="button"
class="status-dismiss"
[attr.aria-label]="'errors.dismiss' | transloco"
[attr.title]="'errors.dismiss' | transloco"
(click)="notifier.dismiss()"
>
<span aria-hidden="true">✕</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
78 changes: 78 additions & 0 deletions src/app/core/services/i18n/plural-transpiler.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => expandPlurals(source, params, 'fr');
const en = (source: string, params: Record<string, unknown> = {}) => 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.');
});
});
126 changes: 126 additions & 0 deletions src/app/core/services/i18n/plural-transpiler.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const cases = new Map<string, string>();
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;
}
Loading