diff --git a/CLAUDE.md b/CLAUDE.md index 48047bc..b90d0a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **A global shortcut is first-come, first-served across the machine, and the loser gets no error.** The palette is on `Ctrl+Alt+P` because `Ctrl+Alt+Space` is taken by widely installed applications. `desktop::init` records what it could not take, and `unavailable_shortcuts` lets the front say so at startup — a log line is not an interface. `init` is also where every piece of native state is managed, before any command can run. - **Every library operation reports, including when it changed nothing.** Exporting then re-importing at once imports zero notes (every id is already there) and that is correct; without `file.importedNothing` it is indistinguishable from a failure. Reports go to `StatusNotifier` (`core/services/notifications/`) and render under the titlebar — not inside the menu, which closes on the click and which a native dialog covers. - **The File menu owns its entries.** `FileMenuComponent` declares them as an array and injects what they need (`LibraryStore`, `NoteSelectionStore`, `SpacesStore`, `ClockService`); the array's order is the order on screen. There were three contribution registries here — menu, settings pages, shortcut groups — so `layout/` could stay ignorant of a feature; with one feature they protected nothing and hid what a button did behind a registration, so they are gone. Same for the preferences panel, which lists its two pages, and the shortcuts sheet, which builds the global group and imports the notes ones from `notes/ui/notes-shortcuts.ts` — the canvas group is derived from the key table that binds it, so a key cannot be documented without being bound. The trash and tag management deliberately stay **out** of the menu — they are views on the notes, so they sit next to the quick filters and at the end of the tag rail respectively. "Préférences…" and "Quitter", which act on the application rather than on a tool, are static entries in the menu itself. +- **A silenced update is a version written down, not a "no".** `AppSettings.skippedUpdate` holds the version the user set aside from the prompt's checkbox; the release after it is a new offer and needs no gesture. `updateNotifications` is the blunter switch beside it, and both only silence the **silent** startup check — `checkNow()` always answers. ⚠️ The dot in the About menu reads `UpdateStore.hasPendingUpdate`, a third thing next to `UpdateStatus` and `CheckState`: `dismiss()` returns the status to `idle`, so a dot driven by it would disappear with the dialog it exists to outlive. That is also why `dismiss()` leaves the update standing and moves only the status. - **A preference applies as it is typed, and three services carry it to the native side.** `SettingsStore` (`core/services/settings/`) writes through `PreferencesService` — one key per setting, never one serialised object — and talks to nobody; `GlobalShortcutsService`, `WindowBehaviorService` and `AutostartService` read its signals in an `effect` and push. There is no "OK / Annuler" anywhere in the panel, on purpose. ⚠️ Each of those `effect`s is built with an **explicit injector**: they start from `provideAppInitializer`, outside any constructor. And that initializer injects **everything before its first `await`** — an `inject()` after one is out of the injection context, and the app fails to bootstrap (NG0203) with a black window and nothing else. - **The default shortcuts are a mirror, in two files.** `ShortcutBindings::defaults()` (`desktop.rs`) and `DEFAULT_SHORTCUTS` (`core/services/shortcuts/shortcut.model.ts`) carry the same three accelerators. The duplication is deliberate and commented on both sides: the native side takes them before the front has started, and without them `Ctrl+Alt+P` is dead for the length of the first render — exactly the second it is used from another application. `set_global_shortcuts` re-registers all three from scratch and **returns what it could not take**; the front is what says so. - **A shortcut is captured, not typed.** `acceleratorFromEvent` reads `KeyboardEvent.code` (the physical key, so a combination set on AZERTY stays put on QWERTY) and refuses a keystroke with no modifier — a _global_ shortcut without one would swallow that key in every application on the machine. That refusal is also what leaves Tab and Escape working inside the field. diff --git a/docs/architecture.md b/docs/architecture.md index 06b4e0e..e71c8ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1897,6 +1897,22 @@ update. lifecycle that `UpdatePromptComponent.busy()` reads; the former is only what the menu has left to announce. Its `idle` covers both "not checked yet" and "found something" — in the second case the prompt is doing the talking. +- **"Later" can be made to stick, and what is written down is a version.** The prompt + carries a checkbox, because the decision belongs where the interruption happens. + `AppSettings.skippedUpdate` holds the version that was set aside — storing "the user said + no" would silence the release after it too, and a newer one is a new offer that needs no + gesture to become one again. `AppSettings.updateNotifications` is the blunter switch next + to it, and the preferences panel names the silenced version with a way to take it back, + since waiting for the next release is not one. + ⚠️ Only the **silent** startup check is silenced. `checkNow()` is a question asked out + loud and always answers, which is also what stops the feature from being a trap. +- **The dot is a third thing, next to `UpdateStatus` and `CheckState`.** `hasPendingUpdate` + is "an update exists and is not installed", and the About menu wears it. It cannot read + `status()`: `dismiss()` returns that to `idle` — which is what closes the prompt — so a dot + driven by it would vanish with the dialog it exists to outlive. For the same reason + `dismiss()` leaves `_update` standing and moves only the status; `offered` is the computed + the prompt reads, and it is `null` exactly when the status is `idle`, so the dialog stays + up through the install it would otherwise disappear from. - **The download does not cross the CSP.** It runs in Rust through the plugin's HTTP client, not in the WebView, so pointing `endpoints` at GitHub needs no widening of `connect-src`. - `bundle.createUpdaterArtifacts` makes the bundler emit a `.sig` beside **every** bundle it diff --git a/src/app/banners/update-prompt/update-prompt.component.html b/src/app/banners/update-prompt/update-prompt.component.html index f3e32f7..8e22df2 100644 --- a/src/app/banners/update-prompt/update-prompt.component.html +++ b/src/app/banners/update-prompt/update-prompt.component.html @@ -1,4 +1,4 @@ -@if (store.update(); as update) { +@if (store.offered(); as update) {

