33 KiB
AGENTS.md — AdGuard Home client_v2 (SolidJS Frontend)
Table of Contents
- AGENTS.md — AdGuard Home client_v2 (SolidJS Frontend)
- Project Overview
- Technical Context
- Project Structure
- Build And Test Commands
- Contribution Instructions
- Code Guidelines
Project Overview
client_v2 is the next-generation web UI for AdGuard Home, written in
SolidJS and TypeScript. It is a multi-entry single-page application that
runs in the browser and communicates with the AdGuard Home backend over its
HTTP /control JSON API.
The app ships four separate HTML entry points that share the same codebase, styles, and theme system:
main— the dashboard / control panel (HashRouter SPA)install— the first-run setup wizardlogin— the sign-in pageforgot_password— the password-reset flow
It replaces the legacy React-based client (client/) and is built with
Webpack, styled with PostCSS + CSS Modules, and tested with Vitest (unit) and
Playwright (e2e).
Technical Context
- Language/Version: TypeScript 5.4+ (
strict: false,noImplicitAny: true) - UI Framework: SolidJS 1.9 (
solid-js,@solidjs/router0.15 withHashRouter) - UI Primitives:
@ark-ui/solid(headless components),@modular-forms/solid(form state) - Build Tooling: Webpack 5 (
webpack.common.js/webpack.dev.js/webpack.prod.js), Babel (babel-preset-solid), PostCSS - Styling: PostCSS (
postcss-import,postcss-nested,autoprefixer) + CSS Modules (.module.pcss); design tokens via CSS custom properties - State: SolidJS
createStoremodule-scoped stores (no Redux, no Context providers) - HTTP: Native
fetchwrapped in a singleApiclass - i18n:
@adguard/translatewith 35 bundled locales fromsrc/__locales/(generated from.twosky.jsonvianpm run locales:generate) - Testing: Vitest 4 +
@solidjs/testing-library(unit); Playwright 1.56 (e2e against a real backend) - Linting/Formatting: ESLint 8 (
@typescript-eslint,eslint-plugin-solid,eslint-plugin-prettier), Prettier 3 - Storage: Browser
localStorage(language, theme); no database - Target Platform: Modern evergreen browsers (see
browserslistinpackage.json); output is static assets served by the Go backend - Project Type: Website (single, browser-resident SPA)
- Performance Goals: N/A (not formally documented)
- Constraints: Output is embedded into the Go binary's
build/static/; the dev server proxies to a running AdGuard Home instance - Scale/Scope: Single-user admin UI (one operator per AdGuard Home installation)
Project Structure
client_v2/
├── src/
│ ├── index.tsx # Main app entry (renders <App/>)
│ ├── index.pcss # Global CSS (vars, reset)
│ ├── initialState.ts # Domain model types + initial form state
│ ├── api/ # Orval-generated typed client + customFetch mutator
│ ├── common/
│ │ ├── controls/ # Input primitives (Checkbox, Input, Select, Switch, …)
│ │ ├── ui/ # Higher-level UI (Button, Dialog, Table, Tabs, Sidebar, …)
│ │ ├── intl/ # i18n setup (@adguard/translate)
│ │ └── styles/ # Theme tokens (vars.css, colors/light|dark|adg.css)
│ ├── components/ # Feature/page components (Dashboard, Clients, QueryLog, …)
│ │ ├── App/ # Root component — HashRouter + layout + <Route>s
│ │ ├── Routes/Paths.ts # Route path constants
│ │ └── Stats/ # Dashboard "Show more" detail pages (top clients, domains, upstreams)
│ ├── helpers/ # Pure utilities, validators, theme helpers
│ ├── hooks/ # Shared SolidJS hooks
│ ├── lib/ # Theme aggregation, misc utils
│ ├── store/types.ts # Legacy type re-exports (post-Redux)
│ ├── stores/ # SolidJS createStore modules — one per domain
│ ├── types/ # css-modules.d.ts ambient declarations
│ ├── login/ # Standalone login entry
│ ├── install/ # Standalone install-wizard entry
│ ├── forgot_password/ # Standalone password-reset entry
│ ├── __locales/ # Translation JSON (en, ru, de, …)
│ └── __tests__/ # Vitest unit/integration tests
├── tests/e2e/ # Playwright e2e specs
├── scripts/ # check-translations.js, translation-audit.js
├── webpack.common.js # 4 entry points, loaders, path aliases
├── webpack.dev.js # Dev server + backend proxy
├── webpack.prod.js # Production build
├── tsconfig.json # Path aliases: panel/* → src/*, Twosky → ../.twosky.json
├── vitest.config.ts # jsdom + vite-plugin-solid
├── playwright.config.ts # e2e config (base URL :3000)
├── postcss.config.js # postcss-import + nested + autoprefixer
├── package.json # Scripts + dependencies
└── constants.js # BUILD_ENVS, BASE_URL = 'control'
Build And Test Commands
All commands are run from the client_v2/ directory. If your terminal's
working directory is the repo root (or anywhere else), cd client_v2 first —
never run npm/npx from the repo root.
| Task | Command |
|---|---|
| Dev server (proxies to backend) | npm run dev |
| Production build | npm run build-prod |
| Dev build (no server) | npm run build-dev |
| Watch build | npm run watch |
| Type check | npm run typecheck |
| Lint | npm run lint |
| Lint + format fix | npm run lint:fix |
| Unit tests (single run) | npm run test |
| Unit tests (single file) | npm run test -- <path> |
| Unit tests (watch) | npm run test:watch |
| E2e tests | npm run test:e2e |
| E2e interactive UI | npm run test:e2e:interactive |
| Locale codegen | npm run locales:generate |
| Locale freshness check | npm run locales:check |
| Translation check | npm run translations:check |
| Full check (lint + typecheck + test) | npm run check |
Note
:
npm run testruns the entire suite. While iterating, run a single file withnpm run test -- <path>, optionally filtered to one test by name with-t "<name>"(a regex matching test names containing the pattern). Runnpm run checkonly once, at the end — see "Fast verification loop" below.
Fast verification loop
While iterating, run the focused command and read its result directly: the terminal tool reports the exit code, and any output over 20 KiB is saved to a temp file whose path you can search.
# If your terminal's working directory is the repo root, cd client_v2 first.
npm run typecheck
npm run test -- src/__tests__/stats-pages/stats-page.test.tsx -t "<name>"
Replace the path with the file you are working on. Do not chain these into
... > log 2>&1; echo "exit:$?"; tail log one-liners: the trailing tail
becomes the reported exit code, the redirect hides the output, and the line
falls outside the terminal allow-list, so every run needs manual approval.
Only slow commands you will re-read — the full npm run check,
npm run build-prod — may be capped to one stable log file, reused across
runs; confirm the command actually ran before trusting its log. A stale log
from an earlier run is the easiest way to "verify" a fix that was never
checked.
Contribution Instructions
-
You MUST verify your work with the linter, formatter, and type checker.
Run only the npm scripts, from the
client_v2/directory. Do not invoke the underlying binaries directly (npx eslint ...,npx tsc,npx vitest,node_modules/.bin/..., etc.) — the scripts are the only supported commands. In particular, never runnpxfrom the repo root: the repository has no rootpackage.json, sonpxtries to download the tooling from the npm registry and can hang for minutes. Ad-hocnpxcalls can also install tooling into the repo root instead ofclient_v2/node_modules:npm run check— the full gate (lint + typecheck + unit tests)npm run lintto run the linternpm run lint:fixto fix linting and formatting issues automaticallynpm run typecheckto check for type errorsnpm run testto run the unit testsnpm run test -- <path> [-t "<name>"]to run a single test file, optionally filtered to one test by name — see "Fast verification loop"
-
You MUST update the unit tests for changed code. New stores, helpers, and components should have corresponding tests under
src/__tests__/. -
You MUST run
npm run checkafter completing your changes: lint, typecheck, and all unit tests must pass before the task is done. -
You MUST verify UI changes against the running AdGuard Home instance, not the webpack dev server. Rebuild with
npm run build-dev(quick iteration) ornpm run build-prod(final check) — both emit to../build/static/— then openhttp://127.0.0.1:<bind_port>/, wherebind_portcomes from the localAdGuardHome.yaml(commonly3001or80). Do not use webpack's default port8080; the dev server, when used, runs onbind_port + 8000. -
When you need to sign in to the running AdGuard Home instance to verify UI changes in the browser, read the credentials from
client_v2/.env.local(git-ignored; keysADGUARD_TEST_USERNAME/ADGUARD_TEST_PASSWORD— relative toclient_v2/it is.env.local). Never echo the password into the chat, terminal output, or log files, and never commit or copy the file elsewhere. If the file is missing, ask the user for the login and password with#tool:vscode/askQuestions— do not guess credentials or reuse hardcoded ones. -
When making changes to the project structure, ensure the Project Structure section in
AGENTS.mdis updated and remains valid. -
If the prompt essentially asks you to refactor or improve existing code, check if you can phrase it as a code guideline. If it is possible, add it to the relevant Code Guidelines section in
AGENTS.md. -
After completing the task you MUST verify that the code you have written follows the Code Guidelines in this file.
Code Guidelines
System Design
client_v2 is a browser-resident SPA — it runs entirely client-side and has
no server process of its own. Design for the browser environment:
- Stateless client, stateful backend. The UI holds no durable state; all
configuration and data persist in the AdGuard Home backend via the
/controlHTTP API. Treat the browser as a thin view layer. - Single source of HTTP. All backend calls go through the orval-generated
typed client in
src/api/generated.ts(with thecustomFetchmutator insrc/api/customFetch.ts). Do not callfetchdirectly from components or stores — import a generated function instead. - No shared server memory. Multiple browser tabs / reloads are independent;
never rely on in-memory module state surviving a reload. Re-fetch data in
component
onMountrather than assuming a store is already populated. - Disposability. The app boots in the browser on demand; clean up
subscriptions and timers in
onCleanupto avoid leaks across route changes. - HashRouter routing. The main app uses
HashRouter; route paths are defined as constants insrc/components/Routes/Paths.ts. The three standalone pages (install,login,forgot_password) are separate entry points, not routes inside the main app. - Theme via attributes, not JS state. Theming is driven by the
data-themeattribute on<html>and CSS custom properties; switch themes throughsetUIThemeinsrc/helpers/helpers.tsx, not by toggling component-level classes. - Static asset output. The production build emits static files into
../build/static/which the Go binary embeds. Do not introduce runtime server requirements (Node, databases, etc.) — the built output must be plain static files.
Architecture
Universal design principles the codebase follows:
- Separation of Concerns — UI components, state stores, API access, and pure helpers live in distinct directories.
- Single Responsibility Principle — each store module owns one domain; each component one page or control.
- Dependency Direction — dependencies point downward: components → stores → api → backend. Stores never import components; api never imports stores.
- Explicit Boundaries — modules interact through named exports; no reaching into another module's internals.
- Data Flow Clarity — user action → store action function → generated
API function →
setState→ reactive UI update. Data moves in one predictable path. - Minimize Coupling, Maximize Cohesion — stores are self-contained and imported directly (no Context providers); components depend on narrow store exports.
- Make Invalid States Impossible — domain types in
initialState.tsconstrain shapes; form builders (e.g.buildClientConfig) normalize/sanitize before submission. - Observability Built-in — errors surface as user-facing toasts via the
toastsstore;console.warn/console.errorare allowed butconsole.logis lint-disallowed. - Keep It Boring — prefer SolidJS primitives (
createSignal,createMemo,createStore) over clever abstractions.
The easiest way to achieve these principles is layered architecture. This project's layers, from top to bottom:
| Layer | Responsibility | Examples |
|---|---|---|
| Components | Render UI, handle user interaction | src/components/Dashboard/, src/components/Clients/ |
| Common UI | Reusable controls and primitives | src/common/controls/Select/, src/common/ui/Button/ |
| Stores | Domain state + async actions | src/stores/clients.ts, src/stores/queryLogs.ts |
| API | HTTP transport to backend | src/api/generated.ts, src/api/customFetch.ts |
| Helpers | Pure utilities, validators | src/helpers/, src/lib/ |
Components (pages, controls)
↓
Stores (domain state + actions)
↓
API (generated functions → customFetch → fetch /control)
↓
AdGuard Home backend (Go)
Components may call stores and helpers. Stores may call the API. The API layer must not depend on stores or components. Helpers are pure and dependency-free.
Known exclusions (to be fixed):
src/store/types.tsis a leftover from the Redux era that only re-exports types frominitialState.ts. New code should import types directly frompanel/initialState; this shim exists for backward compatibility.
Code Quality
- Path aliases: Use the
panel/*alias for allsrc/imports (e.g.import { status } from 'panel/api/generated'). UseTwoskyfor the root.twosky.jsonconfig. Avoid deep relative paths (../../..). - Component conventions: PascalCase directories and component files.
Each component lives in its own directory with an
index.tsxand a co-located*.module.pcss. Sub-components go in ablocks/subfolder. - Props: Use
splitPropsfromsolid-jsto separate local props from forwarded props (seesrc/common/ui/Button). Name the props type after the component (e.g.ButtonProps) and export it when consumers need it; prefertypeoverinterface. Prefer explicit prop types overany. - CSS Modules: Import component styles as
s—import s from './Foo.module.pcss'— and reference classes ass.button,s[variant]. Compose conditional classes withclsx, imported ascn:import cn from 'clsx'(e.g.cn(s.button, s.primary, { [s.active]: on() })). - Hover styles: Wrap
:hoverstyling in@media (hover: hover)so touch devices do not keep a stuck highlight after a tap. Gate:hovertogether with:focuson row links, since a tap also focuses an<a>in Chrome; leave:focus-visibleoutlines ungated for keyboard users. - Colors: Never hardcode color hex values in
.pcssor inline styles. Always reference the CSS custom properties defined insrc/common/styles/vars.cssandsrc/common/styles/colors/*.css(var(--default-main-text),var(--default-page-background), …). Light and dark themes are toggled via thedata-themeattribute on<html>, so using the variables handles dark mode automatically. Prefer the semantic tokens (--default-item-divider,--default-product-icon,--default-error-link) over the raw palette values (--gray-30,--product-primary-50, …) so the element keeps the right context: an icon token for an icon, a link token for text. - Typography: Do not hardcode
font-size,line-height, orfont-weightin a.pcssfile. Use the typography classes frompanel/lib/themeviacn(...)—theme.title.h0–h6for headings andtheme.text.t1–t4for body copy, with thetheme.text.condenced/semibold/mediummodifiers as needed (e.g.cn(theme.text.t3, s.label)). The local class then carries only layout, never the same properties. - No inline styles: Do not use the
styleattribute on elements. All styling belongs in co-located CSS Modules (.module.pcss) using class names. If a value must be dynamic, drive it through a CSS custom property or a conditional class viacn(...), not an inlinestyle. Exception: a single computed pixel offset for a dragging/positioning edge case may be acceptable when no class-based solution exists — justify it in a comment. - Comments: Write comments only where they earn their place, and keep
them useful and compact — one or two lines explaining a non-obvious
why: a workaround, a browser quirk, or a cross-file constraint a
reader would otherwise break. Do not narrate what a rule or function
does, restate selector/property names, or spell out measurements,
arithmetic, and design history; that belongs with the value it
constrains or in the PR description. Applies to
.pcss,.tsx, and tests alike. Delete a comment once the code it describes changes. - Reactivity: Use
createSignalfor local state,createMemofor derived values,createEffectfor side effects,onMountfor initial data fetches, andonCleanupfor teardown. Do not read signals imperatively inside event handlers withoutuntrackwhen needed. - Memo placement in
<Tabs>children: Don't put acreateMemothat reads an async store inside a component rendered as<Tabs tabs={[...]}>content.intl.getMessage()calls in thetabsarray subscribe tolang()— when it changes SolidJS swaps the child instance, disposing its memos. Instead, lift the memo to the parent and pass the value as a prop. If the child uses that prop in arenderclosure inside anothercreateMemo(e.g., table columns), access the prop in the memo body so SolidJS tracks the dependency. - Stores: Module-scoped
createStoresingletons exported directly — no Context/Provider. Async actions set aprocessing*flag, call generated API functions, thensetState. Errors are reported viaaddErrorToast. - Naming: Files
PascalCase.tsxfor components,camelCase.tsfor stores/helpers. CSS module files*.module.pcss. - Error handling: API errors throw from
customFetch.ts; store actions catch and surface a toast. Do not swallow errors silently. - Logging:
console.warnandconsole.errorare allowed;console.logis disallowed by ESLint. - IP and CIDR validation: Validate addresses and ranges with the
ipaddr.js-backed helpers inpanel/helpers/helpers(isValidIpv6,isValidCidr,isIpInCidr), not with hand-rolled regexes. IPv6 syntax is too easy to get subtly wrong — embedded dotted-decimal IPv4, zone IDs, prefix-length bounds — and a wrong pattern fails silently. - Static analysis gates: ESLint, Prettier, and
tsc --noEmitmust pass.@typescript-eslint/no-explicit-anyis off (legacy), but prefer concrete types for new code. Unused vars must be prefixed with_. - Avoid non-null assertions: Do not use the TypeScript
!operator. Use type narrowing, optional chaining, or SolidJS control-flow render props to guarantee values are defined. - Do not modify linter/formatter configs without justification; they enforce the conventions above.
Testing
- Unit tests: Vitest, configured in
vitest.config.ts(jsdom environment,vite-plugin-solid). Test files live undersrc/__tests__/and match*.{test,spec}.{ts,tsx}. - Component tests: Use
@solidjs/testing-library—render(() => <Comp/>),screen.getByTestId,fireEvent/userEvent. Setup filesrc/__tests__/setup.tsauto-cleans after each test and adds@testing-library/jest-dommatchers. - Store/helper tests: Call exported action/builder functions directly and
assert on returned values or state (e.g.
src/__tests__/clientForm/buildClientConfig.test.ts). Mock generated API functions when a test would otherwise hit the network. - Naming: Mirror the source path under
__tests__/(e.g.stores/clients.ts→__tests__/stores/clients.test.ts). - Never repeat user-facing copy in a test.
src/__locales/en.jsonowns the wording, so assert throughpanel/__tests__/helpers/copy:copy('key', values?)— the value a function returns (validator message, store field).copyInDom('key', values?)— the same text asgetByTextand friends see it, with whitespace collapsed (some values use a non-breaking space).createIntlMock()— backsvi.mock('panel/common/intl', …)from the base locale, so components render real copy and a renamed key throws instead of silently rendering the key itself. Use literal strings only for fixture data (a domain, an IP address, or a test-only label).no-inline-copy.spec.tsfails the build when a literal matches a base-locale value, so this is enforced, not just requested.
- Assert whole-config payloads. The backend replaces the entire TLS
config on every save (
POST /control/tls/configureis PUT, not PATCH), so a field missing from an expected payload is a field left behind on the server. Spell the payload out in the test instead of deriving it from the defaults. - E2E tests: Playwright, configured in
playwright.config.ts(testDir: ./tests/e2e, base URLhttp://127.0.0.1:3000). Specs run against a real AdGuard Home backend prepared byscripts/prepareConfig.mjs. E2E tests are not part ofnpm run check; run them explicitly withnpm run test:e2e. - All unit tests must pass before merge (
npm run test).
Dependency Management
- Pin all dependency versions explicitly. Do not introduce version ranges that allow automatic upgrades to untested versions.
- Prefer vanilla solutions. Use the browser's built-in APIs (
fetch,crypto.randomUUIDviananoid, CSS custom properties) when they adequately solve the problem. Only add a dependency when it provides significant value over a vanilla implementation. - Reputable sources only. Dependencies must come from well-established, actively maintained projects.
- Avoid unpopular libraries. Do not add niche or obscure packages with limited community adoption.
- Minimize dependency count. Each new dependency increases bundle size and maintenance burden; justify every addition.
- Use the latest stable version when adding a new dependency — check the npm registry for the current stable release rather than copying a stale version.
Rationale: Fewer, well-vetted dependencies reduce security vulnerabilities, supply-chain risk, and long-term maintenance cost.
Known exclusions (to be fixed):
- Most dependencies in
package.jsonuse caret ranges (^) rather than exact pins. This should be tightened to exact versions over time. lodashis a large dependency; prefer importing only the specific functions needed or replacing with native equivalents where possible.
Configuration & Documentation
- Runtime configuration: The dev server (
webpack.dev.js) reads the backend host/port from the rootAdGuardHome.yaml(gitignored, local config) and proxies/controlrequests to it. The dev server runs onbind_port + 8000(overridable viaDEV_SERVER_PORT), NOT webpack's default8080. The AdGuard Home instance itself is reachable athttp://127.0.0.1:<bind_port>/— commonly3001or80. - Build configuration:
BUILD_ENV(dev/prod) is set viacross-envin npm scripts and read fromconstants.js.BASE_URL = 'control'is the API path prefix. - Path aliases: Defined in
tsconfig.json(panel/*,Twosky) and consumed by Webpack, Vitest, and ESLint. Updatetsconfig.jsonwhen adding new aliases. - i18n: Locale JSON files live in
src/__locales/. The base locale and language list come from the root.twosky.json(via theTwoskyalias insrc/helpers/twosky.ts). Runnpm run translations:checkafter editing translation files. - Documentation to keep in sync: Update this
AGENTS.mdProject Structure when directories change. UpdateCHANGELOG.md(root) for user-facing changes following the config inchangelog.config.js. - No secrets in code. Never hardcode credentials, tokens, or API keys. The app authenticates at runtime via the backend login flow.
Markdown Formatting
All Markdown files MUST follow these formatting rules:
-
Line length: Keep lines at most 80 characters, but don't overwrap the lines artificially short just to hit the limit, keep them close to 80 characters where possible. This is not a hard lint gate, but SHOULD be followed for readability. Lines inside fenced code blocks are exempt from this limit.
-
Unordered lists: Use dashes (
-) for bullet points. Indent nested list items by 4 spaces. -
Continuation lines: When a list item wraps to the next line, align the continuation with the first character of the item text, not the list marker. This applies to all list types (ordered and unordered).
-
Emphasis: Use asterisks (
*) for emphasis (*italic*,**bold**). Do NOT use underscores. -
Headings: Duplicate heading names are allowed only among sibling headings (same parent level). Avoid duplicates across different levels.
-
Inline HTML: Avoid raw HTML in Markdown. The only allowed elements are
<a>,<p>,<details>,<summary>, and<img>. -
Trailing spaces: Do NOT leave trailing whitespace on any line. Do NOT use two-space line breaks — use a blank line instead.
-
Bare URLs: Bare URLs are permitted and do not need to be wrapped in angle brackets.
-
Table formatting: Align table columns with padding when the table fits within 80 characters. If the table exceeds 80 characters or triggers an MD060 linter warning, switch to a compact format using single spaces only. This applies to the separator row as well—it should be written as
| --- |, not|--|.Example of correct layout:
| Col1 | Col2 | | ------ | ------ | | Value1 | Value2 |Do NOT use extra padding or alignment characters beyond single spaces.
Rationale: Uniform Markdown formatting improves readability for both humans and AI agents that consume project documentation.
Other
Accessibility
All interactive components must be keyboard-operable and screen-reader friendly:
- Use semantic HTML controls for every interaction (
button,a,input,select). Do not attachonClicktodiv,span, or icon elements — wrap them in a<button>. - Every interactive element must have an accessible name. Icon-only buttons
must carry
aria-label={intl.getMessage('key')}— the label string must be a dedicated translation key (see existing usage inPagination.tsx,CopiedText.tsx,Input.tsx). - Visible labels pair a
<label htmlFor={id}>with a matching inputid. - Focusable rows acting as buttons must be keyboard-activatable:
tabindexof0plus anonKeyDownhandler for Enter/Space. - Any
idused for labeling must be unique per rendered instance.
Translations
- All user-facing strings must be localized. Add new keys to the base locale
src/__locales/en.json; other locales are managed externally via Twosky. - Locale imports are generated — do not hand-edit.
npm run locales:generateregeneratessrc/common/intl/locales.generated.tsfrom the language list in.twosky.json; it does not read translation string content. Do NOT run it after editing translation strings (e.g. adding a key toen.json) — it is only needed when.twosky.jsonchanges or a locale JSON file is added or removed. Use the side-effect-freenpm run locales:checkto verify freshness instead. - Access translations via the
intlobject frompanel/common/intl:intl.getMessage('key', values?)— returns a plain localized string. Pass interpolation values as the second argument, referenced inside the translation value as%name%placeholders (e.g.intl.getMessage('client_blocked', { name })→"%name% blocked").intl.getPlural('key', number, values?)— returns a pluralized string selected bynumber. Plural values are pipe-delimited into three forms:zero | singular | plural, each separated by|. The leading|denotes the zero form (used whennumber === 0); it is usually left empty, which is why values start with|. Use%count%(and other%name%) placeholders for the interpolated count, e.g."| %count% query total | %count% queries total". Always pass the count as the second argument and reuse the existing plural key rather than branching into separategetMessagecalls (seeDashboard/blocks/Header/Header.tsx,Settings/helpers.ts).
- Never use dynamic keys. Always pass a string literal as the
keyargument togetMessage/getPlural. Do not build keys from variables, template literals, or concatenation (e.g.getMessage(\btn_${action}`)`). Dynamic keys cannot be statically detected, so the translation tooling will not pick them up and the strings will go untranslated. - Translate while rendering, never at module load.
getMessage/getPluraltrack the language signal, but a call at module scope — or in a plainconstin a component body, which SolidJS runs once per mount — happens outside any tracked scope, so the string keeps whichever language was active when it ran. Resolve the translation where it is rendered: inline in JSX, by reading acreateMemoin the JSX expression (<For each={items()}>,options={options()}), or by calling a per-render accessor (label: () => intl.getMessage('key'), rendered asconfig.label()— seeDnsSettings/Cache/blocks/CacheInputDialog.tsx).For's row mapper runs untracked, so only itseachexpression is reactive: a value read inside the row body must be resolved at the JSX read site, not in the mapper. - Do not add duplicate keys to any locale file.
- Run
npm run translations:checkafter adding or editing translation keys. - Every
aria-*string must have its own dedicated translation key.