feat(notices): add system notice infrastructure

Server-side notice registry with per-user condition evaluation (firstLogin,
existingUserBeforeVersion, addonEnabled, dateWindow, role, custom).
Notices are sorted by priority then severity, filtered against dismissals
stored in a new user_notice_dismissals table, and served via
GET /api/system-notices/active + POST /api/system-notices/:id/dismiss.

Client renders notices through a host component that partitions by
display type (modal / banner / toast). The modal renderer supports
multi-page pagination with directional slide transitions, keyboard
navigation, and correct dismiss-all semantics on CTA / X / ESC.
Dismissals are optimistic with a single background retry.

Includes 3.0.0 upgrade notices (v3-photos, v3-journey, v3-features),
onboarding welcome modal, and full i18n coverage across 15 languages.
The /journey route is addon-gated on both client and server.

Also includes: unit + integration test suites, registry integrity test
that validates action CTA IDs against client source, and technical
documentation in docs/system-notices.md.
This commit is contained in:
jubnl
2026-04-16 14:36:33 +02:00
parent 3b069bc543
commit 293506217e
46 changed files with 3539 additions and 50 deletions
+754
View File
@@ -0,0 +1,754 @@
# System Notices — Technical Documentation & Dev Guide
System notices are server-evaluated, user-targeted messages shown in the TREK UI as modals, banners, or toasts. They are used for onboarding, upgrade announcements, breaking change warnings, and time-boxed campaigns. Every aspect — targeting, display, copy, and dismissal — is controlled from one place: the server-side registry.
---
## Table of Contents
1. [Architecture overview](#1-architecture-overview)
2. [Data flow](#2-data-flow)
3. [Database schema](#3-database-schema)
4. [The notice registry](#4-the-notice-registry)
5. [Notice fields reference](#5-notice-fields-reference)
6. [Condition system](#6-condition-system)
7. [Display types](#7-display-types)
8. [CTAs (call to action)](#8-ctas-call-to-action)
9. [i18n — translation keys](#9-i18n--translation-keys)
10. [Client store & dismissal](#10-client-store--dismissal)
11. [Sorting & priority](#11-sorting--priority)
12. [How-to recipes](#12-how-to-recipes)
13. [Testing](#13-testing)
14. [Rules & constraints](#14-rules--constraints)
---
## 1. Architecture overview
```
server/src/systemNotices/
├── types.ts — TypeScript types (SystemNotice, NoticeCondition, …)
├── registry.ts — Authoritative list of all notices (edit here to add/change/remove)
├── conditions.ts — Condition evaluators + custom predicate registry
└── service.ts — Queries DB, evaluates conditions, sorts, strips server-only fields
server/src/routes/systemNotices.ts — REST endpoints
client/src/store/systemNoticeStore.ts — Zustand store (fetch + optimistic dismiss)
client/src/components/SystemNotices/
├── SystemNoticeHost.tsx — Renders all three channels (modal / banner / toast)
├── SystemNoticeModal.tsx — Modal renderer (pager, animations, keyboard nav)
├── SystemNoticeBanner.tsx — Banner + toast renderers
└── noticeActions.ts — Client-side action registry for action-kind CTAs
client/src/pages/Trips/noticeActions.ts — Example domain action registration
```
There are **no database rows for notice definitions**. The registry is code-only. The database only stores which notices a user has dismissed.
---
## 2. Data flow
```
1. User authenticates
2. authStore.loadUser() completes
3. SystemNoticeHost mounts → calls useSystemNoticeStore.fetch()
│ (also triggered on cold page reload if store not yet loaded)
4. GET /api/system-notices/active
5. service.getActiveNoticesFor(userId)
├── reads user row (login_count, first_seen_version, role)
├── counts user trips
├── reads user_notice_dismissals
├── filters SYSTEM_NOTICES:
not dismissed
not expired (expiresAt)
all conditions pass (AND logic)
├── sorts by priority → severity → publishedAt (desc)
└── strips server-only fields (conditions, publishedAt, expiresAt, priority)
6. Client receives SystemNoticeDTO[]
7. SystemNoticeHost partitions by display type
├── modal → ModalRenderer (multi-page pager, slide transitions)
├── banner → BannerRenderer (sticky top bar, max 2)
└── toast → ToastRenderer (fires window.__addToast, auto-dismisses)
8. User dismisses → POST /api/system-notices/:id/dismiss
├── Server: INSERT OR IGNORE into user_notice_dismissals
└── Client: optimistic remove from store (retry once on failure)
```
---
## 3. Database schema
Added in **migration 101** (`server/src/db/migrations.ts`).
### `users` columns (added by migration 101)
| Column | Type | Default | Purpose |
|---|---|---|---|
| `first_seen_version` | `TEXT` | `'0.0.0'` | App version at account creation. Used by `existingUserBeforeVersion` condition. Backfilled users get `'0.0.0'`. |
| `login_count` | `INTEGER` | `0` | Incremented on each successful login. Used by `firstLogin` condition. |
### `user_notice_dismissals`
| Column | Type | Notes |
|---|---|---|
| `user_id` | `INTEGER` | FK → `users.id` CASCADE DELETE |
| `notice_id` | `TEXT` | Matches `SystemNotice.id` from registry |
| `dismissed_at` | `INTEGER` | Unix ms timestamp |
Primary key: `(user_id, notice_id)` — dismissals are idempotent.
---
## 4. The notice registry
**`server/src/systemNotices/registry.ts`** is the single source of truth. Add, change, or retire notices here.
```typescript
export const SYSTEM_NOTICES: SystemNotice[] = [
{
id: 'my-notice', // ← globally unique, never reuse
display: 'modal',
severity: 'info',
titleKey: 'system_notice.my_notice.title',
bodyKey: 'system_notice.my_notice.body',
dismissible: true,
conditions: [{ kind: 'firstLogin' }],
publishedAt: '2026-05-01T00:00:00Z',
priority: 50,
},
];
```
### The golden rule for IDs
**Never remove or renumber an entry. Never reuse an ID.**
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry.
---
## 5. Notice fields reference
### Required fields
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Globally unique, stable identifier. Use kebab-case, descriptive, version-scoped when appropriate (`v3-photos`, `welcome-v1`). Max recommended length: 40 chars. |
| `display` | `'modal' \| 'banner' \| 'toast'` | How the notice is rendered. See [§7 Display types](#7-display-types). |
| `severity` | `'info' \| 'warn' \| 'critical'` | Affects colour scheme and accessibility role. `critical` notices cannot be toasts. |
| `titleKey` | `string` | i18n key for the title. |
| `bodyKey` | `string` | i18n key for the body. Markdown supported in modals; plain text only in banners/toasts. |
| `dismissible` | `boolean` | If `false`, the X button and ESC key are hidden/blocked. Use only for `critical` notices that require action before proceeding. |
| `conditions` | `NoticeCondition[]` | Empty array (`[]`) means always shown (same as `[{ kind: 'always' }]`). All conditions must pass (AND logic). |
| `publishedAt` | `string` | ISO 8601 date. Used as a tiebreaker in sorting. Set to the deployment date. |
### Optional fields
| Field | Type | Description |
|---|---|---|
| `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. |
| `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. |
| `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. |
| `bodyParams` | `Record<string, string>` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** |
| `media` | `NoticeMedia` | Image to display in the modal. See below. |
| `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. |
| `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). |
### `NoticeMedia`
```typescript
interface NoticeMedia {
src: string; // URL or path
srcDark?: string; // Optional dark-mode variant
altKey: string; // i18n key for alt text
placement?: 'hero' | 'inline'; // default: 'hero' (full-width above body)
aspectRatio?: string; // CSS aspect-ratio value, default '16/9'
}
```
### Character limits
| Field | Modal | Banner | Toast |
|---|---|---|---|
| Title | ≤ 40 chars | ≤ 40 chars | ≤ 40 chars |
| Body | ≤ 400 chars (markdown) | ≤ 140 chars (plain) | ≤ 80 chars (plain) |
| CTA label | ≤ 20 chars, a verb | ≤ 20 chars | ≤ 20 chars |
---
## 6. Condition system
Conditions are evaluated **server-side** on every `GET /api/system-notices/active` call. The client never sees conditions — only the filtered result.
All conditions in `conditions[]` must pass (AND logic). To implement OR logic, create multiple notices with overlapping IDs is not possible — instead use a `custom` predicate with internal OR logic.
### Built-in conditions
#### `always`
```typescript
{ kind: 'always' }
```
Always passes. Equivalent to an empty `conditions` array.
---
#### `firstLogin`
```typescript
{ kind: 'firstLogin' }
```
Passes when `users.login_count <= 1`. The counter is incremented during login, so this fires on the first fetch after the very first login. Useful for onboarding notices.
---
#### `noTrips`
```typescript
{ kind: 'noTrips' }
```
Passes when the user has zero trips. Often combined with `firstLogin`.
---
#### `existingUserBeforeVersion`
```typescript
{ kind: 'existingUserBeforeVersion', version: '3.0.0' }
```
Passes when:
- `users.first_seen_version < version` (user existed before this version)
- AND the running app version `>= version` (the version has been deployed)
Backfilled/legacy users have `first_seen_version = '0.0.0'` and always pass the first condition. Use this for upgrade announcements targeting users who were around before a breaking change.
---
#### `dateWindow`
```typescript
{ kind: 'dateWindow', startsAt: '2026-06-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }
```
Passes when the current server time is inside `[startsAt, endsAt]`. `endsAt` is optional (open-ended). Use for campaigns, maintenance banners, and time-limited promotions.
---
#### `role`
```typescript
{ kind: 'role', roles: ['admin'] }
// or both roles:
{ kind: 'role', roles: ['admin', 'user'] }
```
Passes when the user's role is in the given list.
---
#### `addonEnabled`
```typescript
{ kind: 'addonEnabled', addonId: 'journey' }
```
Passes when the named addon is enabled in admin settings. Addon IDs are the string values in `server/src/addons.ts` (`ADDON_IDS`). Use this to gate notices that promote features behind an addon.
---
#### `custom`
```typescript
{ kind: 'custom', id: 'my-predicate-id' }
```
Delegates evaluation to a predicate registered server-side with `registerPredicate`. This is the escape hatch for logic not covered by the built-in conditions.
**Registering a custom predicate:**
```typescript
// server/src/systemNotices/conditions.ts exports registerPredicate
import { registerPredicate } from '../systemNotices/conditions.js';
registerPredicate('has-immich-configured', (ctx) => {
// ctx.user = { login_count, first_seen_version, role, noTrips }
// ctx.currentAppVersion = string
// ctx.now = Date
return someDbCheck(ctx.user);
});
```
Register predicates at application startup before the first `getActiveNoticesFor` call.
---
### Combining conditions (AND)
```typescript
conditions: [
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
{ kind: 'addonEnabled', addonId: 'journey' },
]
// Only shows to pre-3.0 users AND only if the journey addon is enabled.
```
---
## 7. Display types
### `modal`
Full-screen overlay with backdrop. On mobile: bottom sheet with drag-to-dismiss. On desktop: centered card.
**Features:**
- Markdown body (via `react-markdown` + `remark-gfm` + `rehype-sanitize`)
- Optional hero or inline image
- Optional highlights list (icon + label bullets)
- Optional CTA button + "Not now" link
- OK button when no CTA is defined
- **Multi-page pager**: when multiple modal notices are active simultaneously, they are rendered as a paginated single modal with prev/next arrows, dot indicators, `N / M` counter, and keyboard arrow navigation
- Slide transition between pages
- ESC to dismiss all (if current notice is dismissible)
- CTA and OK dismiss **all** active modal notices, not just the current page
- "Not now" dismisses only the current page
**Non-dismissible modals** (`dismissible: false`): X button, ESC key, and pager navigation are all disabled until the user acts on the CTA. Use only for `critical` severity.
---
### `banner`
Sticky top bar below the navigation. Slides in with a translate-Y animation.
**Constraints:**
- Maximum 2 banners shown simultaneously (the 2 highest-priority active banners)
- Plain text only (no markdown)
- RTL-aware left-border accent
- Reports its height via a CSS variable `--banner-stack-h` for layout reflow
---
### `toast`
Fires the global `window.__addToast` toast system. Auto-dismisses after 6 s (`info`) or 9 s (`warn`). The notice is dismissed from the store after the toast expires.
**Constraints:**
- `critical` severity is not allowed as a toast — the renderer logs a warning and auto-dismisses it instead
- Plain text only
- No interaction (no CTA rendered via toast)
---
## 8. CTAs (call to action)
A CTA renders as the primary blue button in modals and as an underline link in banners. There are two kinds.
### `nav` — navigate to a route
```typescript
cta: {
kind: 'nav',
labelKey: 'system_notice.my_notice.cta_label',
href: '/journey',
}
```
On click: navigates to `href` using React Router, then **dismisses all active modal notices** (or the current banner notice). The label is resolved through the i18n system.
---
### `action` — run a registered client-side handler
```typescript
cta: {
kind: 'action',
labelKey: 'system_notice.my_notice.cta_label',
actionId: 'open:trip-create',
dismissOnAction: true, // default true — set false to keep notice open after action
}
```
On click: looks up `actionId` in the client-side action registry and calls the handler, then **dismisses all active modal notices**.
**To add a new action:**
1. Create (or extend) a `noticeActions.ts` file in the relevant feature directory:
```typescript
// client/src/pages/MyFeature/noticeActions.ts
import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
registerNoticeAction('open:my-feature', ({ navigate }) => {
navigate('/my-feature?from=notice');
});
```
2. Import it as a side-effect in `client/src/App.tsx`:
```typescript
import './pages/MyFeature/noticeActions.js'
```
3. The registry integrity test (`server/tests/unit/systemNotices/registry.test.ts`) automatically scans all `noticeActions.ts` files and verifies that every `actionId` in the registry is registered. The test will fail if you add an `actionId` to the registry without registering it on the client.
**Action handler signature:**
```typescript
(ctx: NoticeActionContext) => void | Promise<void>
interface NoticeActionContext {
navigate: NavigateFunction; // React Router navigate function
}
```
### Dismiss behaviour summary
| Trigger | What is dismissed |
|---|---|
| X button (modal) | All active modal notices |
| ESC key | All active modal notices (if current is dismissible) |
| CTA button | All active modal notices |
| OK button (no CTA) | All active modal notices |
| "Not now" link | Current page only |
| Banner dismiss (X) | That banner only |
| Backdrop click (modal) | Current page only |
| Swipe down (mobile) | Current page only |
| Toast expires | That toast only |
---
## 9. i18n — translation keys
Every notice field that is user-visible (`titleKey`, `bodyKey`, CTA `labelKey`, highlight `labelKey`, media `altKey`) is an i18n key resolved through `useTranslation().t()`. The key string is what gets stored in the registry; the display value lives in the translation files.
**Translation files location:** `client/src/i18n/translations/` (15 files: `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `cs`, `hu`, `ru`, `zh`, `zhTw`, `ar`, `br`, `id`)
### Key naming convention
```
system_notice.<notice_id_snake>.<field>
```
Examples:
```
system_notice.welcome_v1.title
system_notice.welcome_v1.body
system_notice.welcome_v1.cta_label
system_notice.welcome_v1.highlight_plan
system_notice.welcome_v1.hero_alt
```
### Adding keys
Add the English key to `client/src/i18n/translations/en.ts` first, then replicate to the other 14 files. Group related notice keys together with a comment:
```typescript
// System notices — my feature
'system_notice.my_notice.title': 'My feature is here',
'system_notice.my_notice.body': 'Here is what changed.',
'system_notice.my_notice.cta_label': 'Explore',
```
### `bodyParams` interpolation
For values that vary at runtime (version numbers, dates, counts), use `{placeholder}` syntax in the translation string and pass `bodyParams` in the registry entry:
```typescript
// In registry:
bodyKey: 'system_notice.my_notice.body',
bodyParams: { version: '3.1.0', date: '1 May 2026' },
// In en.ts:
'system_notice.my_notice.body': 'TREK {version} was released on {date}.',
```
**Never hardcode dynamic values directly in translation strings.** The interpolation runs client-side in `ModalRenderer` before rendering.
### Multiline bodies (modals only)
Use `\n\n` (escaped, not literal newlines) for paragraph breaks in modal body strings:
```typescript
'system_notice.my_notice.body': 'First paragraph.\n\nSecond paragraph.',
```
Literal newlines in single-quoted TypeScript strings cause a parse error.
### Pager i18n keys
The pager UI uses its own keys (already present in all 15 files):
```
system_notice.pager.prev → "Previous notice"
system_notice.pager.next → "Next notice"
system_notice.pager.counter → "{current} / {total}"
system_notice.pager.goto → "Go to notice {n}"
system_notice.pager.position → "Notice {current} of {total}" (aria-live)
```
---
## 10. Client store & dismissal
`client/src/store/systemNoticeStore.ts` (Zustand, no persistence).
| Action | Behaviour |
|---|---|
| `fetch()` | `GET /api/system-notices/active`. Fails silently (non-critical). Sets `loaded = true` regardless. |
| `dismiss(id)` | Optimistic: removes notice from store immediately. POSTs to `/api/system-notices/{id}/dismiss` in background with one retry on failure. |
`SystemNoticeHost` triggers `fetch()` on mount if `loaded === false`. Auth store also triggers it after login, so on a fresh login the fetch happens exactly once.
---
## 11. Sorting & priority
Notices are sorted before being sent to the client. The sort order is:
1. **`priority`** (descending) — primary key. Higher number appears first.
2. **`severity`** (descending) — tiebreaker: `critical` (2) > `warn` (1) > `info` (0).
3. **`publishedAt`** (descending) — final tiebreaker: more recent notices first.
This means `priority` always wins over severity. Assign priorities deliberately so the intended reading order is preserved when multiple notices are active simultaneously.
Current priority allocations in the registry:
| Range | Use |
|---|---|
| 100 | Onboarding / first-login |
| 8090 | Major version upgrade notices |
| 5070 | Feature announcements |
| 1040 | Campaigns, banners |
| 0 (default) | Miscellaneous |
---
## 12. How-to recipes
### Add a new modal notice
1. **Registry** — add an entry to `SYSTEM_NOTICES` in `server/src/systemNotices/registry.ts`:
```typescript
{
id: 'my-feature-v2',
display: 'modal',
severity: 'info',
icon: 'Zap',
titleKey: 'system_notice.my_feature_v2.title',
bodyKey: 'system_notice.my_feature_v2.body',
highlights: [
{ labelKey: 'system_notice.my_feature_v2.highlight_one', iconName: 'Check' },
],
cta: {
kind: 'nav',
labelKey: 'system_notice.my_feature_v2.cta_label',
href: '/my-feature',
},
dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '2.0.0' }],
publishedAt: '2026-06-01T00:00:00Z',
priority: 60,
},
```
2. **i18n** — add keys to `client/src/i18n/translations/en.ts` and the 14 other language files.
3. **Test** — run `cd server && npx vitest run tests/unit/systemNotices/` to verify registry integrity.
---
### Add a notice with an action CTA
1. Create the action handler in the relevant feature directory:
```typescript
// client/src/pages/MyFeature/noticeActions.ts
import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
registerNoticeAction('open:my-feature-dialog', ({ navigate }) => {
navigate('/my-feature?dialog=welcome');
});
```
2. Import it in `client/src/App.tsx`:
```typescript
import './pages/MyFeature/noticeActions.js'
```
3. Reference the `actionId` in the registry:
```typescript
cta: {
kind: 'action',
labelKey: 'system_notice.my_notice.cta_label',
actionId: 'open:my-feature-dialog',
},
```
The registry integrity test will catch any `actionId` that appears in the registry but lacks a `registerNoticeAction` call.
---
### Retire a notice (stop showing it)
**Do not delete the entry.** Set `expiresAt`:
```typescript
{
id: 'old-campaign',
// ... all existing fields unchanged ...
expiresAt: '2026-07-01T00:00:00Z',
}
```
After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless.
---
### Show a notice only during a campaign window
Combine `dateWindow` with any other targeting conditions:
```typescript
conditions: [
{ kind: 'dateWindow', startsAt: '2026-06-15T00:00:00Z', endsAt: '2026-06-30T23:59:59Z' },
{ kind: 'role', roles: ['admin'] },
],
```
---
### Show a notice only if an addon is enabled
```typescript
conditions: [
{ kind: 'addonEnabled', addonId: 'journey' },
],
```
Addon IDs are the string values in `server/src/addons.ts``ADDON_IDS`.
---
### Add a custom condition
```typescript
// server/src/startup.ts (or wherever your bootstrap code runs)
import { registerPredicate } from './systemNotices/conditions.js';
registerPredicate('has-no-profile-photo', (ctx) => {
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(ctx.user.id);
return !row?.avatar;
});
```
Then reference it in the registry:
```typescript
conditions: [{ kind: 'custom', id: 'has-no-profile-photo' }],
```
---
### Create a multipage upgrade announcement
Give multiple notices the same `conditions` and adjacent `priority` values. The pager groups all active modal notices together automatically — no extra wiring required.
```typescript
// Page 1 — breaking change (higher priority, warn severity)
{ id: 'v4-breaking', priority: 90, severity: 'warn', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... },
// Page 2 — new feature (lower priority, info severity)
{ id: 'v4-feature', priority: 80, severity: 'info', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... },
```
Users who have already dismissed page 1 will only see page 2 on their next session.
---
## 13. Testing
### Server unit tests
**`server/tests/unit/systemNotices/conditions.test.ts`**
Tests each condition kind in isolation using `evaluate()` directly. No DB required.
**`server/tests/unit/systemNotices/registry.test.ts`**
Validates registry integrity:
- No duplicate `id` values
- All `action` CTA `actionId`s have a corresponding `registerNoticeAction()` call in the client source (scanned via regex — no JSON file needed)
- All `publishedAt` values parse as valid ISO dates
Run: `cd server && npx vitest run tests/unit/systemNotices/`
**`server/tests/integration/systemNotices.test.ts`**
Integration tests against a real in-memory SQLite database:
- `GET /api/system-notices/active` returns 401 without auth, returns correct notices per user state
- `POST /api/system-notices/:id/dismiss` stores the dismissal and filters on subsequent requests
- Dismissing an unknown ID returns 404
Run: `cd server && npx vitest run tests/integration/systemNotices.test.ts`
---
### Client unit tests
**`client/src/components/SystemNotices/SystemNoticeModal.test.tsx`**
Tests `ModalRenderer` with fake timers (`vi.useFakeTimers()`) and MSW for the dismiss endpoint. Key helpers:
```typescript
// Flush the 500 ms grace delay that gates the modal's visible state
async function flushGraceDelay() {
await act(async () => { vi.runAllTimers(); });
}
// Minimal notice factory
function makeNotice(overrides?: Partial<SystemNoticeDTO>): SystemNoticeDTO
```
Covered cases (FE-SN-MODAL-001 to 018):
- Grace delay before visibility
- Dismiss button, X button, ESC key
- Non-dismissible notices (all affordances blocked)
- CTA nav button — dismisses all notices
- Body param interpolation
- Pager: counter, dots, prev/next buttons, keyboard arrows, dot click, non-dismissible lock
- Dismiss-does-not-skip regression
- X and ESC dismiss all in multipage scenario
- Last notice close
Run: `cd client && npm run test -- SystemNoticeModal`
---
### Running all notice tests
```bash
cd server && npx vitest run tests/unit/systemNotices/ tests/integration/systemNotices.test.ts
cd client && npm run test -- SystemNoticeModal
```
---
## 14. Rules & constraints
| Rule | Reason |
|---|---|
| Never delete or reuse a notice `id` | Dismissal records are keyed by `id`. Deletion causes dismissed users to see the notice again. |
| Never use literal newlines in translation strings | Single-quoted TS strings with literal newlines cause esbuild parse errors. Use `\n\n` (escaped). |
| Never hardcode version numbers or dates in translation strings | Use `bodyParams` so strings stay translatable without retranslation per release. |
| `critical` severity must have `dismissible: false` | `critical` toasts are auto-dismissed with a warning; a dismissible critical modal is inconsistent UX. |
| `critical` must not use `display: 'toast'` | The toast renderer logs a warning and auto-dismisses critical toasts rather than showing them. |
| CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. |
| Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. |
| `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. |
| `expiresAt` over deletion for retiring notices | See above. |