@@ -30,8 +30,22 @@

[attr.aria-label]="'update.progressLabel' | transloco" > } @else { + + +
- @@ -21,7 +27,15 @@ [attr.aria-disabled]="checking()" (click)="checkUpdates()" > - {{ 'about.checkUpdates' | transloco }} + + + {{ 'about.checkUpdates' | transloco }} + @if (store.hasPendingUpdate()) { + + {{ 'about.updateWaiting' | transloco }} + } + diff --git a/src/app/titlebar/about-menu/about-menu.component.scss b/src/app/titlebar/about-menu/about-menu.component.scss index b8c3124..7b3c3eb 100644 --- a/src/app/titlebar/about-menu/about-menu.component.scss +++ b/src/app/titlebar/about-menu/about-menu.component.scss @@ -89,3 +89,29 @@ margin: 4px 6px; background: var(--line-soft); } + +// An update that was silenced still exists, and this is all that says so. +// ⚠️ Green, not the accent. Amber is what this interface already means "active" and +// "selected" with — the space rail wears an amber dot of its own two rows below — and a +// badge in the accent colour reads as one more state rather than as news. +.about-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green); + flex: none; +} + +// On the trigger it is a badge: superimposed on the corner rather than wedged between +// the label and the chevron, which are one control and read as one. +.about-dot.on-trigger { + position: absolute; + top: 1px; + right: 1px; +} + +.about-option-label { + display: flex; + align-items: center; + gap: 6px; +} diff --git a/src/app/titlebar/about-menu/about-menu.component.spec.ts b/src/app/titlebar/about-menu/about-menu.component.spec.ts index ccba990..384b1e5 100644 --- a/src/app/titlebar/about-menu/about-menu.component.spec.ts +++ b/src/app/titlebar/about-menu/about-menu.component.spec.ts @@ -60,6 +60,50 @@ describe('AboutMenuComponent', () => { fixture.autoDetectChanges(); }); + describe('the update dot', () => { + const dot = (): HTMLElement | null => fixture.nativeElement.querySelector('[data-testid="update-dot"]'); + + it('is absent while there is nothing waiting', () => { + expect(dot()).toBeNull(); + }); + + /** + * ⚠️ The point of the dot. `dismiss()` returns the status to `idle`, which is what + * closes the prompt — a dot reading that state would disappear with the dialog it + * exists to outlive. + */ + it('stays after the prompt has been dismissed', async () => { + updater.available = { version: '0.2.0', currentVersion: '0.1.0' }; + await store.check(); + await fixture.whenStable(); + expect(dot()).not.toBeNull(); + + await store.dismiss(true); + await fixture.whenStable(); + + expect(dot()).not.toBeNull(); + }); + + it('marks the entry the dot is about, once the menu is open', async () => { + updater.available = { version: '0.2.0', currentVersion: '0.1.0' }; + await store.check(); + await store.dismiss(true); + await openMenu(); + + const marked = option('mises à jour'); + + expect(marked.querySelector('[data-testid="update-dot-option"]')).not.toBeNull(); + }); + + it('carries a text twin, since it says something no word does', async () => { + updater.available = { version: '0.2.0', currentVersion: '0.1.0' }; + await store.check(); + await fixture.whenStable(); + + expect(trigger().querySelector('.visually-hidden')?.textContent?.trim()).not.toBe(''); + }); + }); + it('keeps the menu closed until asked', () => { expect(fixture.debugElement.query(By.css('.about-dropdown'))).toBeNull(); expect(trigger().getAttribute('aria-expanded')).toBe('false'); diff --git a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.html b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.html index a1cc1e2..8d5d4de 100644 --- a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.html +++ b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.html @@ -150,5 +150,30 @@

{{ 'settings.notifications.title' | transloco }} (change)="onCopyConfirmation($event)" />

+ +
+ + +
+ + + @if (settings.skippedUpdate(); as skipped) { +

+ {{ 'settings.notifications.updateSkipped' | transloco: { version: skipped } }} + +

+ } diff --git a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.scss b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.scss index a6f0b83..e159318 100644 --- a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.scss +++ b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.scss @@ -72,3 +72,21 @@ outline: 2px solid var(--amber); outline-offset: 1px; } + +// Inside the note, not beside it: what it undoes is named in the sentence before it. +.setting-inline-btn { + @include unstyled-control; + color: var(--amber); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.setting-inline-btn:hover { + color: var(--text-0); +} + +.setting-inline-btn:focus-visible { + outline: 2px solid var(--amber); + outline-offset: 2px; +} diff --git a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.spec.ts b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.spec.ts index baac53e..91330ad 100644 --- a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.spec.ts +++ b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.spec.ts @@ -84,6 +84,47 @@ describe('SettingsPageComponent', () => { expect(settings.copyConfirmation()).toBe(false); }); + describe('the update entry', () => { + const note = (): HTMLElement | null => + fixture.nativeElement.querySelector('[data-testid="setting-skipped-update"]'); + + it('says nothing while nothing has been silenced', () => { + expect(note()).toBeNull(); + }); + + it('names the silenced version, so the row has a subject', async () => { + settings.setSkippedUpdate('0.1.5'); + await fixture.whenStable(); + + expect(note()?.textContent).toContain('0.1.5'); + }); + + it('takes the skip back without waiting for the next release', async () => { + settings.setSkippedUpdate('0.1.5'); + await fixture.whenStable(); + + note()?.querySelector('button')?.click(); + await fixture.whenStable(); + + expect(settings.skippedUpdate()).toBe(''); + expect(note()).toBeNull(); + }); + + /** Asking to be told about updates is exactly what taking a skip back means. */ + it('forgets the skipped version when notifications are turned back on', async () => { + settings.setSkippedUpdate('0.1.5'); + toggle('setting-update-notifications').click(); + await fixture.whenStable(); + expect(settings.updateNotifications()).toBe(false); + + toggle('setting-update-notifications').click(); + await fixture.whenStable(); + + expect(settings.updateNotifications()).toBe(true); + expect(settings.skippedUpdate()).toBe(''); + }); + }); + it('records a shortcut from the keystroke rather than from typed text', async () => { press({ code: 'KeyK', ctrlKey: true, shiftKey: true }); await fixture.whenStable(); diff --git a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.ts b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.ts index 4b6499b..b2a4505 100644 --- a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.ts +++ b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.ts @@ -39,6 +39,14 @@ export class SettingsPageComponent { protected readonly densities = DENSITIES; protected readonly defaultShortcut = DEFAULT_SHORTCUTS.palette; + protected onUpdateNotifications(event: Event): void { + this.settings.setUpdateNotifications(checkedValue(event)); + } + + protected forgetSkippedUpdate(): void { + this.settings.setSkippedUpdate(''); + } + protected onLocale(event: Event): void { this.settings.setLocale(selectedValue(event) as LocaleChoice); }